editor-floating-menu.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import React, {
  2. ComponentType,
  3. FC,
  4. memo,
  5. useEffect,
  6. useRef,
  7. useState,
  8. } from 'react'
  9. import ReactDOM from 'react-dom'
  10. import classNames from 'classnames'
  11. import { getTooltip } from '@codemirror/view'
  12. import importOverleafModules from '../../../macros/import-overleaf-module.macro'
  13. import {
  14. useCodeMirrorStateContext,
  15. useCodeMirrorViewContext,
  16. } from '@/features/source-editor/components/codemirror-context'
  17. import { reviewTooltipStateField } from '@/features/source-editor/extensions/review-tooltip'
  18. import usePreviousValue from '@/shared/hooks/use-previous-value'
  19. import { useLayoutContext } from '@/shared/context/layout-context'
  20. import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
  21. import AddCommentAction from './components/add-comment-action'
  22. import TrackedChangesActions from './components/tracked-changes-actions'
  23. const TOOLTIP_SHOW_DELAY = 120
  24. // Each default-exports a self-gating component.
  25. const editorFloatingMenuActions = importOverleafModules(
  26. 'editorFloatingMenuActions'
  27. ) as { import: { default: ComponentType }; path: string }[]
  28. const EditorFloatingMenu: FC = () => {
  29. const state = useCodeMirrorStateContext()
  30. const view = useCodeMirrorViewContext()
  31. const [show, setShow] = useState(true)
  32. const tooltipState = state.field(reviewTooltipStateField, false)?.tooltip
  33. const previousTooltipState = usePreviousValue(tooltipState)
  34. useEffect(() => {
  35. if (tooltipState !== null && previousTooltipState === null) {
  36. setShow(true)
  37. }
  38. }, [tooltipState, previousTooltipState])
  39. useEffect(() => {
  40. if (!show || !tooltipState) {
  41. return
  42. }
  43. const handleMouseDown = (event: MouseEvent) => {
  44. const target = event.target as Element | null
  45. if (
  46. !view.contentDOM.contains(target) &&
  47. !target?.closest?.('.review-tooltip-menu-container') &&
  48. !target?.closest?.('.modal') &&
  49. !target?.closest?.('.modal-backdrop')
  50. ) {
  51. setShow(false)
  52. }
  53. }
  54. document.addEventListener('mousedown', handleMouseDown)
  55. return () => {
  56. document.removeEventListener('mousedown', handleMouseDown)
  57. }
  58. }, [show, tooltipState, view])
  59. if (!show || !tooltipState) {
  60. return null
  61. }
  62. const tooltipView = getTooltip(view, tooltipState)
  63. if (!tooltipView) {
  64. return null
  65. }
  66. return ReactDOM.createPortal(<EditorFloatingMenuContent />, tooltipView.dom)
  67. }
  68. const EditorFloatingMenuContent = memo(function EditorFloatingMenuContent() {
  69. const view = useCodeMirrorViewContext()
  70. const { reviewPanelOpen } = useLayoutContext()
  71. const { wantTrackChanges } = useEditorPropertiesContext()
  72. const [visible, setVisible] = useState(false)
  73. const menuRef = useRef<HTMLDivElement>(null)
  74. useEffect(() => {
  75. const measure = () => {
  76. view.requestMeasure({
  77. key: 'editor-floating-menu-position',
  78. read(view) {
  79. const cursorCoords = view.coordsAtPos(view.state.selection.main.head)
  80. if (!cursorCoords) {
  81. return
  82. }
  83. const menuHeight =
  84. menuRef.current?.getBoundingClientRect().height ?? 0
  85. const scrollDomRect = view.scrollDOM.getBoundingClientRect()
  86. const contentDomRect = view.contentDOM.getBoundingClientRect()
  87. const cursorCenterY = (cursorCoords.top + cursorCoords.bottom) / 2
  88. if (
  89. // Cursor scrolls out of view at the top
  90. cursorCoords.top < scrollDomRect.top ||
  91. // Cursor scrolls out of view at the bottom
  92. cursorCoords.top > scrollDomRect.bottom
  93. ) {
  94. return { visibility: 'hidden' as const }
  95. }
  96. return {
  97. position: 'fixed' as const,
  98. // Align centrally
  99. top: cursorCenterY - menuHeight / 2,
  100. right: window.innerWidth - contentDomRect.left,
  101. }
  102. },
  103. // Mutate the DOM directly rather than via state to avoid re-rendering
  104. // on every scroll frame.
  105. write(res) {
  106. const el = menuRef.current
  107. if (!el || !res) return
  108. // Only toggle visibility when off-screen
  109. if (res.visibility === 'hidden') {
  110. el.style.visibility = 'hidden'
  111. } else {
  112. el.style.visibility = ''
  113. el.style.position = res.position
  114. el.style.top = `${res.top}px`
  115. el.style.right = `${res.right}px`
  116. }
  117. },
  118. })
  119. }
  120. measure()
  121. // Re-center when the menu's own height changes (e.g. tracked-change
  122. // actions appear/disappear).
  123. const observer = new ResizeObserver(measure)
  124. if (menuRef.current) {
  125. observer.observe(menuRef.current)
  126. }
  127. // The scroller's box tracks the editor pane, so this catches pane/window
  128. // resizes. It doesn't fire on scroll, hence the separate scroll listener.
  129. observer.observe(view.scrollDOM)
  130. // Track the cursor as the editor scrolls.
  131. view.scrollDOM.addEventListener('scroll', measure)
  132. return () => {
  133. observer.disconnect()
  134. view.scrollDOM.removeEventListener('scroll', measure)
  135. }
  136. }, [view, reviewPanelOpen, wantTrackChanges])
  137. useEffect(() => {
  138. setVisible(false)
  139. const timeout = setTimeout(() => {
  140. setVisible(true)
  141. }, TOOLTIP_SHOW_DELAY)
  142. return () => {
  143. clearTimeout(timeout)
  144. }
  145. }, [])
  146. return (
  147. <div
  148. ref={menuRef}
  149. className={classNames('editor-floating-menu', {
  150. 'editor-floating-menu-visible': visible,
  151. })}
  152. >
  153. <AddCommentAction />
  154. <TrackedChangesActions />
  155. {editorFloatingMenuActions.map(
  156. ({ import: { default: Component }, path }) => (
  157. <React.Fragment key={path}>
  158. <div className="editor-floating-menu-divider" />
  159. <Component />
  160. </React.Fragment>
  161. )
  162. )}
  163. </div>
  164. )
  165. })
  166. export default EditorFloatingMenu