use-context-menu-items.tsx 8.3 KB

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