From 97621901c761c315238ccf780f743f95590ef8be Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 19 Feb 2026 11:25:22 -0300 Subject: [PATCH 1/3] fix(context-menu): paste via context menu inserts at wrong position (SD-1302) When the context menu is open, its hidden search input holds focus, causing the ProseMirror editor's contenteditable to lose focus. The paste action previously called `view.dom.focus()` (raw DOM focus), which restarts ProseMirror's DOMObserver. The observer reads the stale browser selection (collapsed at the document start) and overwrites the PM state, causing content to be pasted at position 0 instead of the cursor location. Fix: use `view.focus()` (ProseMirror-aware) which properly syncs the PM selection to the DOM before restarting the observer. Additionally, save and restore the selection across the async `readClipboardRaw()` gap as a safety net against further async drift. --- .../src/components/context-menu/menuItems.js | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 }); From 90b70a1f7245b2b118f8238d72081f80941d4590 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 19 Feb 2026 11:32:15 -0300 Subject: [PATCH 2/3] test(context-menu): add tests for paste selection preservation (SD-1302) Verify the paste action saves the cursor position before focusing, restores it after the async clipboard read, and clamps positions when the document shrinks during the async gap. --- .../context-menu/tests/menuItems.test.js | 183 +++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) 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']); + }); + }); }); From 38edec0c49267ded6bdd4b11edaebe572c210f58 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Feb 2026 21:26:51 -0800 Subject: [PATCH 3/3] chore: add behavior tests --- tests/behavior/tests/slash-menu/paste.spec.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) 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'); +});