use-context-menu-items.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. import {
  2. useCodeMirrorStateContext,
  3. useCodeMirrorViewContext,
  4. } from '@/features/source-editor/components/codemirror-context'
  5. import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
  6. import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
  7. import { useProjectContext } from '@/shared/context/project-context'
  8. import useSynctex from '@/features/pdf-preview/hooks/use-synctex'
  9. import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
  10. import { useLayoutContext } from '@/shared/context/layout-context'
  11. import { useFeatureFlag } from '@/shared/context/split-test-context'
  12. import { useTranslation } from 'react-i18next'
  13. import { useCallback, useEffect, useRef } from 'react'
  14. import {
  15. formatShortcut,
  16. useCommandRegistry,
  17. } from '@/features/ide-react/context/command-registry-context'
  18. import { closeContextMenuEffect } from '../extensions/context-menu'
  19. import * as commands from '../extensions/toolbar/commands'
  20. import {
  21. cutSelection,
  22. copySelection,
  23. pasteWithoutFormatting,
  24. pasteWithFormatting,
  25. } from '../commands/clipboard'
  26. import { isVisual } from '../extensions/visual/visual'
  27. import { useEditorContext } from '@/shared/context/editor-context'
  28. import { useTrackingChangesMode } from '@/shared/hooks/use-tracking-changes-mode'
  29. export const useContextMenuItems = () => {
  30. const view = useCodeMirrorViewContext()
  31. const state = useCodeMirrorStateContext()
  32. const permissions = usePermissionsContext()
  33. const { wantTrackChanges } = useEditorPropertiesContext()
  34. const { syncToPdf, syncToPdfInFlight, canSyncToPdf } = useSynctex()
  35. const { pdfUrl, pdfViewer } = useDetachCompileContext()
  36. const { detachRole } = useLayoutContext()
  37. const visualPreviewEnabled = useFeatureFlag('visual-preview')
  38. const { t } = useTranslation()
  39. const { shortcuts } = useCommandRegistry()
  40. const { features } = useProjectContext()
  41. const requestedPdfSyncRef = useRef(false)
  42. const { setShowUpgradeModal } = useEditorContext()
  43. const trackingChangesMode = useTrackingChangesMode()
  44. const isReview = trackingChangesMode === 'review'
  45. const closeMenu = useCallback(() => {
  46. view.dispatch({ effects: closeContextMenuEffect.of(null) })
  47. }, [view])
  48. // Handle closing the menu when it loses focus, e.g. click outside the editor
  49. const onToggle = (show: boolean) => {
  50. if (!show) {
  51. // Skip closing if a sync to PDF is in flight
  52. if (requestedPdfSyncRef.current) {
  53. return
  54. }
  55. closeMenu()
  56. }
  57. }
  58. // Wait for syncToPdf to finish before closing the menu
  59. useEffect(() => {
  60. if (requestedPdfSyncRef.current && !syncToPdfInFlight) {
  61. closeMenu()
  62. // Clear the synchronous flag when the close completes
  63. requestedPdfSyncRef.current = false
  64. }
  65. }, [syncToPdfInFlight, closeMenu])
  66. const hasSelection = !state.selection.main.empty
  67. const canEdit = permissions.write || permissions.trackedWrite
  68. const jumpToLocationInPdfEnabled =
  69. pdfUrl &&
  70. pdfViewer !== 'native' &&
  71. !detachRole &&
  72. !visualPreviewEnabled &&
  73. canSyncToPdf
  74. const wrapForContextMenu = useCallback(
  75. (command: () => Promise<boolean> | boolean) => async () => {
  76. const result = await command()
  77. if (result !== false) {
  78. view.focus()
  79. closeMenu()
  80. }
  81. },
  82. [view, closeMenu]
  83. )
  84. const inVisualMode = isVisual(view)
  85. const handleCut = wrapForContextMenu(() => cutSelection(view))
  86. const handleCopy = wrapForContextMenu(() => copySelection(view))
  87. const handlePaste = wrapForContextMenu(() =>
  88. inVisualMode ? pasteWithFormatting(view) : pasteWithoutFormatting(view)
  89. )
  90. const handlePasteSpecial = wrapForContextMenu(() =>
  91. inVisualMode ? pasteWithoutFormatting(view) : pasteWithFormatting(view)
  92. )
  93. const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view))
  94. const handleToggleTrackChanges = wrapForContextMenu(() => {
  95. // Matching the logic in review toggle to ensure consistency for server pro
  96. if (!features.trackChanges && !isReview) {
  97. setShowUpgradeModal(true)
  98. return true
  99. }
  100. window.dispatchEvent(new Event('toggle-track-changes'))
  101. return true
  102. })
  103. const handleComment = wrapForContextMenu(() => {
  104. commands.addComment()
  105. return true
  106. })
  107. // Sync-to-PDF is special: it needs to wait for async completion before closing
  108. const handleSyncToPdf = useCallback(() => {
  109. requestedPdfSyncRef.current = true
  110. syncToPdf()
  111. view.focus()
  112. }, [syncToPdf, view])
  113. const getShortcut = useCallback(
  114. (id: string) => {
  115. const shortcut = shortcuts[id]?.[0]
  116. return shortcut ? formatShortcut(shortcut) : undefined
  117. },
  118. [shortcuts]
  119. )
  120. return {
  121. closeMenu,
  122. onToggle,
  123. menuItems: [
  124. {
  125. label: t('cut'),
  126. handler: handleCut,
  127. disabled: false,
  128. show: canEdit,
  129. shortcut: getShortcut('cut'),
  130. },
  131. {
  132. label: t('copy'),
  133. handler: handleCopy,
  134. disabled: false,
  135. show: true,
  136. shortcut: getShortcut('copy'),
  137. },
  138. {
  139. label: t('paste'),
  140. handler: handlePaste,
  141. disabled: false,
  142. show: canEdit,
  143. shortcut: getShortcut('paste'),
  144. },
  145. {
  146. label: inVisualMode
  147. ? t('paste_without_formatting')
  148. : t('paste_with_formatting'),
  149. handler: handlePasteSpecial,
  150. disabled: false,
  151. show: canEdit,
  152. shortcut: inVisualMode ? getShortcut('paste-special') : undefined,
  153. },
  154. {
  155. label: t('delete'),
  156. handler: handleDelete,
  157. disabled: !hasSelection,
  158. show: canEdit,
  159. shortcut: undefined,
  160. },
  161. {
  162. label: t('jump_to_location_in_pdf'),
  163. handler: handleSyncToPdf,
  164. disabled: syncToPdfInFlight,
  165. separatorAbove: true,
  166. show: jumpToLocationInPdfEnabled,
  167. shortcut: undefined,
  168. },
  169. {
  170. label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
  171. handler: handleToggleTrackChanges,
  172. disabled: false,
  173. separatorAbove: true,
  174. show: canEdit && features.trackChangesVisible,
  175. shortcut: getShortcut('toggle-track-changes'),
  176. },
  177. {
  178. label: t('comment'),
  179. handler: handleComment,
  180. disabled: !hasSelection,
  181. show: permissions.comment,
  182. shortcut: getShortcut('insert-comment'),
  183. },
  184. ].filter(item => item.show),
  185. }
  186. }