diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index b16bd41652..f76f1a6a33 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -459,6 +459,7 @@ const _ProjectController = { 'wf-citations-checker-on-selection', 'writefull-asymetric-queue-size-per-model', 'writefull-encourage-prompt-for-paraphrase', + 'editor-context-menu', 'email-notifications', 'editor-redesign-no-opt-out', ].filter(Boolean) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 2474c47092..dd5f3eb6b3 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -180,6 +180,7 @@ "available_with_group_professional": "", "back": "", "back_to_configuration": "", + "back_to_editing": "", "back_to_editor": "", "back_to_subscription": "", "back_to_your_projects": "", @@ -382,6 +383,7 @@ "customize_your_group_subscription": "", "customizing_figures": "", "customizing_tables": "", + "cut": "", "dark_mode_pdf_preview": "", "dark_themes": "", "date_and_owner": "", @@ -940,6 +942,7 @@ "join_x_enterprise_group": "", "joined_team": "", "joining": "", + "jump_to_location_in_pdf": "", "justify": "", "kb_suggestions_enquiry": "", "keep_current_plan": "", @@ -1262,6 +1265,7 @@ "password": "", "password_managed_externally": "", "password_was_detected_on_a_public_list_of_known_compromised_passwords": "", + "paste": "", "paste_options": "", "paste_with_formatting": "", "paste_without_formatting": "", @@ -1804,6 +1808,7 @@ "success_sso_set_up": "", "success_youve_successfully_joined_group": "", "suggest_a_different_fix": "", + "suggest_edits": "", "suggest_fix": "", "suggested": "", "suggested_code": "", diff --git a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx index ff54c21f2a..f3ea32b97e 100644 --- a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx @@ -57,6 +57,10 @@ export const CommandRegistryProvider: React.FC = ({ // NOTE: This is where we'd add functionality for customising shortcuts. const shortcuts: Record = useMemo( () => ({ + cut: [{ key: 'Mod-x' }], + copy: [{ key: 'Mod-c' }], + paste: [{ key: 'Mod-v' }], + 'toggle-track-changes': [{ key: 'Mod-Shift-A' }], undo: [ { key: 'Mod-z', diff --git a/services/web/frontend/js/features/source-editor/commands/clipboard.ts b/services/web/frontend/js/features/source-editor/commands/clipboard.ts new file mode 100644 index 0000000000..115ac0722c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/commands/clipboard.ts @@ -0,0 +1,115 @@ +import { EditorView } from '@codemirror/view' +import { + findImageInClipboard, + dispatchFigureModalPasteEvent, +} from '../utils/paste-image' + +const getEntireLineText = (view: EditorView, pos: number): string => { + const line = view.state.doc.lineAt(pos) + const atDocumentEnd = line.to === view.state.doc.length + return atDocumentEnd ? line.text : line.text + view.state.lineBreak +} + +export const cutSelection = async (view: EditorView): Promise => { + const selections = view.state.selection.ranges + const changes = [] + const texts = [] + + for (const range of selections) { + const { from, to } = range + if (from === to) { + const text = getEntireLineText(view, from) + texts.push(text) + const line = view.state.doc.lineAt(from) + const atDocumentEnd = line.to === view.state.doc.length + const deleteTo = atDocumentEnd + ? line.to + : line.to + view.state.lineBreak.length + changes.push({ from: line.from, to: deleteTo, insert: '' }) + } else { + const text = view.state.sliceDoc(from, to) + texts.push(text) + changes.push({ from, to, insert: '' }) + } + } + + await navigator.clipboard.writeText(texts.join('')) + view.dispatch({ + changes, + selection: { anchor: changes[0]?.from ?? view.state.selection.main.from }, + }) + return true +} + +export const copySelection = async (view: EditorView): Promise => { + const selections = view.state.selection.ranges + const texts = [] + + for (const range of selections) { + const { from, to } = range + const text = + from === to + ? getEntireLineText(view, from) + : view.state.sliceDoc(from, to) + texts.push(text) + } + + await navigator.clipboard.writeText(texts.join('')) + return true +} + +export const pasteWithoutFormatting = async ( + view: EditorView +): Promise => { + // Check for pasted images first + const imageFile = await findImageInClipboard() + if (imageFile) { + dispatchFigureModalPasteEvent({ + name: imageFile.name, + type: imageFile.type, + data: imageFile, + }) + return true + } + + // Fall back to plain text paste + try { + const text = await navigator.clipboard.readText() + const selections = view.state.selection.ranges + const changes = [] + let lastChangeTo = 0 + + // Detect line-wise paste: single line of text with trailing linebreak + const textWithoutTrailingBreak = text.slice(0, -view.state.lineBreak.length) + const isSingleLineWithTrailingBreak = + text.endsWith(view.state.lineBreak) && + !textWithoutTrailingBreak.includes(view.state.lineBreak) + + // Apply paste to each selection/range + for (const range of selections) { + const { from, to } = range + const noSelection = from === to + const shouldInsertAtLineStart = + noSelection && isSingleLineWithTrailingBreak + + if (shouldInsertAtLineStart) { + const line = view.state.doc.lineAt(from) + changes.push({ from: line.from, to: line.from, insert: text }) + lastChangeTo = line.from + text.length + } else { + changes.push({ from, to, insert: text }) + lastChangeTo = from + text.length + } + } + + view.dispatch({ + changes, + selection: { anchor: lastChangeTo }, + }) + + return true + } catch { + // Clipboard access denied or empty + return false + } +} diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx index ad92971c51..d116bd600c 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx @@ -18,6 +18,7 @@ import { } from './codemirror-context' import MathPreviewTooltip from './math-preview-tooltip' import { getVisualEditorComponent } from '../utils/visual-editor' +import EditorContextMenu from './editor-context-menu' import { useToolbarMenuBarEditorCommands } from '@/features/ide-redesign/hooks/use-toolbar-menu-editor-commands' import { useProjectContext } from '@/shared/context/project-context' import { useFeatureFlag } from '@/shared/context/split-test-context' @@ -90,6 +91,7 @@ function CodeMirrorEditorComponents() { + {features.trackChangesVisible && } {features.trackChangesVisible && } diff --git a/services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx b/services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx new file mode 100644 index 0000000000..94d7c262b2 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx @@ -0,0 +1,80 @@ +import { FC, Fragment, memo } from 'react' +import ReactDOM from 'react-dom' +import { useTranslation } from 'react-i18next' +import { getTooltip } from '@codemirror/view' +import { + useCodeMirrorStateContext, + useCodeMirrorViewContext, +} from './codemirror-context' +import { contextMenuStateField } from '../extensions/context-menu' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import { useContextMenuItems } from '../hooks/use-context-menu-items' + +const EditorContextMenu: FC = () => { + const state = useCodeMirrorStateContext() + const view = useCodeMirrorViewContext() + const editorContextMenuEnabled = useFeatureFlag('editor-context-menu') + + const menuState = state.field(contextMenuStateField, false) + if (!editorContextMenuEnabled || !menuState?.tooltip) { + return null + } + + const tooltipView = getTooltip(view, menuState.tooltip) + if (!tooltipView) { + return null + } + + return ReactDOM.createPortal(, tooltipView.dom) +} + +const EditorContextMenuContent: FC = memo(function EditorContextMenuContent() { + const { t } = useTranslation() + + const menuItems = useContextMenuItems() + + return ( +
+ {menuItems.map((menuItem, index) => ( + + {menuItem.separatorAbove && ( +
+ )} + menuItem.handler()} + disabled={menuItem.disabled} + shortcut={menuItem.shortcut} + /> + + ))} +
+ ) +}) + +type ContextMenuItemProps = { + label: string + onClick: () => void + disabled?: boolean + shortcut?: string +} + +const ContextMenuItem: FC = ({ + label, + shortcut, + onClick, + disabled, +}) => ( + +) + +export default EditorContextMenu diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx index dce0beabe4..632c7a6b98 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx @@ -1,5 +1,5 @@ import { FC, createContext, useContext, useReducer } from 'react' -import { PastedImageData } from '../../extensions/figure-modal' +import { PastedImageData } from '../../utils/paste-image' /* eslint-disable no-unused-vars */ export enum FigureModalSource { diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx index c062c572ff..69f0be326c 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx @@ -18,7 +18,6 @@ import { ChangeSpec } from '@codemirror/state' import { snippet } from '@codemirror/autocomplete' import { FigureData, - PastedImageData, editFigureData, editFigureDataEffect, } from '../../extensions/figure-modal' @@ -28,6 +27,7 @@ import useEventListener from '../../../../shared/hooks/use-event-listener' import { prepareLines } from '../../utils/prepare-lines' import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner' import { isSvgFile } from '../../utils/file' +import { PastedImageData } from '../../utils/paste-image' const FigureModalBody = lazy(() => import('./figure-modal-body')) diff --git a/services/web/frontend/js/features/source-editor/extensions/context-menu.ts b/services/web/frontend/js/features/source-editor/extensions/context-menu.ts new file mode 100644 index 0000000000..a2c07a901b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/context-menu.ts @@ -0,0 +1,249 @@ +import { + EditorView, + showTooltip, + Tooltip, + TooltipView, + keymap, +} from '@codemirror/view' +import { + Extension, + StateField, + StateEffect, + TransactionSpec, + EditorSelection, + Prec, +} from '@codemirror/state' + +export const openContextMenuEffect = StateEffect.define<{ + pos: number + x: number + y: number +}>() + +export const closeContextMenuEffect = StateEffect.define() + +type ContextMenuState = { + tooltip: Tooltip | null + mousePosition: { x: number; y: number } | null +} + +export const contextMenuStateField = StateField.define({ + create() { + return { tooltip: null, mousePosition: null } + }, + + update(field, tr) { + // Process state effects to open/close menu + for (const effect of tr.effects) { + if (effect.is(openContextMenuEffect)) { + const { pos, x, y } = effect.value + return { + tooltip: buildContextMenuTooltip(pos), + mousePosition: { x, y }, + } + } + if (effect.is(closeContextMenuEffect)) { + return { tooltip: null, mousePosition: null } + } + } + + // Close menu on document changes + if (tr.docChanged && field.tooltip) { + return { tooltip: null, mousePosition: null } + } + + return field + }, + + // Connect state field to tooltip system + provide: field => [ + showTooltip.compute([field], state => state.field(field).tooltip), + ], +}) + +function buildContextMenuTooltip(pos: number): Tooltip { + return { + pos, + above: false, + strictSide: false, + arrow: false, + create: createTooltipView, + } +} + +const createTooltipView = (): TooltipView => { + const dom = document.createElement('div') + dom.className = 'editor-context-menu-container' + return { dom, overlap: true, offset: { x: 0, y: 0 } } +} + +function isPositionInsideSelection(pos: number, from: number, to: number) { + return from !== to && pos >= from && pos <= to +} + +function selectEntireLine( + view: EditorView, + pos: number +): EditorSelection | null { + if (pos === null) { + return null + } + + const line = view.state.doc.lineAt(pos) + return EditorSelection.single(line.from, line.to) +} + +function closeContextMenu(view: EditorView): void { + const menuState = view.state.field(contextMenuStateField, false) + if (menuState?.tooltip) { + view.dispatch({ effects: closeContextMenuEffect.of(null) }) + } +} + +function openContextMenuAtPosition( + view: EditorView, + pos: number, + selection: EditorSelection | TransactionSpec['selection'], + clientX: number, + clientY: number +): void { + view.dispatch({ + selection, + effects: openContextMenuEffect.of({ + pos, + x: clientX, + y: clientY, + }), + }) +} + +function isClickOnGutter(target: HTMLElement): boolean { + return !!target.closest('.cm-gutters') +} + +// Gutter context menu plugin +const gutterContextMenuPlugin = (): Extension => + EditorView.updateListener.of(update => { + if (!update.view.dom.parentElement) { + return + } + + const gutters = update.view.dom.parentElement.querySelector('.cm-gutters') + // Attach listener only once per editor instance + if (!gutters || gutters.hasAttribute('data-context-menu-attached')) { + return + } + + gutters.setAttribute('data-context-menu-attached', 'true') + gutters.addEventListener('contextmenu', (event: Event) => { + const mouseEvent = event as MouseEvent + event.preventDefault() + + const pos = update.view.posAtCoords({ + x: mouseEvent.clientX, + y: mouseEvent.clientY, + }) + if (pos === null) { + return + } + + const selection = selectEntireLine(update.view, pos) + if (selection) { + openContextMenuAtPosition( + update.view, + pos, + selection, + mouseEvent.clientX, + mouseEvent.clientY + ) + } + }) + }) + +// Editor view context menu handlers +const editorContextMenuHandlers = (): Extension => + EditorView.domEventHandlers({ + contextmenu(event: MouseEvent, view: EditorView) { + event.preventDefault() + + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) + if (pos === null) { + return false + } + + const { from, to } = view.state.selection.main + const clickedInsideSelection = isPositionInsideSelection(pos, from, to) + + // Set cursor to clicked position if outside selection + let selection: TransactionSpec['selection'] = { anchor: pos } + if (clickedInsideSelection) { + // Keep current selection if inside selection + // so actions apply to the existing selection + selection = view.state.selection + } + + openContextMenuAtPosition( + view, + pos, + selection, + event.clientX, + event.clientY + ) + return true + }, + + mousedown(event: MouseEvent, view: EditorView) { + const target = event.target as HTMLElement + const isGutter = isClickOnGutter(target) + const isRightClick = event.button === 2 || event.ctrlKey + + // Close menu on any click except right-click on non-gutter + if (!isRightClick || isGutter) { + closeContextMenu(view) + } + + // Prevent default on right-click to preserve selection + if (isRightClick) { + event.preventDefault() + return true + } + return false + }, + }) + +// High-priority keymap to handle Escape before default handlers +const contextMenuKeymap = (): Extension => + Prec.high( + keymap.of([ + { + key: 'Escape', + run: view => { + const menuState = view.state.field(contextMenuStateField, false) + if (menuState?.tooltip) { + closeContextMenu(view) + return true + } + return false + }, + }, + ]) + ) + +export const contextMenu = (enabled: boolean): Extension => + enabled + ? [ + contextMenuContainerTheme, + contextMenuStateField, + gutterContextMenuPlugin(), + editorContextMenuHandlers(), + contextMenuKeymap(), + ] + : [] + +const contextMenuContainerTheme = EditorView.baseTheme({ + '.editor-context-menu-container.cm-tooltip': { + backgroundColor: 'transparent', + border: 'none', + zIndex: 100, + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts b/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts index d00353ec4f..0294342b58 100644 --- a/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts +++ b/services/web/frontend/js/features/source-editor/extensions/figure-modal.ts @@ -8,6 +8,10 @@ import { EditorView } from '@codemirror/view' import { addEffectListener, removeEffectListener } from './effect-listeners' import { setMetadataEffect } from './language' import { debugConsole } from '@/utils/debugging' +import { + dispatchFigureModalPasteEvent, + isAllowedImageType, +} from '../utils/paste-image' type NestedReadonly = { readonly [P in keyof T]: NestedReadonly @@ -160,18 +164,6 @@ export function waitForFileTreeUpdate(view: EditorView) { } } -const ALLOWED_MIME_TYPES = new Set([ - 'image/jpeg', - 'image/png', - 'application/pdf', -]) - -export type PastedImageData = { - name: string - type: string - data: Blob -} - export const figureModalPasteHandler = (): Extension => { return EditorView.domEventHandlers({ drop: evt => { @@ -179,18 +171,14 @@ export const figureModalPasteHandler = (): Extension => { return } const file = evt.dataTransfer.files[0] - if (!ALLOWED_MIME_TYPES.has(file.type)) { + if (!isAllowedImageType(file.type)) { return } - window.dispatchEvent( - new CustomEvent('figure-modal:paste-image', { - detail: { - name: file.name, - type: file.type, - data: file, - }, - }) - ) + dispatchFigureModalPasteEvent({ + name: file.name, + type: file.type, + data: file, + }) }, paste: evt => { if (!evt.clipboardData || evt.clipboardData.files.length === 0) { @@ -200,18 +188,14 @@ export const figureModalPasteHandler = (): Extension => { return // allow pasted text to be handled even if there's also a file on the clipboard } const file = evt.clipboardData.files[0] - if (!ALLOWED_MIME_TYPES.has(file.type)) { + if (!isAllowedImageType(file.type)) { return } - window.dispatchEvent( - new CustomEvent('figure-modal:paste-image', { - detail: { - name: file.name, - type: file.type, - data: file, - }, - }) - ) + dispatchFigureModalPasteEvent({ + name: file.name, + type: file.type, + data: file, + }) }, }) } diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index d107756bd3..a86c3c9d28 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -55,6 +55,7 @@ import { trackDetachedComments } from './track-detached-comments' import { reviewTooltip } from './review-tooltip' import { tooltipsReposition } from './tooltips-reposition' import { selectionListener } from '@/features/source-editor/extensions/selection-listener' +import { contextMenu } from './context-menu' const moduleExtensions: Array<(options: Record) => Extension> = importOverleafModules('sourceEditorExtensions').map( @@ -156,6 +157,7 @@ export const createExtensions = (options: Record): Extension[] => [ visual(options.visual), mathPreview(options.settings.mathPreview), reviewTooltip(), + contextMenu(options.editorContextMenuEnabled), toolbarPanel(), breadcrumbPanel(), verticalOverflow(), diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts index fad9ba0e61..0fbb21c643 100644 --- a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts @@ -166,3 +166,14 @@ export const toggleSearch: Command = view => { export const addComment = () => { window.dispatchEvent(new Event('add-new-review-comment')) } + +export const deleteSelection: Command = view => { + const { from, to } = view.state.selection.main + if (from === to) return false + + view.dispatch({ + changes: { from, to, insert: '' }, + selection: { anchor: from }, + }) + return true +} diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index af271e04f1..058bb17cbf 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -62,6 +62,7 @@ import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme' import { useEditorSelectionContext } from '@/shared/context/editor-selection-context' import { useActiveEditorTheme } from '@/shared/hooks/use-active-editor-theme' import { isVisualEditorAvailable } from '../utils/visual-editor' +import { useFeatureFlag } from '@/shared/context/split-test-context' function useCodeMirrorScope(view: EditorView) { const { fileTreeData } = useFileTreeData() @@ -96,6 +97,7 @@ function useCodeMirrorScope(view: EditorView) { const { onlineUserCursorHighlights } = useOnlineUsersContext() const { project, features: projectFeatures } = useProjectContext() + const editorContextMenuEnabled = useFeatureFlag('editor-context-menu') let spellCheckLanguage = project?.spellCheckLanguage || '' // spell check is off when read-only if (!permissions.write && !permissions.trackedWrite) { @@ -210,6 +212,7 @@ function useCodeMirrorScope(view: EditorView) { }, [view, spellCheckLanguage, hunspellManager]) const projectFeaturesRef = useRef(projectFeatures) + const editorContextMenuEnabledRef = useRef(editorContextMenuEnabled) // listen to doc:after-opened, and focus the editor if it's not a new doc useEffect(() => { @@ -337,6 +340,7 @@ function useCodeMirrorScope(view: EditorView) { spelling: spellingRef.current, visual: visualRef.current, projectFeatures: projectFeaturesRef.current, + editorContextMenuEnabled: editorContextMenuEnabledRef.current, initialSearchQuery: searchQueryRef.current, showBoundary, handleException, diff --git a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx new file mode 100644 index 0000000000..b01e201657 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx @@ -0,0 +1,154 @@ +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 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, useState } from 'react' +import { + formatShortcut, + useCommandRegistry, +} from '@/features/ide-react/context/command-registry-context' +import { closeContextMenuEffect } from '../extensions/context-menu' +import * as commands from '../extensions/toolbar/commands' +import { + cutSelection, + copySelection, + pasteWithoutFormatting, +} from '../commands/clipboard' + +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 } = useLayoutContext() + const visualPreviewEnabled = useFeatureFlag('visual-preview') + const { t } = useTranslation() + const { shortcuts } = useCommandRegistry() + + const closeMenu = useCallback(() => { + view.dispatch({ effects: closeContextMenuEffect.of(null) }) + }, [view]) + + const [pendingClose, setPendingClose] = useState(false) + + // Wait for syncToPdf to finish before closing the menu + useEffect(() => { + if (pendingClose && !syncToPdfInFlight) { + closeMenu() + setPendingClose(false) + } + }, [pendingClose, syncToPdfInFlight, closeMenu]) + + const hasSelection = !state.selection.main.empty + const canEdit = permissions.write || permissions.trackedWrite + const jumpToLocationInPdfEnabled = + pdfUrl && + pdfViewer !== 'native' && + !detachRole && + !visualPreviewEnabled && + canSyncToPdf + + const wrapForContextMenu = useCallback( + (command: () => Promise | boolean) => async () => { + const result = await command() + if (result !== false) { + view.focus() + closeMenu() + } + }, + [view, closeMenu] + ) + + const handleCut = wrapForContextMenu(() => cutSelection(view)) + const handleCopy = wrapForContextMenu(() => copySelection(view)) + const handlePaste = wrapForContextMenu(() => pasteWithoutFormatting(view)) + const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view)) + + const handleToggleTrackChanges = wrapForContextMenu(() => { + window.dispatchEvent(new Event('toggle-track-changes')) + return true + }) + + const handleComment = wrapForContextMenu(() => { + commands.addComment() + return true + }) + + // Sync-to-PDF is special: it needs to wait for async completion before closing + const handleSyncToPdf = useCallback(() => { + syncToPdf() + setPendingClose(true) + view.focus() + }, [syncToPdf, view]) + + const getShortcut = useCallback( + (id: string) => { + const shortcut = shortcuts[id]?.[0] + return shortcut ? formatShortcut(shortcut) : undefined + }, + [shortcuts] + ) + + return [ + { + 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: 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: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'), + handler: handleToggleTrackChanges, + disabled: false, + separatorAbove: true, + show: canEdit, + shortcut: getShortcut('toggle-track-changes'), + }, + { + label: t('comment'), + handler: handleComment, + disabled: !hasSelection, + show: permissions.comment, + shortcut: getShortcut('insert-comment'), + }, + ].filter(item => item.show) +} diff --git a/services/web/frontend/js/features/source-editor/utils/paste-image.ts b/services/web/frontend/js/features/source-editor/utils/paste-image.ts new file mode 100644 index 0000000000..20bdeda423 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/paste-image.ts @@ -0,0 +1,47 @@ +export const ALLOWED_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'application/pdf', +]) + +export function isAllowedImageType(mimeType: string): boolean { + return ALLOWED_IMAGE_TYPES.has(mimeType) +} + +export type PastedImageData = { + name: string + type: string + data: Blob +} + +export function dispatchFigureModalPasteEvent( + imageData: PastedImageData +): void { + window.dispatchEvent( + new CustomEvent('figure-modal:paste-image', { + detail: imageData, + }) + ) +} + +export async function findImageInClipboard(): Promise { + try { + const clipboardItems = await navigator.clipboard.read() + + for (const item of clipboardItems) { + for (const type of item.types) { + if (isAllowedImageType(type)) { + const blob = await item.getType(type) + const file = new File([blob], `image.${type.split('/')[1]}`, { + type, + }) + return file + } + } + } + } catch (error) { + // Clipboard.read() may fail in some browsers + } + + return null +} diff --git a/services/web/frontend/js/shared/context/file-tree-data-context.tsx b/services/web/frontend/js/shared/context/file-tree-data-context.tsx index be48f52776..bcddbf8113 100644 --- a/services/web/frontend/js/shared/context/file-tree-data-context.tsx +++ b/services/web/frontend/js/shared/context/file-tree-data-context.tsx @@ -33,7 +33,7 @@ const { buildFileTree, createFolder } = (importOverleafModules('snapshotUtils')[0] ?.import as typeof StubSnapshotUtils) || StubSnapshotUtils -const FileTreeDataContext = createContext< +export const FileTreeDataContext = createContext< | { // fileTreeData is the up-to-date representation of the files list, updated // by the file tree diff --git a/services/web/frontend/stylesheets/pages/all.scss b/services/web/frontend/stylesheets/pages/all.scss index e9e0e1778a..3e6c9adcbe 100644 --- a/services/web/frontend/stylesheets/pages/all.scss +++ b/services/web/frontend/stylesheets/pages/all.scss @@ -31,6 +31,7 @@ @import 'editor/share'; @import 'editor/tags-input'; @import 'editor/review-panel'; +@import 'editor/context-menu'; @import 'editor/table-generator-column-width-modal'; @import 'editor/math-preview'; @import 'editor/references-search'; diff --git a/services/web/frontend/stylesheets/pages/editor/context-menu.scss b/services/web/frontend/stylesheets/pages/editor/context-menu.scss new file mode 100644 index 0000000000..53d0cd9ef0 --- /dev/null +++ b/services/web/frontend/stylesheets/pages/editor/context-menu.scss @@ -0,0 +1,55 @@ +.editor-context-menu { + display: flex; + flex-direction: column; + min-width: 180px; + border-radius: var(--border-radius-base); + padding: var(--spacing-02); + gap: var(--spacing-01); + box-shadow: 0 2px 4px 0 #1e253029; + border: 1px solid var(--border-divider); + background-color: var(--bg-light-primary); +} + +.editor-context-menu-item { + display: flex; + align-items: center; + gap: var(--spacing-04); + padding: var(--spacing-02) var(--spacing-04); + border: none; + border-radius: var(--border-radius-base); + background-color: transparent; + color: var(--content-primary); + cursor: pointer; + text-align: left; + font-size: var(--font-size-02); + line-height: var(--line-height-02); + + &:hover:not(:disabled) { + background-color: var(--bg-light-secondary); + } + + &:disabled { + color: var(--content-disabled); + cursor: not-allowed; + } + + &-label { + flex: 1; + } + + &-shortcut { + min-width: var(--spacing-10); + text-align: right; + color: var(--content-secondary); + + .editor-context-menu-item:disabled & { + color: var(--content-disabled); + } + } +} + +.editor-context-menu-separator { + height: 1px; + margin: var(--spacing-01) var(--spacing-04); + background-color: var(--border-divider); +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 738e89429c..00d6b404db 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -226,6 +226,7 @@ "back_to_account_settings": "Back to account settings", "back_to_all_posts": "Back to all posts", "back_to_configuration": "Back to configuration", + "back_to_editing": "Back to editing", "back_to_editor": "Back to editor", "back_to_log_in": "Back to log in", "back_to_subscription": "Back to subscription", @@ -499,6 +500,7 @@ "customize_your_group_subscription": "Customize your group subscription", "customizing_figures": "Customizing figures", "customizing_tables": "Customizing tables", + "cut": "Cut", "da": "Danish", "dark_mode": "Dark mode", "dark_mode_pdf_preview": "Dark mode PDF preview", @@ -1204,6 +1206,7 @@ "joined_team": "You have joined the group subscription managed by __inviterName__", "joining": "Joining", "july": "July", + "jump_to_location_in_pdf": "Jump to location in PDF", "june": "June", "justify": "Justify", "kb_suggestions_enquiry": "Have you checked our <0>__kbLink__?", @@ -1654,6 +1657,7 @@ "password_too_long_please_reset": "Maximum password length exceeded. Please reset your password.", "password_updated": "Password updated.", "password_was_detected_on_a_public_list_of_known_compromised_passwords": "This password was detected on a <0>public list of known compromised passwords.", + "paste": "Paste", "paste_options": "Paste options", "paste_with_formatting": "Paste with formatting", "paste_without_formatting": "Paste without formatting", @@ -2302,6 +2306,7 @@ "success_sso_set_up": "Success! Single sign-on is all set up for you.", "success_youve_successfully_joined_group": "Success! You’ve joined the <0>__groupName__ group subscription. Your group has SSO enabled.", "suggest_a_different_fix": "Suggest a different fix", + "suggest_edits": "Suggest edits", "suggest_fix": "Suggest fix", "suggested": "Suggested", "suggested_code": "Suggested code", diff --git a/services/web/test/frontend/features/source-editor/commands/clipboard.test.ts b/services/web/test/frontend/features/source-editor/commands/clipboard.test.ts new file mode 100644 index 0000000000..54cd07fedb --- /dev/null +++ b/services/web/test/frontend/features/source-editor/commands/clipboard.test.ts @@ -0,0 +1,195 @@ +import { expect } from 'chai' +import { EditorState, EditorSelection } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { + copySelection, + cutSelection, + pasteWithoutFormatting, +} from '../../../../../frontend/js/features/source-editor/commands/clipboard' + +const createClipboardStub = () => { + const stub = { + written: null as string | null, + reads: [] as string[], + rejectRead: undefined as Error | undefined, + writeText: async (text: string) => { + stub.written = text + }, + readText: async () => { + if (stub.rejectRead) throw stub.rejectRead + return stub.reads.shift() ?? '' + }, + } + return stub +} + +const createView = (doc: string, anchor = 0, head = anchor) => { + const state = EditorState.create({ doc, selection: { anchor, head } }) + const parent = document.createElement('div') + return new EditorView({ state, parent }) +} + +const createViewWithMultipleRanges = ( + doc: string, + ranges: Array<{ anchor: number; head?: number }> +) => { + const state = EditorState.create({ + doc, + extensions: [EditorState.allowMultipleSelections.of(true)], + selection: EditorSelection.create( + ranges.map(({ anchor, head }) => + EditorSelection.range(anchor, head ?? anchor) + ) + ), + }) + const parent = document.createElement('div') + return new EditorView({ state, parent }) +} + +describe('clipboard behavior', function () { + let clipboard: ReturnType + + beforeEach(function () { + clipboard = createClipboardStub() + ;(navigator as any).clipboard = clipboard + }) + + describe('copySelection', function () { + it('copies only the selected range when a selection exists', async function () { + const view = createView('abcde', 1, 4) // selects "bcd" + await copySelection(view) + expect(clipboard.written).to.equal('bcd') + }) + + it('copies entire current line with trailing break when no selection (middle line)', async function () { + const view = createView('one\ntwo\nthree', 5) // inside "two" + await copySelection(view) + expect(clipboard.written).to.equal('two\n') + }) + + it('copies last line without an extra trailing break at document end', async function () { + const view = createView('one\ntwo\nthree', 9) // inside "three" + await copySelection(view) + expect(clipboard.written).to.equal('three') + }) + + it('copies all selected ranges when multiple selections exist', async function () { + const view = createViewWithMultipleRanges('abcdefgh', [ + { anchor: 0, head: 1 }, // "a" + { anchor: 3, head: 5 }, // "de" + ]) + await copySelection(view) + expect(clipboard.written).to.equal('ade') + }) + + it('copies entire lines for each cursor when multiple empty selections', async function () { + const view = createViewWithMultipleRanges('line1\nline2\nline3', [ + { anchor: 2 }, // in "line1" + { anchor: 8 }, // in "line2" + ]) + await copySelection(view) + expect(clipboard.written).to.equal('line1\nline2\n') + }) + }) + + describe('cutSelection', function () { + it('cuts only the selected range when a selection exists', async function () { + const view = createView('abcde', 1, 4) // selects "bcd" + await cutSelection(view) + expect(clipboard.written).to.equal('bcd') + expect(view.state.doc.toString()).to.equal('ae') + }) + + it('cuts entire line with trailing break when no selection (middle line)', async function () { + const view = createView('first\nsecond', 1) + await cutSelection(view) + expect(clipboard.written).to.equal('first\n') + expect(view.state.doc.toString()).to.equal('second') + }) + + it('cuts last line without removing the preceding newline when at document end', async function () { + const view = createView('first\nsecond', 8) // inside last line + await cutSelection(view) + expect(clipboard.written).to.equal('second') + expect(view.state.doc.toString()).to.equal('first\n') + }) + + it('cuts all selected ranges when multiple selections exist', async function () { + const view = createViewWithMultipleRanges('abcdefgh', [ + { anchor: 0, head: 2 }, // "ab" + { anchor: 4, head: 6 }, // "ef" + ]) + await cutSelection(view) + expect(clipboard.written).to.equal('abef') + expect(view.state.doc.toString()).to.equal('cdgh') + }) + + it('cuts entire lines for each cursor when multiple empty selections', async function () { + const view = createViewWithMultipleRanges('line1\nline2\nline3', [ + { anchor: 2 }, // in "line1" + { anchor: 14 }, // in "line3" + ]) + await cutSelection(view) + expect(clipboard.written).to.equal('line1\nline3') + expect(view.state.doc.toString()).to.equal('line2\n') + }) + }) + + describe('pasteWithoutFormatting', function () { + it('inserts a line-wise single line above current line when no selection', async function () { + clipboard.reads.push('pasted\n') + const view = createView('current', 0) + await pasteWithoutFormatting(view) + expect(view.state.doc.toString()).to.equal('pasted\ncurrent') + }) + + it('replaces an existing selection inline with single-line text', async function () { + clipboard.reads.push('XX') + const view = createView('hello', 1, 3) // replace "el" + await pasteWithoutFormatting(view) + expect(view.state.doc.toString()).to.equal('hXXlo') + }) + + it('replaces an existing selection even when clipboard text is line-wise (no insert-above)', async function () { + clipboard.reads.push('line\n') + const view = createView('abc', 0, 1) // replace "a" + await pasteWithoutFormatting(view) + expect(view.state.doc.toString()).to.equal('line\nbc') + }) + + it('pastes multiline text inline (no line-wise handling)', async function () { + clipboard.reads.push('lineA\nlineB\n') + const view = createView('X', 0) + await pasteWithoutFormatting(view) + expect(view.state.doc.toString()).to.equal('lineA\nlineB\nX') + }) + + it('returns false and leaves document unchanged if clipboard read fails', async function () { + clipboard.rejectRead = new Error('denied') + const view = createView('stay', 0) + const result = await pasteWithoutFormatting(view) + expect(result).to.equal(false) + expect(view.state.doc.toString()).to.equal('stay') + }) + + it('pastes into all selected ranges when multiple selections exist', async function () { + clipboard.reads.push('XX') + const view = createViewWithMultipleRanges('abcdefgh', [ + { anchor: 1, head: 2 }, // "b" + { anchor: 5, head: 6 }, // "f" + ]) + await pasteWithoutFormatting(view) + expect(view.state.doc.toString()).to.equal('aXXcdeXXgh') + }) + + it('pastes line-wise content at line start for each cursor when multiple empty selections', async function () { + clipboard.reads.push('new\n') + const view = createViewWithMultipleRanges('line1\nline2', [ + { anchor: 2 }, // in "line1" + { anchor: 8 }, // in "line2" + ]) + await pasteWithoutFormatting(view) + expect(view.state.doc.toString()).to.equal('new\nline1\nnew\nline2') + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx new file mode 100644 index 0000000000..512027cb41 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx @@ -0,0 +1,773 @@ +import { mockScope } from '../helpers/mock-scope' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' +import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { TestContainer } from '../helpers/test-container' +import { FC } from 'react' +import { PermissionsContext } from '@/features/ide-react/context/permissions-context' +import { Permissions } from '@/features/ide-react/types/permissions' +import { DetachCompileContext } from '@/shared/context/detach-compile-context' +import { FileTreeDataContext } from '@/shared/context/file-tree-data-context' + +const createPermissionsProvider = ( + permissions: Partial +): FC => { + const defaultPermissions: Permissions = { + read: true, + comment: true, + resolveOwnComments: false, + resolveAllComments: false, + trackedWrite: false, + write: false, + admin: false, + labelVersion: false, + } + + return function PermissionsProvider({ children }) { + return ( + + {children} + + ) + } +} + +const MockDetachCompileProvider: FC = ({ + children, +}) => ( + + {children} + +) + +const MockFileTreeDataProvider: FC = ({ + children, +}) => ( + + {children} + +) + +describe('editor context menu', { scrollBehavior: false }, function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + window.metaAttributesCache.set('ol-splitTestVariants', { + 'editor-context-menu': 'enabled', + }) + cy.interceptEvents() + cy.interceptMetadata() + }) + + it('should open on right-click and close on Escape', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.editor-context-menu').should('not.exist') + + cy.get('.cm-line').eq(10).rightclick() + cy.get('.editor-context-menu').should('be.visible') + + cy.get('body').type('{esc}') + cy.get('.editor-context-menu').should('not.exist') + }) + + it('should close when clicking elsewhere', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + cy.get('.editor-context-menu').should('be.visible') + + cy.get('.cm-line').eq(5).click() + cy.get('.editor-context-menu').should('not.exist') + }) + + describe('when nothing is selected', function () { + it('should enable Cut, Copy, Paste, Suggest edits and disable Delete, Comment', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /delete/i }).should('be.disabled') + cy.findByRole('menuitem', { name: /comment/i }).should('be.disabled') + cy.findByRole('menuitem', { name: /paste/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /suggest edits/i }).should( + 'be.enabled' + ) + }) + }) + }) + + describe('when text is selected', function () { + it('should enable Cut, Copy, Paste, Delete, Suggest edits, and Comment', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(16).as('line') + cy.get('@line').click() + cy.get('@line').type('test text') + cy.get('@line').type( + '{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}' + ) + + cy.get('.cm-selectionBackground').should('exist') + + cy.get('@line').rightclick() + + cy.get('.cm-selectionBackground').should('exist') + cy.get('.editor-context-menu').should('be.visible') + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /paste/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /suggest edits/i }).should( + 'be.enabled' + ) + cy.findByRole('menuitem', { name: /comment/i }).should('be.enabled') + }) + }) + + it('should copy selected text and close menu', function () { + // Grant clipboard permissions for this test + cy.wrap( + Cypress.automation('remote:debugger:protocol', { + command: 'Browser.grantPermissions', + params: { + permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], + origin: window.location.origin, + }, + }) + ) + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(16).as('line') + cy.get('@line').click() + cy.get('@line').type('test text') + cy.get('@line').type( + '{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}' + ) + + cy.get('.cm-selectionBackground').should('exist') + + cy.get('@line').rightclick() + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /copy/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + }) + + it('should cut and paste text via the context menu', function () { + // Grant clipboard permissions for this test + cy.wrap( + Cypress.automation('remote:debugger:protocol', { + command: 'Browser.grantPermissions', + params: { + permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], + origin: window.location.origin, + }, + }) + ) + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(16).as('line') + cy.get('@line').click() + cy.get('@line').type('hello world') + + // Select "world" + cy.get('@line').type( + '{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}' + ) + + // Cut "world" + cy.get('@line').rightclick() + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /cut/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + cy.get('@line').should('contain', 'hello ') + cy.get('@line').should('not.contain', 'world') + + // Move cursor to beginning of line and right-click at column 0 so paste starts there + cy.get('@line').type('{home}') + cy.get('@line').rightclick(0, 0) + + // Paste "world" at the beginning + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /paste/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + cy.get('@line').should('contain', 'worldhello') + }) + + it('should delete selected text', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(16).as('line') + cy.get('@line').click() + cy.get('@line').type('hello world') + + // Select "world" + cy.get('@line').type( + '{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}' + ) + + cy.get('@line').rightclick() + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /delete/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + cy.get('@line').should('contain', 'hello ') + cy.get('@line').should('not.contain', 'world') + }) + }) + + describe('track changes toggle', function () { + let toggleTrackChangesListener: Cypress.Agent + + beforeEach(function () { + toggleTrackChangesListener = cy.stub().as('toggleTrackChanges') + window.addEventListener( + 'toggle-track-changes', + toggleTrackChangesListener + ) + }) + + afterEach(function () { + window.removeEventListener( + 'toggle-track-changes', + toggleTrackChangesListener + ) + }) + + it('should show "Suggest edits" in edit mode and dispatch toggle event when clicked', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + + cy.get('.editor-context-menu').within(() => { + // Verify we're showing the edit mode label + cy.findByRole('menuitem', { name: /suggest edits/i }).should( + 'be.visible' + ) + cy.findByRole('menuitem', { name: /back to editing/i }).should( + 'not.exist' + ) + cy.findByRole('menuitem', { name: /suggest edits/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + + // Verify the toggle event was dispatched + cy.get('@toggleTrackChanges').should('have.been.calledOnce') + }) + + it('should show "Back to editing" in review mode and dispatch toggle event when clicked', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + + cy.get('.editor-context-menu').within(() => { + // Verify we're showing the review mode label + cy.findByRole('menuitem', { name: /back to editing/i }).should( + 'be.visible' + ) + cy.findByRole('menuitem', { name: /suggest edits/i }).should( + 'not.exist' + ) + cy.findByRole('menuitem', { name: /back to editing/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + + // Verify the toggle event was dispatched + cy.get('@toggleTrackChanges').should('have.been.calledOnce') + }) + }) + + describe('when feature flag is disabled', function () { + it('should not show the context menu', function () { + window.metaAttributesCache.set('ol-splitTestVariants', { + 'editor-context-menu': 'default', + }) + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + cy.get('.editor-context-menu').should('not.exist') + }) + }) + + describe('when a user does not have edit permissions', function () { + it('should only show Copy and Comment (hidden Cut, Paste, Delete, Suggest edits)', function () { + const scope = mockScope() + scope.permissions.write = false + scope.permissions.trackedWrite = false + + cy.mount( + + + + + + ) + + // Select some existing text + cy.get('.cm-line').eq(10).as('line') + cy.get('@line').click() + cy.get('@line').type( + '{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}' + ) + + cy.get('.cm-selectionBackground').should('exist') + + cy.get('@line').rightclick() + + cy.get('.editor-context-menu').should('be.visible') + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /cut/i }).should('not.exist') + cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /paste/i }).should('not.exist') + cy.findByRole('menuitem', { name: /delete/i }).should('not.exist') + cy.findByRole('menuitem', { name: /suggest edits/i }).should( + 'not.exist' + ) + cy.findByRole('menuitem', { name: /comment/i }).should('be.enabled') + }) + }) + }) + + describe('when a user does not have comment permissions', function () { + it('should hide the Comment button', function () { + const scope = mockScope() + scope.permissions.write = false + scope.permissions.trackedWrite = false + scope.permissions.comment = false + + cy.mount( + + + + + + ) + + // Select some existing text + cy.get('.cm-line').eq(10).as('line') + cy.get('@line').click() + cy.get('@line').type( + '{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}' + ) + + cy.get('.cm-selectionBackground').should('exist') + + cy.get('@line').rightclick() + + cy.get('.editor-context-menu').should('be.visible') + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /comment/i }).should('not.exist') + }) + }) + }) + + describe('pasting images via context menu', function () { + it('should open figure modal on pasting image via context menu', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.fixture>('images/gradient.png').then( + gradientBuffer => { + // Stub the clipboard API to return our test image + cy.window().then(_win => { + const readStub = cy.stub(navigator.clipboard, 'read') + readStub.resolves([ + { + types: ['image/png'], + getType: cy + .stub() + .withArgs('image/png') + .resolves(new Blob([gradientBuffer], { type: 'image/png' })), + }, + ]) + }) + + // Right-click to open context menu + cy.get('.cm-line').eq(10).rightclick() + cy.get('.editor-context-menu').should('be.visible') + + // Click paste button + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /paste/i }).click() + }) + + // Figure modal should open with the image + cy.findByText('Upload from computer').should('be.visible') + + // Context menu should close + cy.get('.editor-context-menu').should('not.exist') + } + ) + }) + }) + + describe('sync to PDF button', function () { + beforeEach(function () { + // Stub the sync API call + cy.intercept('GET', '/project/*/sync/code*', { + statusCode: 200, + body: { pdf: [] }, + }).as('syncToPdfRequest') + }) + + it('should show jump to location in PDF button and call sync API when clicked', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /jump to location in pdf/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + + // Verify the sync API was called and returned expected response + cy.wait('@syncToPdfRequest').then(interception => { + expect(interception.response?.statusCode).to.equal(200) + expect(interception.response?.body).to.deep.equal({ pdf: [] }) + }) + }) + + it('should hide button when visual preview is enabled', function () { + window.metaAttributesCache.set('ol-splitTestVariants', { + 'editor-context-menu': 'enabled', + 'visual-preview': 'enabled', + }) + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /jump to location in pdf/i }).should( + 'not.exist' + ) + }) + }) + }) + + describe('gutter context menu', function () { + const editorLine = 2 + const gutterLineIndex = editorLine + 1 // extra hidden gutter line + + it('should select entire line when right-clicking on gutter', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.editor-context-menu').should('not.exist') + + cy.get('.cm-line').eq(editorLine).as('targetLine') + cy.get('@targetLine').click() + cy.get('@targetLine').type('This is a test line') + + cy.get('@targetLine').click() + cy.get('.cm-selectionBackground').should('not.exist') + + cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick() + + cy.get('.cm-selectionBackground').should('exist') + cy.get('.editor-context-menu').should('be.visible') + }) + + it('should work with cut/copy/delete operations on gutter-selected line', function () { + cy.wrap( + Cypress.automation('remote:debugger:protocol', { + command: 'Browser.grantPermissions', + params: { + permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], + origin: window.location.origin, + }, + }) + ) + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.window().then(win => { + cy.stub(win.navigator.clipboard, 'writeText').as('writeText') + }) + + cy.get('.cm-line').eq(editorLine).as('testLine') + cy.get('@testLine').click() + cy.get('@testLine').type('Test line for gutter copy') + + cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick() + + cy.get('.cm-selectionBackground').should('exist') + cy.get('.editor-context-menu').should('be.visible') + + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /paste/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled') + cy.findByRole('menuitem', { name: /suggest edits/i }).should( + 'be.enabled' + ) + cy.findByRole('menuitem', { name: /comment/i }).should('be.enabled') + + cy.findByRole('menuitem', { name: /copy/i }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + + cy.get('@writeText').should('have.been.calledOnce') + cy.get('@writeText').should( + 'have.been.calledWith', + Cypress.sinon.match((text: string) => + text.includes('Test line for gutter copy') + ) + ) + }) + + it('should close menu when clicking elsewhere after gutter right-click', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-gutterElement').eq(5).rightclick() + cy.get('.editor-context-menu').should('be.visible') + + cy.get('.cm-line').eq(10).click() + cy.get('.editor-context-menu').should('not.exist') + }) + + it('should close menu on Escape after gutter right-click', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-gutterElement').eq(5).rightclick() + cy.get('.editor-context-menu').should('be.visible') + + cy.get('.cm-content').focus() + cy.get('body').type('{esc}') + cy.get('.editor-context-menu').should('not.exist') + }) + + it('should not show context menu on gutter when feature flag is disabled', function () { + window.metaAttributesCache.set('ol-splitTestVariants', { + 'editor-context-menu': 'default', + }) + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-gutterElement').eq(5).rightclick() + cy.get('.editor-context-menu').should('not.exist') + }) + }) +})