mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Merge pull request #30495 from overleaf/mg-context-menu-paste
[web] Support paste with formatting in context menu GitOrigin-RevId: 551ed1d49ca423395bd9bfc756e10e8d59d71ecd
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user