use-context-menu-items.tsx 7.6 KB

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