Files
overleaf-cep/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx
T
Jakob Ackermann 731ed50f78 [clsi] address zod violations (#36199)
* [editor-core] gracefully handle no-op TextOperation

* [web] avoid sending sync to pdf requests without a ?file

* [validation-tools] log deeper nested violations

* [editor-core] fix round trip of EditNoOperation

* [web] use custom error for sync to pdf w/o file, fix tests

* [validation-tools] include request params in log msg for violation

* Revert "[editor-core] gracefully handle no-op TextOperation"

This reverts commit c1d37261d5db72b37dbf39428c54edcfb5423f41.

* [web] gracefully handle rejected sync to pdf from context menu

GitOrigin-RevId: 21de7dc96b23575b0d67b6eb0c68ed9c2f86292b
2026-09-17 11:48:12 +00:00

329 lines
10 KiB
TypeScript

import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { useProjectContext } from '@/shared/context/project-context'
import useSynctex from '@/features/pdf-preview/hooks/use-synctex'
import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useRef } from 'react'
import {
formatShortcut,
useCommandRegistry,
} from '@/features/ide-react/context/command-registry-context'
import { closeContextMenuEffect } from '../extensions/context-menu'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { useContextMenuCommandDefinition } from './use-command-definitions'
import * as commands from '../extensions/toolbar/commands'
import {
cutSelection,
copySelection,
pasteWithoutFormatting,
pasteWithFormatting,
} from '../commands/clipboard'
import { showClipboardPasteErrorToast } from '../components/clipboard-toasts'
import { isVisual } from '../extensions/visual/visual'
import { useEditorContext } from '@/shared/context/editor-context'
import { useTrackingChangesMode } from '@/shared/hooks/use-tracking-changes-mode'
import {
sendContextMenuEvent,
ContextMenuItemSegmentation,
} from '../utils/context-menu-analytics'
import { isCursorOnEmptyLine } from '../utils/is-cursor-on-empty-line'
import { selectAll } from '@codemirror/commands'
import { useTrackedChangesActions } from '@/features/review-panel/hooks/use-tracked-changes-actions'
export const useContextMenuItems = () => {
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const permissions = usePermissionsContext()
const { wantTrackChanges } = useEditorPropertiesContext()
const { syncToPdf, syncToPdfInFlight, canSyncToPdf } = useSynctex()
const { pdfUrl, pdfViewer } = useDetachCompileContext()
const {
detachRole,
changeLayout,
pdfLayout,
view: ideView,
} = useLayoutContext()
const visualPreviewEnabled = useFeatureFlag('visual-preview')
const { t } = useTranslation()
const { shortcuts } = useCommandRegistry()
const { features } = useProjectContext()
const requestedPdfSyncRef = useRef(false)
const { setUpgradeTrackChangesModal } = useEditorContext()
const trackingChangesMode = useTrackingChangesMode()
const isReview = trackingChangesMode === 'review'
const { changesInSelection, acceptChangesHandler, rejectChangesHandler } =
useTrackedChangesActions()
const { openDocWithId } = useEditorManagerContext()
const { findEntityByPath } = useFileTreePathContext()
const { commandName, commandDefinition } = useContextMenuCommandDefinition()
const closeMenu = useCallback(() => {
view.dispatch({ effects: closeContextMenuEffect.of(null) })
view.focus()
}, [view])
// Handle closing the menu when it loses focus, e.g. click outside the editor
const onToggle = (show: boolean) => {
if (!show) {
// Skip closing if a sync to PDF is in flight
if (requestedPdfSyncRef.current) {
return
}
closeMenu()
}
}
// Wait for syncToPdf to finish before closing the menu
useEffect(() => {
if (requestedPdfSyncRef.current && !syncToPdfInFlight) {
closeMenu()
// Clear the synchronous flag when the close completes
requestedPdfSyncRef.current = false
}
}, [syncToPdfInFlight, closeMenu])
const hasSelection = !state.selection.main.empty
const canEdit = permissions.write || permissions.trackedWrite
// Determine layout states for PDF sync functionality
const isPdfDetached = detachRole === 'detacher'
const isEditorOnly =
pdfLayout === 'flat' && ideView === 'editor' && !isPdfDetached
const jumpToLocationInPdfEnabled =
pdfUrl && pdfViewer !== 'native' && !visualPreviewEnabled && canSyncToPdf
const wrapForContextMenu = useCallback(
(
item: ContextMenuItemSegmentation,
command: () => Promise<boolean> | boolean
) =>
async () => {
sendContextMenuEvent('menu-click', {
location: 'editor-context-menu',
item,
})
const result = await command()
if (result !== false) {
view.focus()
closeMenu()
}
},
[view, closeMenu]
)
const getShortcut = useCallback(
(id: string) => {
const shortcut = shortcuts[id]?.[0]
return shortcut ? formatShortcut(shortcut) : undefined
},
[shortcuts]
)
const inVisualMode = isVisual(view)
const handleCut = wrapForContextMenu('cut', () => cutSelection(view))
const handleCopy = wrapForContextMenu('copy', () => copySelection(view))
const handlePaste = wrapForContextMenu('paste', async () => {
const result = await (inVisualMode
? pasteWithFormatting(view)
: pasteWithoutFormatting(view))
if (result === false) {
showClipboardPasteErrorToast(getShortcut('paste'))
}
return result
})
const handlePasteSpecial = wrapForContextMenu(
inVisualMode ? 'paste-without-formatting' : 'paste-with-formatting',
async () => {
const result = await (inVisualMode
? pasteWithoutFormatting(view)
: pasteWithFormatting(view))
if (result === false) {
showClipboardPasteErrorToast(getShortcut('paste'))
}
return result
}
)
const handleSelectAll = wrapForContextMenu('select-all', () =>
selectAll(view)
)
const handleDelete = wrapForContextMenu('delete', () =>
commands.deleteSelection(view)
)
const handleToggleTrackChanges = wrapForContextMenu(
wantTrackChanges ? 'back-to-editing' : 'suggest-edits',
() => {
// Matching the logic in review toggle to ensure consistency for server pro
if (!features.trackChanges && !isReview) {
setUpgradeTrackChangesModal({
show: true,
location: 'editor-context-menu',
})
return true
}
window.dispatchEvent(new Event('toggle-track-changes'))
return true
}
)
const handleComment = wrapForContextMenu('comment', () => {
commands.addComment('editor-context-menu')
return true
})
const handleAcceptChanges = wrapForContextMenu('accept-changes', () => {
acceptChangesHandler()
return true
})
const handleRejectChanges = wrapForContextMenu('reject-changes', () => {
rejectChangesHandler()
return true
})
const handleJumpToDefinition = wrapForContextMenu(
'jump-to-definition',
() => {
if (!commandDefinition) {
return true
}
const result = findEntityByPath(commandDefinition.path)
if (result?.type === 'doc') {
openDocWithId(result.entity._id, { gotoOffset: commandDefinition.pos })
}
return true
}
)
// Sync-to-PDF is special: it needs to wait for async completion before closing
const handleSyncToPdf = useCallback(() => {
// Switch to split view only when in editor-only mode with non-detached PDF
if (isEditorOnly) {
changeLayout('sideBySide')
}
sendContextMenuEvent('menu-click', {
location: 'editor-context-menu',
item: 'jump-to-location-in-pdf',
})
sendContextMenuEvent('jump-to-location', {
method: 'editor-context-menu',
direction: 'code-location-in-pdf',
})
if (syncToPdf()) {
requestedPdfSyncRef.current = true
view.focus()
} else {
closeMenu()
}
}, [syncToPdf, view, changeLayout, isEditorOnly, closeMenu])
return {
closeMenu,
onToggle,
menuItems: [
{
label: t('cut'),
handler: handleCut,
disabled: false,
show: canEdit,
shortcut: getShortcut('cut'),
},
{
label: t('copy'),
handler: handleCopy,
disabled: false,
show: true,
shortcut: getShortcut('copy'),
},
{
label: t('paste'),
handler: handlePaste,
disabled: false,
show: canEdit,
shortcut: getShortcut('paste'),
},
{
label: inVisualMode
? t('paste_without_formatting')
: t('paste_with_formatting'),
handler: handlePasteSpecial,
disabled: false,
show: canEdit,
shortcut: inVisualMode ? getShortcut('paste-special') : undefined,
},
{
label: t('select_all'),
handler: handleSelectAll,
disabled: false,
show: true,
shortcut: getShortcut('select-all'),
},
{
label: t('delete'),
handler: handleDelete,
disabled: !hasSelection,
show: canEdit,
shortcut: undefined,
},
{
label: t('jump_to_location_in_pdf'),
handler: handleSyncToPdf,
disabled: syncToPdfInFlight,
separatorAbove: true,
show: jumpToLocationInPdfEnabled,
shortcut: undefined,
},
{
label: t('jump_to_command_definition'),
handler: handleJumpToDefinition,
disabled: !commandDefinition,
separatorAbove: !jumpToLocationInPdfEnabled,
show: Boolean(commandName),
shortcut: undefined,
},
{
label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
handler: handleToggleTrackChanges,
disabled: false,
separatorAbove: true,
show: canEdit && features.trackChangesVisible,
shortcut: getShortcut('toggle-track-changes'),
},
{
label: t('comment'),
handler: handleComment,
disabled: isCursorOnEmptyLine(state),
show: permissions.comment,
shortcut: getShortcut('insert-comment'),
},
{
label: t('accept_selected_changes'),
handler: handleAcceptChanges,
disabled: false,
show: permissions.write && changesInSelection.length > 0,
shortcut: undefined,
},
{
label: t('reject_selected_changes'),
handler: handleRejectChanges,
disabled: false,
show: permissions.write && changesInSelection.length > 0,
shortcut: undefined,
},
].filter(item => item.show),
}
}