codemirror-toolbar.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  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 ReviewModeSwitcher from '@/features/review-panel/components/review-mode-switcher'
  22. import SwitchToPDFButton from './switch-to-pdf-button'
  23. import { DetacherSynctexControl } from '../../pdf-preview/components/detach-synctex-control'
  24. import DetachCompileButtonWrapper from '../../pdf-preview/components/detach-compile-button-wrapper'
  25. import { isVisual } from '../extensions/visual/visual'
  26. import { language } from '@codemirror/language'
  27. import { minimumListDepthForSelection } from '../utils/tree-operations/ancestors'
  28. import { debugConsole } from '@/utils/debugging'
  29. import { useTranslation } from 'react-i18next'
  30. import { ToggleSearchButton } from '@/features/source-editor/components/toolbar/toggle-search-button'
  31. import ReviewPanelHeader from '@/features/review-panel/components/review-panel-header'
  32. import useReviewPanelLayout from '@/features/review-panel/hooks/use-review-panel-layout'
  33. import Breadcrumbs from '@/features/source-editor/extensions/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 { useProjectContext } from '@/shared/context/project-context'
  38. import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
  39. import { useLayoutContext } from '@/shared/context/layout-context'
  40. import ReviewPanelHeaderBuffer from '@/features/review-panel/components/review-panel-header-buffer'
  41. import { useAreTabsEnabled } from '@/features/ide-react/hooks/use-are-tabs-enabled'
  42. const sourceEditorToolbarStartButtons = importOverleafModules(
  43. 'sourceEditorToolbarStartButtons'
  44. ) as { import: { default: ElementType }; path: string }[]
  45. const sourceEditorToolbarComponents = importOverleafModules(
  46. 'sourceEditorToolbarComponents'
  47. ) as { import: { default: ElementType }; path: string }[]
  48. const sourceEditorToolbarEndButtons = importOverleafModules(
  49. 'sourceEditorToolbarEndButtons'
  50. ) as { import: { default: ElementType }; path: string }[]
  51. export const CodeMirrorToolbar = () => {
  52. const view = useCodeMirrorViewContext()
  53. const panel = getPanel(view, createToolbarPanel)
  54. if (!panel) {
  55. return null
  56. }
  57. return createPortal(<Toolbar />, panel.dom)
  58. }
  59. const Toolbar = memo(function Toolbar() {
  60. const { t } = useTranslation()
  61. const state = useCodeMirrorStateContext()
  62. const view = useCodeMirrorViewContext()
  63. const {
  64. userSettings: { breadcrumbs },
  65. } = useUserSettingsContext()
  66. const visualPreviewEnabled = useFeatureFlag('visual-preview')
  67. const isToolbarMigration = useFeatureFlag('writefull-toolbar-migration')
  68. const { features } = useProjectContext()
  69. const { focusMode } = useLayoutContext()
  70. const [overflowed, setOverflowed] = useState(false)
  71. const overflowedItemsRef = useRef<Set<string>>(new Set())
  72. const languageName = state.facet(language)?.name
  73. const visual = isVisual(view)
  74. const listDepth = minimumListDepthForSelection(state)
  75. const { showHeader: showReviewPanelHeader } = useReviewPanelLayout()
  76. const {
  77. open: overflowOpen,
  78. onToggle: setOverflowOpen,
  79. ref: overflowRef,
  80. } = useDropdown()
  81. const buildOverflow = useCallback(
  82. (element: Element) => {
  83. debugConsole.log('recalculating toolbar overflow')
  84. setOverflowOpen(false)
  85. setOverflowed(true)
  86. overflowedItemsRef.current = new Set()
  87. const buttonGroups = [
  88. ...element.querySelectorAll<HTMLDivElement>('[data-overflow]'),
  89. ].reverse()
  90. // restore all the overflowed items
  91. for (const buttonGroup of buttonGroups) {
  92. buttonGroup.classList.remove('overflow-hidden')
  93. }
  94. // find all the available items
  95. for (const buttonGroup of buttonGroups) {
  96. if (element.scrollWidth <= element.clientWidth) {
  97. break
  98. }
  99. // add this item to the overflow
  100. overflowedItemsRef.current.add(buttonGroup.dataset.overflow!)
  101. buttonGroup.classList.add('overflow-hidden')
  102. }
  103. setOverflowed(overflowedItemsRef.current.size > 0)
  104. },
  105. [setOverflowOpen]
  106. )
  107. // calculate overflow when the container resizes
  108. const { elementRef, resizeRef } = useResizeObserver(buildOverflow)
  109. // calculate overflow when `languageName` or `visual` change
  110. useEffect(() => {
  111. if (resizeRef.current) {
  112. buildOverflow(resizeRef.current.element)
  113. }
  114. }, [buildOverflow, languageName, listDepth, resizeRef, visual])
  115. // calculate overflow when toolbar content changes
  116. const observerRef = useRef<MutationObserver | null>(null)
  117. const handleToolbar = useCallback(
  118. (node: HTMLDivElement) => {
  119. // register the resize observer on the toolbar node
  120. elementRef(node)
  121. if (observerRef.current) {
  122. observerRef.current.disconnect()
  123. observerRef.current = null
  124. }
  125. if (!('MutationObserver' in window)) {
  126. return
  127. }
  128. if (node) {
  129. observerRef.current = new MutationObserver(() => {
  130. if (resizeRef.current) {
  131. buildOverflow(resizeRef.current.element)
  132. }
  133. })
  134. observerRef.current.observe(node, { childList: true, subtree: true })
  135. }
  136. },
  137. [buildOverflow, elementRef, resizeRef]
  138. )
  139. // calculate overflow when active element changes to/from inside a table
  140. const insideTable = document.activeElement?.closest(
  141. '.table-generator-help-modal,.table-generator,.table-generator-width-modal'
  142. )
  143. useEffect(() => {
  144. if (resizeRef.current) {
  145. buildOverflow(resizeRef.current.element)
  146. }
  147. }, [buildOverflow, insideTable, resizeRef])
  148. const showActions = !state.readOnly && !insideTable
  149. const tabsVisible = useAreTabsEnabled()
  150. if (focusMode) {
  151. return null
  152. }
  153. return (
  154. <>
  155. {showReviewPanelHeader &&
  156. (tabsVisible ? <ReviewPanelHeaderBuffer /> : <ReviewPanelHeader />)}
  157. <div
  158. id="ol-cm-toolbar-wrapper"
  159. className={classNames('ol-cm-toolbar-wrapper', {
  160. 'ol-cm-toolbar-wrapper-indented': showReviewPanelHeader,
  161. // The border is only needed when the header is flush with the
  162. // toolbar, which is the case when tabs are disabled and the review
  163. // panel is shown
  164. 'ol-cm-toolbar-wrapper-needs-border':
  165. showReviewPanelHeader && !tabsVisible,
  166. })}
  167. >
  168. <div
  169. role="toolbar"
  170. aria-label={t('toolbar_editor')}
  171. className="ol-cm-toolbar toolbar-editor"
  172. ref={handleToolbar}
  173. >
  174. {showActions &&
  175. sourceEditorToolbarStartButtons.map(
  176. ({ import: { default: Component }, path }) => (
  177. <Component key={path} />
  178. )
  179. )}
  180. {showActions && (
  181. <ToolbarItems
  182. state={state}
  183. languageName={languageName}
  184. visual={visual}
  185. listDepth={listDepth}
  186. />
  187. )}
  188. <div className="ol-cm-toolbar-button-group ol-cm-toolbar-stretch">
  189. {showActions && (
  190. <ToolbarOverflow
  191. overflowed={overflowed}
  192. overflowOpen={overflowOpen}
  193. setOverflowOpen={setOverflowOpen}
  194. overflowRef={overflowRef}
  195. >
  196. <ToolbarItems
  197. state={state}
  198. overflowed={overflowedItemsRef.current}
  199. languageName={languageName}
  200. visual={visual}
  201. listDepth={listDepth}
  202. />
  203. </ToolbarOverflow>
  204. )}
  205. </div>
  206. <div className="ol-cm-toolbar-button-group ol-cm-toolbar-end">
  207. {!visualPreviewEnabled && <EditorSwitch />}
  208. {/* trackChangesVisible controls provider/UI availability; trackChanges
  209. (checked inside the switcher) controls the actual feature entitlement.
  210. Users with trackChangesVisible:true but trackChanges:false see the
  211. switcher and get an upgrade modal when clicking "Reviewing". */}
  212. {isToolbarMigration && features.trackChangesVisible && (
  213. <ReviewModeSwitcher />
  214. )}
  215. {sourceEditorToolbarEndButtons.map(
  216. ({ import: { default: Component }, path }) => (
  217. <Component key={path} />
  218. )
  219. )}
  220. <ToggleSearchButton state={state} />
  221. <SwitchToPDFButton />
  222. <DetacherSynctexControl />
  223. <DetachCompileButtonWrapper />
  224. </div>
  225. </div>
  226. {sourceEditorToolbarComponents.map(
  227. ({ import: { default: Component }, path }) => (
  228. <Component key={path} />
  229. )
  230. )}
  231. {breadcrumbs && <Breadcrumbs />}
  232. </div>
  233. </>
  234. )
  235. })