codemirror-toolbar.tsx 6.3 KB

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