use-context-menu-items.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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 {
  37. detachRole,
  38. changeLayout,
  39. pdfLayout,
  40. view: ideView,
  41. } = useLayoutContext()
  42. const visualPreviewEnabled = useFeatureFlag('visual-preview')
  43. const { t } = useTranslation()
  44. const { shortcuts } = useCommandRegistry()
  45. const { features } = useProjectContext()
  46. const requestedPdfSyncRef = useRef(false)
  47. const { setShowUpgradeModal } = useEditorContext()
  48. const trackingChangesMode = useTrackingChangesMode()
  49. const isReview = trackingChangesMode === 'review'
  50. const closeMenu = useCallback(() => {
  51. view.dispatch({ effects: closeContextMenuEffect.of(null) })
  52. view.focus()
  53. }, [view])
  54. // Handle closing the menu when it loses focus, e.g. click outside the editor
  55. const onToggle = (show: boolean) => {
  56. if (!show) {
  57. // Skip closing if a sync to PDF is in flight
  58. if (requestedPdfSyncRef.current) {
  59. return
  60. }
  61. closeMenu()
  62. }
  63. }
  64. // Wait for syncToPdf to finish before closing the menu
  65. useEffect(() => {
  66. if (requestedPdfSyncRef.current && !syncToPdfInFlight) {
  67. closeMenu()
  68. // Clear the synchronous flag when the close completes
  69. requestedPdfSyncRef.current = false
  70. }
  71. }, [syncToPdfInFlight, closeMenu])
  72. const hasSelection = !state.selection.main.empty
  73. const canEdit = permissions.write || permissions.trackedWrite
  74. // Determine layout states for PDF sync functionality
  75. const isPdfDetached = detachRole === 'detacher'
  76. const isEditorOnly =
  77. pdfLayout === 'flat' && ideView === 'editor' && !isPdfDetached
  78. const jumpToLocationInPdfEnabled =
  79. pdfUrl && pdfViewer !== 'native' && !visualPreviewEnabled && canSyncToPdf
  80. const wrapForContextMenu = useCallback(
  81. (command: () => Promise<boolean> | boolean) => async () => {
  82. const result = await command()
  83. if (result !== false) {
  84. view.focus()
  85. closeMenu()
  86. }
  87. },
  88. [view, closeMenu]
  89. )
  90. const inVisualMode = isVisual(view)
  91. const handleCut = wrapForContextMenu(() => cutSelection(view))
  92. const handleCopy = wrapForContextMenu(() => copySelection(view))
  93. const handlePaste = wrapForContextMenu(() =>
  94. inVisualMode ? pasteWithFormatting(view) : pasteWithoutFormatting(view)
  95. )
  96. const handlePasteSpecial = wrapForContextMenu(() =>
  97. inVisualMode ? pasteWithoutFormatting(view) : pasteWithFormatting(view)
  98. )
  99. const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view))
  100. const handleToggleTrackChanges = wrapForContextMenu(() => {
  101. // Matching the logic in review toggle to ensure consistency for server pro
  102. if (!features.trackChanges && !isReview) {
  103. setShowUpgradeModal(true)
  104. return true
  105. }
  106. window.dispatchEvent(new Event('toggle-track-changes'))
  107. return true
  108. })
  109. const handleComment = wrapForContextMenu(() => {
  110. commands.addComment()
  111. return true
  112. })
  113. // Sync-to-PDF is special: it needs to wait for async completion before closing
  114. const handleSyncToPdf = useCallback(() => {
  115. // Switch to split view only when in editor-only mode with non-detached PDF
  116. if (isEditorOnly) {
  117. changeLayout('sideBySide')
  118. }
  119. requestedPdfSyncRef.current = true
  120. syncToPdf()
  121. view.focus()
  122. }, [syncToPdf, view, changeLayout, isEditorOnly])
  123. const getShortcut = useCallback(
  124. (id: string) => {
  125. const shortcut = shortcuts[id]?.[0]
  126. return shortcut ? formatShortcut(shortcut) : undefined
  127. },
  128. [shortcuts]
  129. )
  130. return {
  131. closeMenu,
  132. onToggle,
  133. menuItems: [
  134. {
  135. label: t('cut'),
  136. handler: handleCut,
  137. disabled: false,
  138. show: canEdit,
  139. shortcut: getShortcut('cut'),
  140. },
  141. {
  142. label: t('copy'),
  143. handler: handleCopy,
  144. disabled: false,
  145. show: true,
  146. shortcut: getShortcut('copy'),
  147. },
  148. {
  149. label: t('paste'),
  150. handler: handlePaste,
  151. disabled: false,
  152. show: canEdit,
  153. shortcut: getShortcut('paste'),
  154. },
  155. {
  156. label: inVisualMode
  157. ? t('paste_without_formatting')
  158. : t('paste_with_formatting'),
  159. handler: handlePasteSpecial,
  160. disabled: false,
  161. show: canEdit,
  162. shortcut: inVisualMode ? getShortcut('paste-special') : undefined,
  163. },
  164. {
  165. label: t('delete'),
  166. handler: handleDelete,
  167. disabled: !hasSelection,
  168. show: canEdit,
  169. shortcut: undefined,
  170. },
  171. {
  172. label: t('jump_to_location_in_pdf'),
  173. handler: handleSyncToPdf,
  174. disabled: syncToPdfInFlight,
  175. separatorAbove: true,
  176. show: jumpToLocationInPdfEnabled,
  177. shortcut: undefined,
  178. },
  179. {
  180. label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
  181. handler: handleToggleTrackChanges,
  182. disabled: false,
  183. separatorAbove: true,
  184. show: canEdit && features.trackChangesVisible,
  185. shortcut: getShortcut('toggle-track-changes'),
  186. },
  187. {
  188. label: t('comment'),
  189. handler: handleComment,
  190. disabled: !hasSelection,
  191. show: permissions.comment,
  192. shortcut: getShortcut('insert-comment'),
  193. },
  194. ].filter(item => item.show),
  195. }
  196. }