diff --git a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx index f3ea32b97e..f4f89f7b99 100644 --- a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx @@ -60,6 +60,7 @@ export const CommandRegistryProvider: React.FC = ({ cut: [{ key: 'Mod-x' }], copy: [{ key: 'Mod-c' }], paste: [{ key: 'Mod-v' }], + 'paste-special': [{ key: 'Mod-Shift-V' }], 'toggle-track-changes': [{ key: 'Mod-Shift-A' }], undo: [ { diff --git a/services/web/frontend/js/features/source-editor/commands/clipboard.ts b/services/web/frontend/js/features/source-editor/commands/clipboard.ts index 115ac0722c..af84e67f41 100644 --- a/services/web/frontend/js/features/source-editor/commands/clipboard.ts +++ b/services/web/frontend/js/features/source-editor/commands/clipboard.ts @@ -1,8 +1,11 @@ import { EditorView } from '@codemirror/view' +import { EditorSelection } from '@codemirror/state' +import { handleImagePaste } from '../utils/paste-image' +import { convertHtmlStringToLatex } from '../extensions/visual/paste-html' import { - findImageInClipboard, - dispatchFigureModalPasteEvent, -} from '../utils/paste-image' + insertPastedContent, + storePastedContent, +} from '../extensions/visual/pasted-content' const getEntireLineText = (view: EditorView, pos: number): string => { const line = view.state.doc.lineAt(pos) @@ -10,6 +13,36 @@ const getEntireLineText = (view: EditorView, pos: number): string => { return atDocumentEnd ? line.text : line.text + view.state.lineBreak } +const pastePlainText = (view: EditorView, text: string): void => { + // Detect line-wise paste: single line of text with trailing linebreak + const textWithoutTrailingBreak = text.slice(0, -view.state.lineBreak.length) + const isLineWise = + text.endsWith(view.state.lineBreak) && + !textWithoutTrailingBreak.includes(view.state.lineBreak) + + // Use changeByRange to apply paste to each selection/range + const changes = view.state.changeByRange(range => { + const { from, to } = range + const noSelection = from === to + const shouldInsertAtLineStart = noSelection && isLineWise + + if (shouldInsertAtLineStart) { + const line = view.state.doc.lineAt(from) + return { + changes: { from: line.from, to: line.from, insert: text }, + range: EditorSelection.cursor(line.from + text.length), + } + } + + return { + changes: { from, to, insert: text }, + range: EditorSelection.cursor(from + text.length), + } + }) + + view.dispatch(changes) +} + export const cutSelection = async (view: EditorView): Promise => { const selections = view.state.selection.ranges const changes = [] @@ -62,54 +95,59 @@ export const pasteWithoutFormatting = async ( view: EditorView ): Promise => { // Check for pasted images first - const imageFile = await findImageInClipboard() - if (imageFile) { - dispatchFigureModalPasteEvent({ - name: imageFile.name, - type: imageFile.type, - data: imageFile, - }) + if (await handleImagePaste()) { 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 }, - }) - + pastePlainText(view, text) return true } catch { // Clipboard access denied or empty return false } } + +export const pasteWithFormatting = async ( + view: EditorView +): Promise => { + try { + const clipboardItems = await navigator.clipboard.read() + + let html = '' + let text = '' + let nonTextBlobCount = 0 + for (const item of clipboardItems) { + for (const type of item.types) { + const blob = await item.getType(type) + if (type === 'text/html') { + html = (await blob.text()).trim() + } else if (type === 'text/plain') { + text = (await blob.text()).trim() + } else if (!type.startsWith('text/')) { + nonTextBlobCount++ + } + } + } + + if (!html) { + return await pasteWithoutFormatting(view) + } + + const latex = convertHtmlStringToLatex(html, nonTextBlobCount) + + if (latex === null || (latex === text && nonTextBlobCount === 0)) { + // No latex or formatting detected, use plain text paste + return await pasteWithoutFormatting(view) + } + + view.dispatch(insertPastedContent(view, { latex, text })) + view.dispatch(storePastedContent({ latex, text }, true)) + return true + } catch { + // Clipboard.read not available, or latex conversion failed, use standard paste behavior + return await pasteWithoutFormatting(view) + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/context-menu.ts b/services/web/frontend/js/features/source-editor/extensions/context-menu.ts index a2c07a901b..a4a6933551 100644 --- a/services/web/frontend/js/features/source-editor/extensions/context-menu.ts +++ b/services/web/frontend/js/features/source-editor/extensions/context-menu.ts @@ -81,6 +81,27 @@ function isPositionInsideSelection(pos: number, from: number, to: number) { return from !== to && pos >= from && pos <= to } +function isPositionInsideAnyRangeOrCursor(view: EditorView, pos: number) { + for (const range of view.state.selection.ranges) { + // If it's a cursor, treat a right-click anywhere on the same line as "inside". + // This avoids collapsing multi-cursor selections when right-clicking on blank lines + // or to the right of the caret. + if (range.from === range.to) { + const clickedLine = view.state.doc.lineAt(pos) + const cursorLine = view.state.doc.lineAt(range.from) + if (clickedLine.number === cursorLine.number) { + return true + } + continue + } + + if (isPositionInsideSelection(pos, range.from, range.to)) { + return true + } + } + return false +} + function selectEntireLine( view: EditorView, pos: number @@ -171,8 +192,7 @@ const editorContextMenuHandlers = (): Extension => return false } - const { from, to } = view.state.selection.main - const clickedInsideSelection = isPositionInsideSelection(pos, from, to) + const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos) // Set cursor to clicked position if outside selection let selection: TransactionSpec['selection'] = { anchor: pos } diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts index 01db7b18ba..f572fed274 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts @@ -45,35 +45,17 @@ export const pasteHtml = [ return false } - // convert the HTML to LaTeX try { - const parser = new DOMParser() - const { documentElement } = parser.parseFromString(html, 'text/html') + const latex = convertHtmlStringToLatex( + html, + clipboardData.files.length + ) - // fall back to creating a figure when there's an image on the clipoard, - // unless the HTML indicates that it came from an Office application - // (which also puts an image on the clipboard) - if ( - clipboardData.files.length > 0 && - !hasProgId(documentElement) && - !isOnlyTable(documentElement) - ) { + // if there's no latex conversion, use plain text version + if (latex === null) { return false } - const bodyElement = documentElement.querySelector('body') - // DOMParser should always create a body element, so this is mostly for TypeScript - if (!bodyElement) { - return false - } - - // if the only content is in a code block, use the plain text version - if (onlyCode(bodyElement)) { - return false - } - - const latex = htmlToLaTeX(bodyElement) - // if there's no formatting, use the plain text version if (latex === text && clipboardData.files.length === 0) { return false @@ -95,6 +77,37 @@ export const pasteHtml = [ pastedContent, ] +export function convertHtmlStringToLatex( + html: string, + filesLength: number +): string | null { + const parser = new DOMParser() + const { documentElement } = parser.parseFromString(html, 'text/html') + + // Do not process HTML as LaTeX when the clipboard contains files (e.g. images), + // unless the HTML is from an Office application or is a table-only selection. + if ( + filesLength > 0 && + !hasProgId(documentElement) && + !isOnlyTable(documentElement) + ) { + return null + } + + const bodyElement = documentElement.querySelector('body') + // DOMParser should always create a body element, so this is mostly for TypeScript + if (!bodyElement) { + return null + } + + // If the only content is a code block, skip latex conversion + if (onlyCode(bodyElement)) { + return null + } + + return htmlToLaTeX(bodyElement) +} + const removeUnwantedElements = ( documentElement: HTMLElement, selector: string diff --git a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx index b01e201657..83a7ffc447 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx +++ b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx @@ -20,7 +20,9 @@ import { cutSelection, copySelection, pasteWithoutFormatting, + pasteWithFormatting, } from '../commands/clipboard' +import { isVisual } from '../extensions/visual/visual' export const useContextMenuItems = () => { const view = useCodeMirrorViewContext() @@ -68,9 +70,16 @@ export const useContextMenuItems = () => { [view, closeMenu] ) + const inVisualMode = isVisual(view) + const handleCut = wrapForContextMenu(() => cutSelection(view)) const handleCopy = wrapForContextMenu(() => copySelection(view)) - const handlePaste = wrapForContextMenu(() => pasteWithoutFormatting(view)) + const handlePaste = wrapForContextMenu(() => + inVisualMode ? pasteWithFormatting(view) : pasteWithoutFormatting(view) + ) + const handlePasteSpecial = wrapForContextMenu(() => + inVisualMode ? pasteWithoutFormatting(view) : pasteWithFormatting(view) + ) const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view)) const handleToggleTrackChanges = wrapForContextMenu(() => { @@ -120,6 +129,15 @@ export const useContextMenuItems = () => { show: canEdit, shortcut: getShortcut('paste'), }, + { + label: inVisualMode + ? t('paste_without_formatting') + : t('paste_with_formatting'), + handler: handlePasteSpecial, + disabled: false, + show: canEdit, + shortcut: inVisualMode ? getShortcut('paste-special') : undefined, + }, { label: t('delete'), handler: handleDelete, diff --git a/services/web/frontend/js/features/source-editor/utils/paste-image.ts b/services/web/frontend/js/features/source-editor/utils/paste-image.ts index 20bdeda423..4ac50b2cb2 100644 --- a/services/web/frontend/js/features/source-editor/utils/paste-image.ts +++ b/services/web/frontend/js/features/source-editor/utils/paste-image.ts @@ -45,3 +45,16 @@ export async function findImageInClipboard(): Promise { return null } + +export const handleImagePaste = async (): Promise => { + const imageFile = await findImageInClipboard() + if (imageFile) { + dispatchFigureModalPasteEvent({ + name: imageFile.name, + type: imageFile.type, + data: imageFile, + }) + return true + } + return false +} diff --git a/services/web/test/frontend/features/source-editor/commands/clipboard.test.ts b/services/web/test/frontend/features/source-editor/commands/clipboard.test.ts index 54cd07fedb..730d47c39c 100644 --- a/services/web/test/frontend/features/source-editor/commands/clipboard.test.ts +++ b/services/web/test/frontend/features/source-editor/commands/clipboard.test.ts @@ -5,6 +5,7 @@ import { copySelection, cutSelection, pasteWithoutFormatting, + pasteWithFormatting, } from '../../../../../frontend/js/features/source-editor/commands/clipboard' const createClipboardStub = () => { @@ -191,5 +192,80 @@ describe('clipboard behavior', function () { await pasteWithoutFormatting(view) expect(view.state.doc.toString()).to.equal('new\nline1\nnew\nline2') }) + + it('preserves multiple cursors after pasting into multiple selections', async function () { + clipboard.reads.push('XX') + const view = createViewWithMultipleRanges('abcdefgh', [ + { anchor: 1, head: 2 }, // "b" + { anchor: 5, head: 6 }, // "f" + ]) + await pasteWithoutFormatting(view) + const ranges = view.state.selection.ranges + expect(ranges).to.have.length(2) + expect(ranges.every(r => r.empty)).to.equal(true) + }) + + it('preserves multiple cursors when pasting line-wise content', async function () { + clipboard.reads.push('new\n') + const view = createViewWithMultipleRanges('line1\nline2', [ + { anchor: 2 }, // in "line1" + { anchor: 8 }, // in "line2" + ]) + await pasteWithoutFormatting(view) + // After line-wise paste at line starts: cursor should be after each "new\n" + const ranges = view.state.selection.ranges + expect(ranges).to.have.length(2) + expect(ranges[0].anchor).to.equal(4) // after first "new\n" + expect(ranges[1].anchor).to.equal(14) // after second "new\n" (original pos 8 + 4 from first insert + 4 from second) + }) + }) + + describe('pasteWithFormatting', function () { + // Helper to set clipboard.read with HTML + plain text + const setClipboardHtml = (html: string, text: string) => { + ;(navigator as any).clipboard.read = async () => [ + { + types: ['text/html', 'text/plain'], + // Avoid relying on DOM Blob in the Mocha/jsdom environment. + getType: async (type: string) => + ({ + text: async () => (type === 'text/html' ? html : text), + }) as any, + }, + ] + ;(navigator as any).clipboard.readText = async () => text + } + + it('pastes converted HTML into a single selection', async function () { + setClipboardHtml('x', 'x') + const view = createView('abcde', 2, 3) // replace 'c' + await pasteWithFormatting(view) + expect(view.state.doc.toString()).to.equal('ab\\textbf{x}de') + expect(view.state.selection.ranges).to.have.length(1) + expect(view.state.selection.main.empty).to.equal(true) + }) + + it('pastes converted HTML into all selected ranges (multi-range)', async function () { + setClipboardHtml('x', 'x') + const view = createViewWithMultipleRanges('abcdefgh', [ + { anchor: 1, head: 2 }, // b + { anchor: 5, head: 6 }, // f + ]) + await pasteWithFormatting(view) + expect(view.state.doc.toString()).to.equal('a\\textbf{x}cde\\textbf{x}gh') + expect(view.state.selection.ranges).to.have.length(2) + expect(view.state.selection.ranges.every(r => r.empty)).to.equal(true) + }) + + it('falls back to plain text when there is no formatting', async function () { + setClipboardHtml('x', 'x') + const view = createViewWithMultipleRanges('abcdefgh', [ + { anchor: 1, head: 2 }, + { anchor: 5, head: 6 }, + ]) + await pasteWithFormatting(view) + expect(view.state.doc.toString()).to.equal('axcdexgh') + expect(view.state.selection.ranges).to.have.length(2) + }) }) }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx index 512027cb41..7acac8e90a 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx @@ -66,6 +66,21 @@ const MockFileTreeDataProvider: FC = ({ ) +// Regex to match plain "Paste" but exclude "Paste with formatting" and "Paste without formatting" +const pasteLabelMatcher = /^paste(?! with| without)/i + +const grantClipboardPermissions = () => { + cy.wrap( + Cypress.automation('remote:debugger:protocol', { + command: 'Browser.grantPermissions', + params: { + permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], + origin: window.location.origin, + }, + }) + ) +} + describe('editor context menu', { scrollBehavior: false }, function () { beforeEach(function () { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) @@ -133,7 +148,12 @@ describe('editor context menu', { scrollBehavior: false }, function () { 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: pasteLabelMatcher }).should( + 'be.enabled' + ) + cy.findByRole('menuitem', { + name: /paste with formatting/i, + }).should('be.enabled') cy.findByRole('menuitem', { name: /suggest edits/i }).should( 'be.enabled' ) @@ -170,7 +190,12 @@ describe('editor context menu', { scrollBehavior: false }, function () { 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: pasteLabelMatcher }).should( + 'be.enabled' + ) + cy.findByRole('menuitem', { + name: /paste with formatting/i, + }).should('be.enabled') cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled') cy.findByRole('menuitem', { name: /suggest edits/i }).should( 'be.enabled' @@ -180,16 +205,7 @@ describe('editor context menu', { scrollBehavior: false }, function () { }) 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, - }, - }) - ) + grantClipboardPermissions() const scope = mockScope() @@ -219,17 +235,8 @@ describe('editor context menu', { scrollBehavior: false }, function () { 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, - }, - }) - ) + it('should cut and paste text', function () { + grantClipboardPermissions() const scope = mockScope() @@ -266,7 +273,7 @@ describe('editor context menu', { scrollBehavior: false }, function () { // Paste "world" at the beginning cy.get('.editor-context-menu').within(() => { - cy.findByRole('menuitem', { name: /paste/i }).click() + cy.findByRole('menuitem', { name: pasteLabelMatcher }).click() }) cy.get('.editor-context-menu').should('not.exist') @@ -458,7 +465,12 @@ describe('editor context menu', { scrollBehavior: false }, function () { 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: pasteLabelMatcher }).should( + 'not.exist' + ) + cy.findByRole('menuitem', { name: /paste with formatting/ }).should( + 'not.exist' + ) cy.findByRole('menuitem', { name: /delete/i }).should('not.exist') cy.findByRole('menuitem', { name: /suggest edits/i }).should( 'not.exist' @@ -512,8 +524,8 @@ describe('editor context menu', { scrollBehavior: false }, function () { }) }) - describe('pasting images via context menu', function () { - it('should open figure modal on pasting image via context menu', function () { + describe('when pasting an image', function () { + it('should open figure modal on pasting image', function () { const scope = mockScope() cy.mount( @@ -546,7 +558,7 @@ describe('editor context menu', { scrollBehavior: false }, function () { // Click paste button cy.get('.editor-context-menu').within(() => { - cy.findByRole('menuitem', { name: /paste/i }).click() + cy.findByRole('menuitem', { name: pasteLabelMatcher }).click() }) // Figure modal should open with the image @@ -559,6 +571,95 @@ describe('editor context menu', { scrollBehavior: false }, function () { }) }) + describe('when a user has HTML content in the clipboard', function () { + const formattedHtml = + 'footh bar2 baz woo woo woo' + const plainText = 'footh bar2 baz woo woo woo' + + beforeEach(function () { + grantClipboardPermissions() + + // Stub the clipboard API with formatted HTML + cy.window().then(win => { + const getTypeStub = cy.stub() + getTypeStub + .withArgs('text/html') + .resolves(new Blob([formattedHtml], { type: 'text/html' })) + getTypeStub + .withArgs('text/plain') + .resolves(new Blob([plainText], { type: 'text/plain' })) + + cy.stub(win.navigator.clipboard, 'read').resolves([ + { + types: ['text/html', 'text/plain'], + getType: getTypeStub, + }, + ]) + cy.stub(win.navigator.clipboard, 'readText').resolves(plainText) + }) + }) + + describe('when pasting with formatting', function () { + it('should paste formatted HTML with LaTeX commands', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { + name: /paste with formatting/i, + }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + + cy.get('.cm-line').should($lines => { + const text = $lines.text() + expect(text).to.include( + '\\textbf{foo}\\textsuperscript{th} \\textit{bar}\\textsubscript{2} baz \\textit{woo} \\textbf{woo} woo' + ) + }) + }) + }) + + describe('when pasting without formatting', function () { + it('should paste plain text without LaTeX commands', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(10).rightclick() + cy.get('.editor-context-menu').within(() => { + cy.findByRole('menuitem', { name: pasteLabelMatcher }).click() + }) + + cy.get('.editor-context-menu').should('not.exist') + + cy.get('.cm-line').should($lines => { + const text = $lines.text() + expect(text).to.include('footh bar2 baz woo woo woo') + expect(text).to.not.include('\\textbf{foo}') + expect(text).to.not.include('\\textsuperscript{th}') + expect(text).to.not.include('\\textit{bar}') + expect(text).to.not.include('\\textsubscript{2}') + }) + }) + }) + }) + describe('sync to PDF button', function () { beforeEach(function () { // Stub the sync API call @@ -588,7 +689,9 @@ describe('editor context menu', { scrollBehavior: false }, function () { 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.findByRole('menuitem', { + name: /jump to location in pdf/i, + }).click() }) cy.get('.editor-context-menu').should('not.exist') @@ -619,18 +722,18 @@ describe('editor context menu', { scrollBehavior: false }, function () { 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' - ) + cy.findByRole('menuitem', { + name: /jump to location in pdf/i, + }).should('not.exist') }) }) }) - describe('gutter context menu', function () { + describe('when right-clicking on the gutter', function () { const editorLine = 2 const gutterLineIndex = editorLine + 1 // extra hidden gutter line - it('should select entire line when right-clicking on gutter', function () { + it('should select entire line', function () { const scope = mockScope() cy.mount( @@ -657,15 +760,7 @@ describe('editor context menu', { scrollBehavior: false }, function () { }) 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, - }, - }) - ) + grantClipboardPermissions() const scope = mockScope() @@ -693,7 +788,9 @@ describe('editor context menu', { scrollBehavior: false }, function () { 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: pasteLabelMatcher }).should( + 'be.enabled' + ) cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled') cy.findByRole('menuitem', { name: /suggest edits/i }).should( 'be.enabled' @@ -714,7 +811,7 @@ describe('editor context menu', { scrollBehavior: false }, function () { ) }) - it('should close menu when clicking elsewhere after gutter right-click', function () { + it('should close menu when clicking elsewhere', function () { const scope = mockScope() cy.mount(