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

Add context menu (right click) to editor

GitOrigin-RevId: f2e567b51b04170ba1a46b0ab4659f3481b05bfe
This commit is contained in:
Malik Glossop
2026-01-15 10:07:58 +01:00
committed by Copybot
parent 5e38efb6b2
commit f087d125c1
21 changed files with 1722 additions and 35 deletions

View File

@@ -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)

View File

@@ -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": "",

View File

@@ -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',

View File

@@ -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
}
}

View File

@@ -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 />}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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'))

View File

@@ -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,
},
})

View File

@@ -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,
})
},
})
}

View File

@@ -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(),

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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! Youve 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",

View File

@@ -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')
})
})
})

View File

@@ -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')
})
})
})