codemirror-toolbar.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import {
  2. ElementType,
  3. memo,
  4. useCallback,
  5. useEffect,
  6. useRef,
  7. useState,
  8. } from 'react'
  9. import { createPortal } from 'react-dom'
  10. import {
  11. useCodeMirrorStateContext,
  12. useCodeMirrorViewContext,
  13. } from './codemirror-context'
  14. import { useResizeObserver } from '@/shared/hooks/use-resize-observer'
  15. import { ToolbarItems } from './toolbar/toolbar-items'
  16. import { ToolbarOverflow } from './toolbar/overflow'
  17. import useDropdown from '../../../shared/hooks/use-dropdown'
  18. import { getPanel } from '@codemirror/view'
  19. import { createToolbarPanel } from '../extensions/toolbar/toolbar-panel'
  20. import EditorSwitch from './editor-switch'
  21. import SwitchToPDFButton from './switch-to-pdf-button'
  22. import { DetacherSynctexControl } from '../../pdf-preview/components/detach-synctex-control'
  23. import DetachCompileButtonWrapper from '../../pdf-preview/components/detach-compile-button-wrapper'
  24. import { isVisual } from '../extensions/visual/visual'
  25. import { language } from '@codemirror/language'
  26. import { minimumListDepthForSelection } from '../utils/tree-operations/ancestors'
  27. import { debugConsole } from '@/utils/debugging'
  28. import { useTranslation } from 'react-i18next'
  29. import { ToggleSearchButton } from '@/features/source-editor/components/toolbar/toggle-search-button'
  30. import ReviewPanelHeader from '@/features/review-panel/components/review-panel-header'
  31. import useReviewPanelLayout from '@/features/review-panel/hooks/use-review-panel-layout'
  32. import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
  33. import Breadcrumbs from '@/features/ide-redesign/components/breadcrumbs'
  34. import classNames from 'classnames'
  35. import { useUserSettingsContext } from '@/shared/context/user-settings-context'
  36. import { useFeatureFlag } from '@/shared/context/split-test-context'
  37. import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
  38. const sourceEditorToolbarComponents = importOverleafModules(
  39. 'sourceEditorToolbarComponents'
  40. ) as { import: { default: ElementType }; path: string }[]
  41. export const CodeMirrorToolbar = () => {
  42. const view = useCodeMirrorViewContext()
  43. const panel = getPanel(view, createToolbarPanel)
  44. if (!panel) {
  45. return null
  46. }
  47. return createPortal(<Toolbar />, panel.dom)
  48. }
  49. const Toolbar = memo(function Toolbar() {
  50. const { t } = useTranslation()
  51. const state = useCodeMirrorStateContext()
  52. const view = useCodeMirrorViewContext()
  53. const {
  54. userSettings: { breadcrumbs },
  55. } = useUserSettingsContext()
  56. const visualPreviewEnabled = useFeatureFlag('visual-preview')
  57. const [overflowed, setOverflowed] = useState(false)
  58. const overflowedItemsRef = useRef<Set<string>>(new Set())
  59. const languageName = state.facet(language)?.name
  60. const visual = isVisual(view)
  61. const listDepth = minimumListDepthForSelection(state)
  62. const newEditor = useIsNewEditorEnabled()
  63. const { showHeader: showReviewPanelHeader } = useReviewPanelLayout()
  64. const {
  65. open: overflowOpen,
  66. onToggle: setOverflowOpen,
  67. ref: overflowRef,
  68. } = useDropdown()
  69. const buildOverflow = useCallback(
  70. (element: Element) => {
  71. debugConsole.log('recalculating toolbar overflow')
  72. setOverflowOpen(false)
  73. setOverflowed(true)
  74. overflowedItemsRef.current = new Set()
  75. const buttonGroups = [
  76. ...element.querySelectorAll<HTMLDivElement>('[data-overflow]'),
  77. ].reverse()
  78. // restore all the overflowed items
  79. for (const buttonGroup of buttonGroups) {
  80. buttonGroup.classList.remove('overflow-hidden')
  81. }
  82. // find all the available items
  83. for (const buttonGroup of buttonGroups) {
  84. if (element.scrollWidth <= element.clientWidth) {
  85. break
  86. }
  87. // add this item to the overflow
  88. overflowedItemsRef.current.add(buttonGroup.dataset.overflow!)
  89. buttonGroup.classList.add('overflow-hidden')
  90. }
  91. setOverflowed(overflowedItemsRef.current.size > 0)
  92. },
  93. [setOverflowOpen]
  94. )
  95. // calculate overflow when the container resizes
  96. const { elementRef, resizeRef } = useResizeObserver(buildOverflow)
  97. // calculate overflow when `languageName` or `visual` change
  98. useEffect(() => {
  99. if (resizeRef.current) {
  100. buildOverflow(resizeRef.current.element)
  101. }
  102. }, [buildOverflow, languageName, resizeRef, visual])
  103. // calculate overflow when buttons change
  104. const observerRef = useRef<MutationObserver | null>(null)
  105. const handleButtons = useCallback(
  106. (node: HTMLDivElement) => {
  107. if (!('MutationObserver' in window)) {
  108. return
  109. }
  110. if (node) {
  111. observerRef.current = new MutationObserver(() => {
  112. if (resizeRef.current) {
  113. buildOverflow(resizeRef.current.element)
  114. }
  115. })
  116. observerRef.current.observe(node, { childList: true })
  117. } else if (observerRef.current) {
  118. observerRef.current.disconnect()
  119. }
  120. },
  121. [buildOverflow, resizeRef]
  122. )
  123. // calculate overflow when active element changes to/from inside a table
  124. const insideTable = document.activeElement?.closest(
  125. '.table-generator-help-modal,.table-generator,.table-generator-width-modal'
  126. )
  127. useEffect(() => {
  128. if (resizeRef.current) {
  129. buildOverflow(resizeRef.current.element)
  130. }
  131. }, [buildOverflow, insideTable, resizeRef])
  132. const showActions = !state.readOnly && !insideTable
  133. return (
  134. <>
  135. {newEditor && showReviewPanelHeader && <ReviewPanelHeader />}
  136. <div
  137. id="ol-cm-toolbar-wrapper"
  138. className={classNames('ol-cm-toolbar-wrapper', {
  139. 'ol-cm-toolbar-wrapper-indented': newEditor && showReviewPanelHeader,
  140. })}
  141. >
  142. <div
  143. role="toolbar"
  144. aria-label={t('toolbar_editor')}
  145. className="ol-cm-toolbar toolbar-editor"
  146. ref={elementRef}
  147. >
  148. {!visualPreviewEnabled && <EditorSwitch />}
  149. {showActions && (
  150. <ToolbarItems
  151. state={state}
  152. languageName={languageName}
  153. visual={visual}
  154. listDepth={listDepth}
  155. />
  156. )}
  157. <div className="ol-cm-toolbar-button-group ol-cm-toolbar-stretch">
  158. {showActions && (
  159. <ToolbarOverflow
  160. overflowed={overflowed}
  161. overflowOpen={overflowOpen}
  162. setOverflowOpen={setOverflowOpen}
  163. overflowRef={overflowRef}
  164. >
  165. <ToolbarItems
  166. state={state}
  167. overflowed={overflowedItemsRef.current}
  168. languageName={languageName}
  169. visual={visual}
  170. listDepth={listDepth}
  171. />
  172. </ToolbarOverflow>
  173. )}
  174. </div>
  175. <div
  176. className="ol-cm-toolbar-button-group ol-cm-toolbar-end"
  177. ref={handleButtons}
  178. >
  179. <ToggleSearchButton state={state} />
  180. <SwitchToPDFButton />
  181. <DetacherSynctexControl />
  182. <DetachCompileButtonWrapper />
  183. </div>
  184. </div>
  185. {sourceEditorToolbarComponents.map(
  186. ({ import: { default: Component }, path }) => (
  187. <Component key={path} />
  188. )
  189. )}
  190. {newEditor && breadcrumbs && <Breadcrumbs />}
  191. </div>
  192. </>
  193. )
  194. })