use-context-menu-items.tsx 8.0 KB

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