فهرست منبع

Merge pull request #30168 from overleaf/mg-context-menu

Add context menu (right click) to editor

GitOrigin-RevId: f2e567b51b04170ba1a46b0ab4659f3481b05bfe
Malik Glossop 7 ماه پیش
والد
کامیت
f087d125c1
21فایلهای تغییر یافته به همراه1722 افزوده شده و 35 حذف شده
  1. 1 0
      services/web/app/src/Features/Project/ProjectController.mjs
  2. 5 0
      services/web/frontend/extracted-translations.json
  3. 4 0
      services/web/frontend/js/features/ide-react/context/command-registry-context.tsx
  4. 115 0
      services/web/frontend/js/features/source-editor/commands/clipboard.ts
  5. 2 0
      services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
  6. 80 0
      services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx
  7. 1 1
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx
  8. 1 1
      services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx
  9. 249 0
      services/web/frontend/js/features/source-editor/extensions/context-menu.ts
  10. 16 32
      services/web/frontend/js/features/source-editor/extensions/figure-modal.ts
  11. 2 0
      services/web/frontend/js/features/source-editor/extensions/index.ts
  12. 11 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
  13. 4 0
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  14. 154 0
      services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx
  15. 47 0
      services/web/frontend/js/features/source-editor/utils/paste-image.ts
  16. 1 1
      services/web/frontend/js/shared/context/file-tree-data-context.tsx
  17. 1 0
      services/web/frontend/stylesheets/pages/all.scss
  18. 55 0
      services/web/frontend/stylesheets/pages/editor/context-menu.scss
  19. 5 0
      services/web/locales/en.json
  20. 195 0
      services/web/test/frontend/features/source-editor/commands/clipboard.test.ts
  21. 773 0
      services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx

+ 1 - 0
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)

+ 5 - 0
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": "",

+ 4 - 0
services/web/frontend/js/features/ide-react/context/command-registry-context.tsx

@@ -57,6 +57,10 @@ export const CommandRegistryProvider: React.FC<React.PropsWithChildren> = ({
   // NOTE: This is where we'd add functionality for customising shortcuts.
   const shortcuts: Record<string, Shortcut[]> = useMemo(
     () => ({
+      cut: [{ key: 'Mod-x' }],
+      copy: [{ key: 'Mod-c' }],
+      paste: [{ key: 'Mod-v' }],
+      'toggle-track-changes': [{ key: 'Mod-Shift-A' }],
       undo: [
         {
           key: 'Mod-z',

+ 115 - 0
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<boolean> => {
+  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<boolean> => {
+  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<boolean> => {
+  // 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
+  }
+}

+ 2 - 0
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() {
       <CodeMirrorCommandTooltip />
 
       <MathPreviewTooltip />
+      <EditorContextMenu />
       {features.trackChangesVisible && <ReviewTooltipMenu />}
       {features.trackChangesVisible && <ReviewPanelRoot />}
 

+ 80 - 0
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(<EditorContextMenuContent />, tooltipView.dom)
+}
+
+const EditorContextMenuContent: FC = memo(function EditorContextMenuContent() {
+  const { t } = useTranslation()
+
+  const menuItems = useContextMenuItems()
+
+  return (
+    <div className="editor-context-menu" role="menu" aria-label={t('menu')}>
+      {menuItems.map((menuItem, index) => (
+        <Fragment key={index}>
+          {menuItem.separatorAbove && (
+            <div className="editor-context-menu-separator" />
+          )}
+          <ContextMenuItem
+            label={menuItem.label}
+            onClick={() => menuItem.handler()}
+            disabled={menuItem.disabled}
+            shortcut={menuItem.shortcut}
+          />
+        </Fragment>
+      ))}
+    </div>
+  )
+})
+
+type ContextMenuItemProps = {
+  label: string
+  onClick: () => void
+  disabled?: boolean
+  shortcut?: string
+}
+
+const ContextMenuItem: FC<ContextMenuItemProps> = ({
+  label,
+  shortcut,
+  onClick,
+  disabled,
+}) => (
+  <button
+    type="button"
+    role="menuitem"
+    className="editor-context-menu-item"
+    onClick={onClick}
+    disabled={disabled}
+  >
+    <span className="editor-context-menu-item-label">{label}</span>
+    <span className="editor-context-menu-item-shortcut">{shortcut ?? ''}</span>
+  </button>
+)
+
+export default EditorContextMenu

+ 1 - 1
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 {

+ 1 - 1
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'))
 

+ 249 - 0
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<ContextMenuState>({
+  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,
+  },
+})

+ 16 - 32
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<T> = {
   readonly [P in keyof T]: NestedReadonly<T[P]>
@@ -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<PastedImageData>('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<PastedImageData>('figure-modal:paste-image', {
-          detail: {
-            name: file.name,
-            type: file.type,
-            data: file,
-          },
-        })
-      )
+      dispatchFigureModalPasteEvent({
+        name: file.name,
+        type: file.type,
+        data: file,
+      })
     },
   })
 }

+ 2 - 0
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<string, any>) => Extension> =
   importOverleafModules('sourceEditorExtensions').map(
@@ -156,6 +157,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
   visual(options.visual),
   mathPreview(options.settings.mathPreview),
   reviewTooltip(),
+  contextMenu(options.editorContextMenuEnabled),
   toolbarPanel(),
   breadcrumbPanel(),
   verticalOverflow(),

+ 11 - 0
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
+}

+ 4 - 0
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,

+ 154 - 0
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> | 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)
+}

+ 47 - 0
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<PastedImageData>('figure-modal:paste-image', {
+      detail: imageData,
+    })
+  )
+}
+
+export async function findImageInClipboard(): Promise<File | null> {
+  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
+}

+ 1 - 1
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

+ 1 - 0
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';

+ 55 - 0
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);
+}

+ 5 - 0
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__</0>?",
@@ -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</0>.",
+  "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__</0> 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",

+ 195 - 0
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<typeof createClipboardStub>
+
+  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')
+    })
+  })
+})

+ 773 - 0
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<Permissions>
+): FC<React.PropsWithChildren> => {
+  const defaultPermissions: Permissions = {
+    read: true,
+    comment: true,
+    resolveOwnComments: false,
+    resolveAllComments: false,
+    trackedWrite: false,
+    write: false,
+    admin: false,
+    labelVersion: false,
+  }
+
+  return function PermissionsProvider({ children }) {
+    return (
+      <PermissionsContext.Provider
+        value={{ ...defaultPermissions, ...permissions }}
+      >
+        {children}
+      </PermissionsContext.Provider>
+    )
+  }
+}
+
+const MockDetachCompileProvider: FC<React.PropsWithChildren> = ({
+  children,
+}) => (
+  <DetachCompileContext.Provider
+    value={
+      {
+        pdfUrl: '/pdf/output.pdf',
+        pdfViewer: 'pdfjs',
+        compiling: false,
+      } as any
+    }
+  >
+    {children}
+  </DetachCompileContext.Provider>
+)
+
+const MockFileTreeDataProvider: FC<React.PropsWithChildren> = ({
+  children,
+}) => (
+  <FileTreeDataContext.Provider
+    value={
+      {
+        selectedEntities: [{ type: 'doc', id: '_root_doc_id' } as any],
+      } as any
+    }
+  >
+    {children}
+  </FileTreeDataContext.Provider>
+)
+
+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(
+      <TestContainer>
+        <EditorProviders scope={scope}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </TestContainer>
+    )
+
+    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(
+      <TestContainer>
+        <EditorProviders scope={scope}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </TestContainer>
+    )
+
+    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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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<sinon.SinonStub>
+
+    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(
+        <TestContainer>
+          <EditorProviders
+            scope={scope}
+            providers={{
+              EditorPropertiesProvider: makeEditorPropertiesProvider({
+                wantTrackChanges: false,
+              }),
+            }}
+          >
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders
+            scope={scope}
+            providers={{
+              EditorPropertiesProvider: makeEditorPropertiesProvider({
+                wantTrackChanges: true,
+              }),
+            }}
+          >
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders
+            scope={scope}
+            providers={{
+              PermissionsProvider: createPermissionsProvider({
+                write: false,
+                trackedWrite: false,
+                comment: true,
+              }),
+            }}
+          >
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      // 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(
+        <TestContainer>
+          <EditorProviders
+            scope={scope}
+            providers={{
+              PermissionsProvider: createPermissionsProvider({
+                write: false,
+                trackedWrite: false,
+                comment: false,
+              }),
+            }}
+          >
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      // 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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      cy.fixture<Uint8Array<ArrayBuffer>>('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(
+        <TestContainer>
+          <EditorProviders
+            scope={scope}
+            providers={{
+              DetachCompileProvider: MockDetachCompileProvider,
+              FileTreeDataProvider: MockFileTreeDataProvider,
+            }}
+          >
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      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(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      cy.get('.cm-gutterElement').eq(5).rightclick()
+      cy.get('.editor-context-menu').should('not.exist')
+    })
+  })
+})