use-context-menu-items.tsx 9.2 KB

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