Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/super-editor/src/components/context-menu/menuItems.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: '<p>html</p>', text: 'html' });

const pasteAction = getPasteAction(editor);
await pasteAction(editor);

// Selection must be restored BEFORE handleClipboardPaste is called
expect(callOrder).toEqual(['setSelection', 'handleClipboardPaste']);
});
});
});
104 changes: 104 additions & 0 deletions tests/behavior/tests/slash-menu/paste.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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');
});
Loading