diff --git a/packages/super-editor/src/components/context-menu/menuItems.js b/packages/super-editor/src/components/context-menu/menuItems.js index e5416a89f..3e0580304 100644 --- a/packages/super-editor/src/components/context-menu/menuItems.js +++ b/packages/super-editor/src/components/context-menu/menuItems.js @@ -290,8 +290,31 @@ export function getItems(context, customItems = [], includeDefaultItems = true) action: async (editor) => { const { view } = editor ?? {}; if (!view) return; - view.dom.focus(); + // Save the current selection before focusing. When the context menu + // is open, its hidden search input holds focus, so the PM editor's + // contenteditable is blurred. A raw `view.dom.focus()` would restart + // ProseMirror's DOMObserver which reads the stale browser selection + // (collapsed at the document start) and overwrites the PM state. + // Using `view.focus()` (ProseMirror-aware) prevents this by writing + // the PM selection to the DOM before restarting the observer. We also + // save/restore as a safety net against async drift during clipboard reads. + const savedFrom = view.state.selection.from; + const savedTo = view.state.selection.to; + view.focus(); const { html, text } = await readClipboardRaw(); + // Restore selection after the async gap — ProseMirror's DOMObserver + // may have overwritten the PM selection with a stale DOM selection + // (collapsed at document start) while awaiting the clipboard read. + if (view.state?.doc?.content) { + const { tr, doc } = view.state; + const maxPos = doc.content.size; + const safeFrom = Math.min(savedFrom, maxPos); + const safeTo = Math.min(savedTo, maxPos); + const SelectionType = view.state.selection.constructor; + if (typeof SelectionType.create === 'function') { + view.dispatch(tr.setSelection(SelectionType.create(doc, safeFrom, safeTo))); + } + } const handled = html ? handleClipboardPaste({ editor, view }, html) : false; if (!handled) { const pasteEvent = createPasteEventShim({ html, text }); diff --git a/packages/super-editor/src/components/context-menu/tests/menuItems.test.js b/packages/super-editor/src/components/context-menu/tests/menuItems.test.js index 10fbcc019..71944f971 100644 --- a/packages/super-editor/src/components/context-menu/tests/menuItems.test.js +++ b/packages/super-editor/src/components/context-menu/tests/menuItems.test.js @@ -550,7 +550,6 @@ describe('menuItems.js', () => { mockEditor = createMockEditor({ commands: { insertContent }, }); - mockEditor.view.dom.focus = vi.fn(); // No pasteHTML or pasteText on view delete mockEditor.view.pasteHTML; delete mockEditor.view.pasteText; @@ -575,4 +574,186 @@ describe('menuItems.js', () => { expect(insertContent).toHaveBeenCalledWith('fallback text', { contentType: 'text' }); }); }); + + describe('getItems - paste selection preservation (SD-1302)', () => { + /** + * Creates a mock editor with doc.content.size and selection.constructor.create + * to exercise the selection save/restore logic in the paste action. + */ + function createPasteTestEditor(options = {}) { + const selFrom = options.selectionFrom ?? 50; + const selTo = options.selectionTo ?? 55; + const docSize = options.docSize ?? 100; + + const mockDoc = { + textBetween: vi.fn(() => ''), + nodeAt: vi.fn(() => ({ type: { name: 'paragraph' } })), + resolve: vi.fn(() => ({})), + content: { size: docSize }, + }; + + const mockCreate = vi.fn((_doc, from, to) => ({ from, to })); + + const mockSelection = { + from: selFrom, + to: selTo, + empty: selFrom === selTo, + $head: { marks: vi.fn(() => []) }, + $from: { depth: 2, node: vi.fn(() => ({ type: { name: 'paragraph' } })) }, + $to: { depth: 2, node: vi.fn(() => ({ type: { name: 'paragraph' } })) }, + constructor: { create: mockCreate, near: vi.fn() }, + }; + + const mockSetSelection = vi.fn(function () { + return this; + }); + const mockTr = { + setMeta: vi.fn(function () { + return this; + }), + setSelection: mockSetSelection, + }; + + const editor = createMockEditor({ + commands: options.commands || {}, + }); + + // Replace state with enhanced version + editor.view.state.selection = mockSelection; + editor.view.state.doc = mockDoc; + editor.view.state.tr = mockTr; + editor.state = editor.view.state; + + // Add pasteText/pasteHTML if not explicitly removed + if (options.pasteText !== false) { + editor.view.pasteText = vi.fn(); + } + if (options.pasteHTML !== false) { + editor.view.pasteHTML = vi.fn(); + } + + return { + editor, + mocks: { mockCreate, mockSetSelection, mockDoc, mockSelection, mockTr }, + }; + } + + /** Helper to extract the paste action from menu items */ + function getPasteAction(editor) { + const context = createMockContext({ editor, trigger: TRIGGERS.click }); + const sections = getItems(context); + return sections.find((section) => section.id === 'clipboard')?.items.find((item) => item.id === 'paste')?.action; + } + + it('should call view.focus() instead of view.dom.focus()', async () => { + const { editor } = createPasteTestEditor(); + editor.view.dom.focus = vi.fn(); + + clipboardMocks.readClipboardRaw.mockResolvedValue({ html: '', text: 'test' }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const pasteAction = getPasteAction(editor); + await pasteAction(editor); + + expect(editor.view.focus).toHaveBeenCalled(); + expect(editor.view.dom.focus).not.toHaveBeenCalled(); + }); + + it('should save selection before focus and restore it after clipboard read', async () => { + const { editor, mocks } = createPasteTestEditor({ selectionFrom: 50, selectionTo: 55, docSize: 100 }); + + clipboardMocks.readClipboardRaw.mockResolvedValue({ html: '', text: 'pasted' }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const pasteAction = getPasteAction(editor); + await pasteAction(editor); + + // Selection should be restored via dispatch(tr.setSelection(...)) + expect(mocks.mockCreate).toHaveBeenCalledWith(editor.view.state.doc, 50, 55); + expect(mocks.mockSetSelection).toHaveBeenCalled(); + expect(editor.view.dispatch).toHaveBeenCalled(); + }); + + it('should clamp restored selection to doc size when document shrinks during async gap', async () => { + // Simulate: selection was at pos 90-95, but doc shrunk to size 80 during async clipboard read + const { editor, mocks } = createPasteTestEditor({ selectionFrom: 90, selectionTo: 95, docSize: 80 }); + + clipboardMocks.readClipboardRaw.mockResolvedValue({ html: '', text: 'text' }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const pasteAction = getPasteAction(editor); + await pasteAction(editor); + + // Positions should be clamped to maxPos (80) + expect(mocks.mockCreate).toHaveBeenCalledWith(editor.view.state.doc, 80, 80); + }); + + it('should skip selection restore when doc.content is not available', async () => { + const { editor, mocks } = createPasteTestEditor(); + // Remove doc.content to simulate missing property + delete editor.view.state.doc.content; + + clipboardMocks.readClipboardRaw.mockResolvedValue({ html: '', text: 'text' }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const pasteAction = getPasteAction(editor); + await pasteAction(editor); + + // setSelection should NOT have been called (no selection restore) + expect(mocks.mockSetSelection).not.toHaveBeenCalled(); + // But paste should still proceed — pasteText should be called + expect(editor.view.pasteText).toHaveBeenCalled(); + }); + + it('should skip selection restore when SelectionType.create is not a function', async () => { + const { editor, mocks } = createPasteTestEditor(); + // Remove create method to simulate selection type without static create + delete editor.view.state.selection.constructor.create; + + clipboardMocks.readClipboardRaw.mockResolvedValue({ html: '', text: 'text' }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const pasteAction = getPasteAction(editor); + await pasteAction(editor); + + // setSelection should NOT have been called + expect(mocks.mockSetSelection).not.toHaveBeenCalled(); + // Paste should still proceed + expect(editor.view.pasteText).toHaveBeenCalled(); + }); + + it('should restore a collapsed selection (cursor) correctly', async () => { + const { editor, mocks } = createPasteTestEditor({ selectionFrom: 42, selectionTo: 42, docSize: 100 }); + + clipboardMocks.readClipboardRaw.mockResolvedValue({ html: '', text: 'word' }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const pasteAction = getPasteAction(editor); + await pasteAction(editor); + + // Both from and to should be the same cursor position + expect(mocks.mockCreate).toHaveBeenCalledWith(editor.view.state.doc, 42, 42); + }); + + it('should restore selection before invoking handleClipboardPaste', async () => { + const { editor, mocks } = createPasteTestEditor({ selectionFrom: 50, selectionTo: 55 }); + + const callOrder = []; + mocks.mockSetSelection.mockImplementation(function () { + callOrder.push('setSelection'); + return this; + }); + clipboardMocks.handleClipboardPaste.mockImplementation(() => { + callOrder.push('handleClipboardPaste'); + return true; + }); + clipboardMocks.readClipboardRaw.mockResolvedValue({ html: '
html
', text: 'html' }); + + const pasteAction = getPasteAction(editor); + await pasteAction(editor); + + // Selection must be restored BEFORE handleClipboardPaste is called + expect(callOrder).toEqual(['setSelection', 'handleClipboardPaste']); + }); + }); }); diff --git a/tests/behavior/tests/slash-menu/paste.spec.ts b/tests/behavior/tests/slash-menu/paste.spec.ts index ad13ba6c6..1fcf4d948 100644 --- a/tests/behavior/tests/slash-menu/paste.spec.ts +++ b/tests/behavior/tests/slash-menu/paste.spec.ts @@ -16,6 +16,28 @@ async function writeToClipboard(page: import('@playwright/test').Page, text: str await page.evaluate((t) => navigator.clipboard.writeText(t), text); } +async function rightClickAtDocPos(page: import('@playwright/test').Page, pos: number) { + const coords = await page.evaluate((p) => { + const editor = (window as any).editor; + const rect = editor?.coordsAtPos?.(p); + if (!rect) return null; + return { + left: Number(rect.left), + right: Number(rect.right), + top: Number(rect.top), + bottom: Number(rect.bottom), + }; + }, pos); + + if (!coords) { + throw new Error(`Could not resolve coordinates for document position ${pos}`); + } + + const x = Math.min(Math.max(coords.left + 1, coords.left), Math.max(coords.right - 1, coords.left + 1)); + const y = (coords.top + coords.bottom) / 2; + await page.mouse.click(x, y, { button: 'right' }); +} + test('right-click opens context menu and paste inserts clipboard text', async ({ superdoc }) => { await superdoc.type('Hello world'); await superdoc.newLine(); @@ -46,3 +68,85 @@ test('right-click opens context menu and paste inserts clipboard text', async ({ // Assert the clipboard text was pasted into the document await superdoc.assertTextContains('Pasted content'); }); + +test('context menu paste inserts at cursor position, not document start (SD-1302)', async ({ superdoc }) => { + await superdoc.type('AAA BBB'); + await superdoc.waitForStable(); + + // Place cursor between AAA and BBB + const pos = await superdoc.findTextPos('BBB'); + await superdoc.setTextSelection(pos, pos); + await superdoc.waitForStable(); + + await writeToClipboard(superdoc.page, 'INSERTED '); + + // Right-click exactly at the current cursor position. + await rightClickAtDocPos(superdoc.page, pos); + await superdoc.waitForStable(); + + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + const pasteItem = menu.locator('.context-menu-item').filter({ hasText: 'Paste' }); + await pasteItem.click(); + await superdoc.waitForStable(); + + // Pasted text should appear between AAA and BBB, NOT at doc start + await superdoc.assertTextContains('AAA INSERTED BBB'); + await superdoc.assertTextNotContains('INSERTED AAA'); +}); + +test('context menu paste replaces selected text (SD-1302)', async ({ superdoc, browserName }) => { + test.skip(browserName === 'firefox', 'Firefox collapses selection on right-click natively'); + + await superdoc.type('Hello cruel world'); + await superdoc.waitForStable(); + + // Select "cruel" + const pos = await superdoc.findTextPos('cruel'); + await superdoc.setTextSelection(pos, pos + 'cruel'.length); + await superdoc.waitForStable(); + + await writeToClipboard(superdoc.page, 'beautiful'); + + // Right-click inside the selected range to preserve it. + await rightClickAtDocPos(superdoc.page, pos + 1); + await superdoc.waitForStable(); + + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + const pasteItem = menu.locator('.context-menu-item').filter({ hasText: 'Paste' }); + await pasteItem.click(); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('Hello beautiful world'); + await superdoc.assertTextNotContains('cruel'); +}); + +test('context menu paste at end of document appends correctly (SD-1302)', async ({ superdoc }) => { + await superdoc.type('First line'); + await superdoc.newLine(); + await superdoc.type('Last line'); + await superdoc.waitForStable(); + + // Place cursor at the end of "Last line" + const pos = await superdoc.findTextPos('Last line'); + await superdoc.setTextSelection(pos + 'Last line'.length, pos + 'Last line'.length); + await superdoc.waitForStable(); + + await writeToClipboard(superdoc.page, ' appended'); + + // Right-click on the second line + const line = superdoc.page.locator('.superdoc-line').nth(1); + const box = await line.boundingBox(); + if (!box) throw new Error('Line not visible'); + await superdoc.page.mouse.click(box.x + box.width - 5, box.y + box.height / 2, { button: 'right' }); + await superdoc.waitForStable(); + + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + const pasteItem = menu.locator('.context-menu-item').filter({ hasText: 'Paste' }); + await pasteItem.click(); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('Last line appended'); +});