Преглед изворни кода

Merge pull request #30495 from overleaf/mg-context-menu-paste

[web] Support paste with formatting in context menu

GitOrigin-RevId: 551ed1d49ca423395bd9bfc756e10e8d59d71ecd
Malik Glossop пре 7 месеци
родитељ
комит
a0fc14b367

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

@@ -60,6 +60,7 @@ export const CommandRegistryProvider: React.FC<React.PropsWithChildren> = ({
       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: [
         {

+ 78 - 40
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<boolean> => {
   const selections = view.state.selection.ranges
   const changes = []
@@ -62,54 +95,59 @@ 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,
-    })
+  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
+    pastePlainText(view, text)
+    return true
+  } catch {
+    // Clipboard access denied or empty
+    return false
+  }
+}
+
+export const pasteWithFormatting = async (
+  view: EditorView
+): Promise<boolean> => {
+  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++
+        }
       }
     }
 
-    view.dispatch({
-      changes,
-      selection: { anchor: lastChangeTo },
-    })
+    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 access denied or empty
-    return false
+    // Clipboard.read not available, or latex conversion failed, use standard paste behavior
+    return await pasteWithoutFormatting(view)
   }
 }

+ 22 - 2
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 }

+ 37 - 24
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')
-
-          // 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)
-          ) {
-            return false
-          }
-
-          const bodyElement = documentElement.querySelector('body')
-          // DOMParser should always create a body element, so this is mostly for TypeScript
-          if (!bodyElement) {
-            return false
-          }
+          const latex = convertHtmlStringToLatex(
+            html,
+            clipboardData.files.length
+          )
 
-          // if the only content is in a code block, use the plain text version
-          if (onlyCode(bodyElement)) {
+          // if there's no latex conversion, use plain text version
+          if (latex === null) {
             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

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

+ 13 - 0
services/web/frontend/js/features/source-editor/utils/paste-image.ts

@@ -45,3 +45,16 @@ export async function findImageInClipboard(): Promise<File | null> {
 
   return null
 }
+
+export const handleImagePaste = async (): Promise<boolean> => {
+  const imageFile = await findImageInClipboard()
+  if (imageFile) {
+    dispatchFigureModalPasteEvent({
+      name: imageFile.name,
+      type: imageFile.type,
+      data: imageFile,
+    })
+    return true
+  }
+  return false
+}

+ 76 - 0
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('<b>x</b>', '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('<strong>x</strong>', '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)
+    })
   })
 })

+ 142 - 45
services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx

@@ -66,6 +66,21 @@ const MockFileTreeDataProvider: FC<React.PropsWithChildren> = ({
   </FileTreeDataContext.Provider>
 )
 
+// 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 =
+      '<b>foo</b><sup>th</sup> <i>bar</i><sub>2</sub> baz <em>woo</em> <strong>woo</strong> 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(
+          <TestContainer>
+            <EditorProviders scope={scope}>
+              <CodeMirrorEditor />
+            </EditorProviders>
+          </TestContainer>
+        )
+
+        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(
+          <TestContainer>
+            <EditorProviders scope={scope}>
+              <CodeMirrorEditor />
+            </EditorProviders>
+          </TestContainer>
+        )
+
+        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(