From ee04e063c6a3c104cee850febad800343ffc5722 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 2 Feb 2026 16:17:00 +0200 Subject: [PATCH 1/4] fix: allow paste from context menu --- .../src/components/slash-menu/menuItems.js | 21 +++++++------- .../src/core/utilities/clipboardUtils.d.ts | 4 +++ .../src/core/utilities/clipboardUtils.js | 29 +++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/components/slash-menu/menuItems.js b/packages/super-editor/src/components/slash-menu/menuItems.js index 92bd438cf1..3b92461cd7 100644 --- a/packages/super-editor/src/components/slash-menu/menuItems.js +++ b/packages/super-editor/src/components/slash-menu/menuItems.js @@ -4,6 +4,8 @@ import TableActions from '../toolbar/TableActions.vue'; import LinkInput from '../toolbar/LinkInput.vue'; import { TEXTS, ICONS, TRIGGERS } from './constants.js'; import { isTrackedChangeActionAllowed } from '@extensions/track-changes/permission-helpers.js'; +import { readClipboardRaw } from '../../core/utilities/clipboardUtils.js'; +import { handleClipboardPaste } from '../../core/InputRule.js'; /** * Check if a module is enabled based on editor options @@ -257,17 +259,14 @@ export function getItems(context, customItems = [], includeDefaultItems = true) label: TEXTS.paste, icon: ICONS.paste, isDefault: true, - action: (editor) => { - // Use execCommand('paste') - triggers native paste without permission prompt - // This works because it's triggered by user interaction (clicking the menu item) - const editorDom = editor.view?.dom; - if (editorDom) { - editorDom.focus(); - // execCommand paste is allowed when triggered by user action - const success = document.execCommand('paste'); - if (!success) { - console.warn('[Paste] execCommand paste failed - clipboard may be empty or inaccessible'); - } + action: async (editor) => { + const { view } = editor ?? {}; + if (!view) return; + view.dom.focus(); + const { html, text } = await readClipboardRaw(); + const handled = html ? handleClipboardPaste({ editor, view }, html) : false; + if (!handled && text && editor.commands?.insertContent) { + editor.commands.insertContent(text, { contentType: 'text' }); } }, showWhen: (context) => { diff --git a/packages/super-editor/src/core/utilities/clipboardUtils.d.ts b/packages/super-editor/src/core/utilities/clipboardUtils.d.ts index a277ab6ae2..dacb2bc68d 100644 --- a/packages/super-editor/src/core/utilities/clipboardUtils.d.ts +++ b/packages/super-editor/src/core/utilities/clipboardUtils.d.ts @@ -7,6 +7,10 @@ * @returns {Promise} Whether clipboard read permission is granted */ export function ensureClipboardPermission(): Promise; +/** + * Reads raw HTML and text from the system clipboard (for use in paste actions). + */ +export function readClipboardRaw(): Promise<{ html: string; text: string }>; /** * Reads content from the system clipboard and parses it into a ProseMirror fragment. * Attempts to read HTML first, falling back to plain text if necessary. diff --git a/packages/super-editor/src/core/utilities/clipboardUtils.js b/packages/super-editor/src/core/utilities/clipboardUtils.js index 80a1ba7556..12ce729c17 100644 --- a/packages/super-editor/src/core/utilities/clipboardUtils.js +++ b/packages/super-editor/src/core/utilities/clipboardUtils.js @@ -52,6 +52,35 @@ export async function ensureClipboardPermission() { } } +/** + * Reads raw HTML and text from the system clipboard (for use in paste actions). + * @returns {Promise<{ html: string, text: string }>} + */ +export async function readClipboardRaw() { + let html = ''; + let text = ''; + const hasPermission = await ensureClipboardPermission(); + + if (hasPermission && navigator.clipboard && navigator.clipboard.read) { + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + if (item.types.includes('text/html')) { + html = await (await item.getType('text/html')).text(); + } + if (item.types.includes('text/plain')) { + text = await (await item.getType('text/plain')).text(); + } + } + } catch { + try { + text = await navigator.clipboard.readText(); + } catch {} + } + } + return { html, text: text || '' }; +} + /** * Reads content from the system clipboard and parses it into a ProseMirror fragment. * Attempts to read HTML first, falling back to plain text if necessary. From 07ef1beca823f120385646e14cb4cc5b39aa12f8 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 2 Feb 2026 19:04:03 +0200 Subject: [PATCH 2/4] fix: additional check --- .../src/core/utilities/clipboardUtils.js | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/core/utilities/clipboardUtils.js b/packages/super-editor/src/core/utilities/clipboardUtils.js index 12ce729c17..2abdc7cac5 100644 --- a/packages/super-editor/src/core/utilities/clipboardUtils.js +++ b/packages/super-editor/src/core/utilities/clipboardUtils.js @@ -61,18 +61,24 @@ export async function readClipboardRaw() { let text = ''; const hasPermission = await ensureClipboardPermission(); - if (hasPermission && navigator.clipboard && navigator.clipboard.read) { - try { - const items = await navigator.clipboard.read(); - for (const item of items) { - if (item.types.includes('text/html')) { - html = await (await item.getType('text/html')).text(); - } - if (item.types.includes('text/plain')) { - text = await (await item.getType('text/plain')).text(); + if (hasPermission && navigator.clipboard) { + if (navigator.clipboard.read) { + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + if (item.types.includes('text/html')) { + html = await (await item.getType('text/html')).text(); + } + if (item.types.includes('text/plain')) { + text = await (await item.getType('text/plain')).text(); + } } + } catch { + try { + text = await navigator.clipboard.readText(); + } catch {} } - } catch { + } else { try { text = await navigator.clipboard.readText(); } catch {} From 94ecfc6de23035539b97796700a56808ff6f3ddc Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Wed, 4 Feb 2026 21:55:54 +0200 Subject: [PATCH 3/4] fix: optimise clipboard utils --- .../src/core/utilities/clipboardUtils.js | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/super-editor/src/core/utilities/clipboardUtils.js b/packages/super-editor/src/core/utilities/clipboardUtils.js index 2abdc7cac5..0e0342b70f 100644 --- a/packages/super-editor/src/core/utilities/clipboardUtils.js +++ b/packages/super-editor/src/core/utilities/clipboardUtils.js @@ -94,30 +94,7 @@ export async function readClipboardRaw() { * @returns {Promise} A promise that resolves to a ProseMirror fragment or text node, or null if reading fails. */ export async function readFromClipboard(state) { - let html = ''; - let text = ''; - const hasPermission = await ensureClipboardPermission(); - - if (hasPermission && navigator.clipboard && navigator.clipboard.read) { - try { - const items = await navigator.clipboard.read(); - for (const item of items) { - if (item.types.includes('text/html')) { - html = await (await item.getType('text/html')).text(); - break; - } else if (item.types.includes('text/plain')) { - text = await (await item.getType('text/plain')).text(); - } - } - } catch { - // Fallback to plain text read; may still fail if permission denied - try { - text = await navigator.clipboard.readText(); - } catch {} - } - } else { - // permissions denied or API unavailable; leave content empty - } + const { html, text } = await readClipboardRaw(); let content = null; if (html) { try { From 84ea1ff1ea4dd0e8c14a59cc533b3bee88456555 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 8 Feb 2026 13:16:46 -0800 Subject: [PATCH 4/4] fix(super-editor): preserve native paste pipeline and add clipboard readText fallback --- devtools/visual-testing/pnpm-lock.yaml | 2 +- .../basic-commands/slash-menu-paste.ts | 83 ++++++++++++++ .../src/components/slash-menu/menuItems.js | 42 ++++++- .../slash-menu/tests/menuItems.test.js | 108 +++++++++++++++++- .../src/core/utilities/clipboardUtils.js | 48 +++++--- .../utilities/tests/clipboardUtils.test.js | 49 +++++++- 6 files changed, 307 insertions(+), 25 deletions(-) create mode 100644 devtools/visual-testing/tests/interactions/stories/basic-commands/slash-menu-paste.ts diff --git a/devtools/visual-testing/pnpm-lock.yaml b/devtools/visual-testing/pnpm-lock.yaml index ffb5d54521..7635a575e6 100644 --- a/devtools/visual-testing/pnpm-lock.yaml +++ b/devtools/visual-testing/pnpm-lock.yaml @@ -2062,7 +2062,7 @@ packages: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} superdoc@file:../../packages/superdoc/superdoc.tgz: - resolution: {integrity: sha512-vqmURFD+JspQ8sqL0h2nT5K1hfkQQyqEOlULmX4A+zZUUJXsAIan/GBcNQEJM3m8nqzYS6G0NLilnBcNidlonA==, tarball: file:../../packages/superdoc/superdoc.tgz} + resolution: {integrity: sha512-ujI4h2Ru+d9t8xIZmdJazSVhaDA6l2QS3IK2MuThzl0MqSCGjxAKDlfvTeyWA/7WLxQkQzxvY+V8/lCo0+ysqg==, tarball: file:../../packages/superdoc/superdoc.tgz} version: 1.10.1-next.4 peerDependencies: '@hocuspocus/provider': ^2.13.6 diff --git a/devtools/visual-testing/tests/interactions/stories/basic-commands/slash-menu-paste.ts b/devtools/visual-testing/tests/interactions/stories/basic-commands/slash-menu-paste.ts new file mode 100644 index 0000000000..c2c450d001 --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/basic-commands/slash-menu-paste.ts @@ -0,0 +1,83 @@ +import { defineStory } from '@superdoc-testing/helpers'; +import { clickOnLine } from '../../helpers/index.js'; + +const WAIT_MS = 300; +const WAIT_LONG_MS = 600; + +export default defineStory({ + name: 'slash-menu-paste', + description: 'Verify the slash menu shows a Paste action and that pasting formatted HTML preserves formatting.', + tickets: ['SD-1302'], + startDocument: null, + hideCaret: true, + + async run(page, helpers): Promise { + const { step, type, newLine, press, waitForStable, milestone } = helpers; + + await step('Type a normal line', async () => { + await type('Normal line'); + await newLine(); + await waitForStable(WAIT_MS); + await milestone('before-paste', 'One line typed, cursor on second line.'); + }); + + await step('Open slash menu via right-click to show Paste option', async () => { + const lines = page.locator('.superdoc-line'); + const lastLine = lines.last(); + const box = await lastLine.boundingBox(); + if (!box) throw new Error('Last line not visible'); + + await page.mouse.click(box.x + 20, box.y + box.height / 2, { button: 'right' }); + await waitForStable(WAIT_MS); + + const menu = page.locator('.slash-menu'); + await menu.waitFor({ state: 'visible', timeout: 5000 }); + await waitForStable(WAIT_MS); + + await milestone('slash-menu-open', 'Slash menu visible with Paste action.'); + }); + + await step('Dismiss menu and paste bold HTML via editor API', async () => { + await press('Escape'); + await waitForStable(WAIT_MS); + + // Reposition cursor on line 2 (lost when slash menu closed) + await clickOnLine(page, 1); + await waitForStable(WAIT_MS); + + // Paste formatted HTML directly via ProseMirror's pasteHTML API. + // This exercises the same rendering path as the slash menu paste action + // without depending on browser clipboard permissions. + // A clipboard event shim is required — pasteHTML internally accesses + // event.clipboardData.getData(). + await page.evaluate( + `(function() { + var view = window.editor && window.editor.view; + if (!view) return; + var html = 'Bold pasted text'; + var text = 'Bold pasted text'; + var fakeEvent = { + type: 'paste', + preventDefault: function() {}, + stopPropagation: function() {}, + clipboardData: { + getData: function(type) { + if (type === 'text/html') return html; + if (type === 'text/plain') return text; + return ''; + } + } + }; + if (typeof view.pasteHTML === 'function') { + view.pasteHTML(html, fakeEvent); + } else if (window.editor.commands && window.editor.commands.insertContent) { + window.editor.commands.insertContent(html); + } + })()`, + ); + + await waitForStable(WAIT_LONG_MS); + await milestone('after-paste', 'Bold text pasted on line 2 — formatting should be preserved.'); + }); + }, +}); diff --git a/packages/super-editor/src/components/slash-menu/menuItems.js b/packages/super-editor/src/components/slash-menu/menuItems.js index 3b92461cd7..02fa6bd05b 100644 --- a/packages/super-editor/src/components/slash-menu/menuItems.js +++ b/packages/super-editor/src/components/slash-menu/menuItems.js @@ -7,6 +7,30 @@ import { isTrackedChangeActionAllowed } from '@extensions/track-changes/permissi import { readClipboardRaw } from '../../core/utilities/clipboardUtils.js'; import { handleClipboardPaste } from '../../core/InputRule.js'; +/** + * Build a minimal clipboard event-like object so ProseMirror paste hooks + * can access text/html and text/plain data. + * @param {{ html?: string, text?: string }} clipboard + * @returns {{ clipboardData: { getData: (type: string) => string } }} + */ +const createPasteEventShim = (clipboard) => { + const html = clipboard?.html || ''; + const text = clipboard?.text || ''; + + return { + type: 'paste', + preventDefault: () => {}, + stopPropagation: () => {}, + clipboardData: { + getData: (type) => { + if (type === 'text/html') return html; + if (type === 'text/plain') return text; + return ''; + }, + }, + }; +}; + /** * Check if a module is enabled based on editor options * This is used for hiding menu items based on module availability @@ -265,8 +289,22 @@ export function getItems(context, customItems = [], includeDefaultItems = true) view.dom.focus(); const { html, text } = await readClipboardRaw(); const handled = html ? handleClipboardPaste({ editor, view }, html) : false; - if (!handled && text && editor.commands?.insertContent) { - editor.commands.insertContent(text, { contentType: 'text' }); + if (!handled) { + const pasteEvent = createPasteEventShim({ html, text }); + + if (html && typeof view.pasteHTML === 'function') { + view.pasteHTML(html, pasteEvent); + return; + } + + if (text && typeof view.pasteText === 'function') { + view.pasteText(text, pasteEvent); + return; + } + + if (text && editor.commands?.insertContent) { + editor.commands.insertContent(text, { contentType: 'text' }); + } } }, showWhen: (context) => { diff --git a/packages/super-editor/src/components/slash-menu/tests/menuItems.test.js b/packages/super-editor/src/components/slash-menu/tests/menuItems.test.js index eec06c6431..dc58eb1dd4 100644 --- a/packages/super-editor/src/components/slash-menu/tests/menuItems.test.js +++ b/packages/super-editor/src/components/slash-menu/tests/menuItems.test.js @@ -3,6 +3,11 @@ import { getItems } from '../menuItems.js'; import { createMockEditor, createMockContext, assertMenuSectionsStructure, SlashMenuConfigs } from './testHelpers.js'; import { TRIGGERS } from '../constants.js'; +const clipboardMocks = vi.hoisted(() => ({ + readClipboardRaw: vi.fn(), + handleClipboardPaste: vi.fn(() => true), +})); + vi.mock('../../cursor-helpers.js', async () => { const actual = await vi.importActual('../../cursor-helpers.js'); return { @@ -47,8 +52,12 @@ vi.mock('../../toolbar/AIWriter.vue', () => ({ default: { template: '
AIWrit vi.mock('../../toolbar/TableActions.vue', () => ({ default: { template: '
TableActions
' } })); vi.mock('../../toolbar/LinkInput.vue', () => ({ default: { template: '
LinkInput
' } })); -vi.mock('../../core/InputRule.js', () => ({ - handleClipboardPaste: vi.fn(() => true), +vi.mock('../../../core/utilities/clipboardUtils.js', () => ({ + readClipboardRaw: clipboardMocks.readClipboardRaw, +})); + +vi.mock('../../../core/InputRule.js', () => ({ + handleClipboardPaste: clipboardMocks.handleClipboardPaste, })); vi.mock('@extensions/track-changes/permission-helpers.js', () => ({ @@ -435,4 +444,99 @@ describe('menuItems.js', () => { expect(removeItem).toBeDefined(); }); }); + + describe('getItems - paste action behavior', () => { + it('should not force plain-text insert when HTML paste is unhandled', async () => { + const insertContent = vi.fn(); + mockEditor = createMockEditor({ + commands: { insertContent }, + }); + mockEditor.view.dom.focus = vi.fn(); + mockEditor.view.pasteHTML = vi.fn(); + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + }); + + clipboardMocks.readClipboardRaw.mockResolvedValue({ + html: '

word html

', + text: 'word html', + }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const sections = getItems(mockContext); + const pasteAction = sections + .find((section) => section.id === 'clipboard') + ?.items.find((item) => item.id === 'paste')?.action; + + expect(pasteAction).toBeTypeOf('function'); + await pasteAction(mockEditor); + + expect(clipboardMocks.handleClipboardPaste).toHaveBeenCalledWith( + { editor: mockEditor, view: mockEditor.view }, + '

word html

', + ); + expect(mockEditor.view.pasteHTML).toHaveBeenCalledWith('

word html

', expect.any(Object)); + expect(insertContent).not.toHaveBeenCalled(); + }); + + it('should use pasteText when clipboard has text but no HTML', async () => { + const insertContent = vi.fn(); + mockEditor = createMockEditor({ + commands: { insertContent }, + }); + mockEditor.view.dom.focus = vi.fn(); + mockEditor.view.pasteText = vi.fn(); + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + }); + + clipboardMocks.readClipboardRaw.mockResolvedValue({ + html: '', + text: 'plain text content', + }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const sections = getItems(mockContext); + const pasteAction = sections + .find((section) => section.id === 'clipboard') + ?.items.find((item) => item.id === 'paste')?.action; + + await pasteAction(mockEditor); + + expect(mockEditor.view.pasteText).toHaveBeenCalledWith('plain text content', expect.any(Object)); + expect(insertContent).not.toHaveBeenCalled(); + }); + + it('should fall back to insertContent when view has no pasteHTML or pasteText', async () => { + const insertContent = vi.fn(); + mockEditor = createMockEditor({ + commands: { insertContent }, + }); + mockEditor.view.dom.focus = vi.fn(); + // No pasteHTML or pasteText on view + delete mockEditor.view.pasteHTML; + delete mockEditor.view.pasteText; + mockContext = createMockContext({ + editor: mockEditor, + trigger: TRIGGERS.click, + }); + + clipboardMocks.readClipboardRaw.mockResolvedValue({ + html: '', + text: 'fallback text', + }); + clipboardMocks.handleClipboardPaste.mockReturnValue(false); + + const sections = getItems(mockContext); + const pasteAction = sections + .find((section) => section.id === 'clipboard') + ?.items.find((item) => item.id === 'paste')?.action; + + await pasteAction(mockEditor); + + expect(insertContent).toHaveBeenCalledWith('fallback text', { contentType: 'text' }); + }); + }); }); diff --git a/packages/super-editor/src/core/utilities/clipboardUtils.js b/packages/super-editor/src/core/utilities/clipboardUtils.js index 0e0342b70f..b501a833a4 100644 --- a/packages/super-editor/src/core/utilities/clipboardUtils.js +++ b/packages/super-editor/src/core/utilities/clipboardUtils.js @@ -61,29 +61,39 @@ export async function readClipboardRaw() { let text = ''; const hasPermission = await ensureClipboardPermission(); - if (hasPermission && navigator.clipboard) { - if (navigator.clipboard.read) { - try { - const items = await navigator.clipboard.read(); - for (const item of items) { - if (item.types.includes('text/html')) { - html = await (await item.getType('text/html')).text(); - } - if (item.types.includes('text/plain')) { - text = await (await item.getType('text/plain')).text(); - } + if (!navigator.clipboard) { + return { html, text: text || '' }; + } + + if (hasPermission && navigator.clipboard.read) { + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + if (item.types.includes('text/html')) { + html = await (await item.getType('text/html')).text(); + } + if (item.types.includes('text/plain')) { + text = await (await item.getType('text/plain')).text(); } - } catch { - try { - text = await navigator.clipboard.readText(); - } catch {} } - } else { - try { - text = await navigator.clipboard.readText(); - } catch {} + } catch { + // clipboard.read() may throw in restricted contexts (e.g. iframe sandbox, + // browser permission denied) — fall through to readText fallback below. + } + } + + // Always attempt readText as a best-effort fallback. This keeps paste + // functional in environments where permission querying is unsupported but + // clipboard.readText() is still available. + if (!text && navigator.clipboard.readText) { + try { + text = await navigator.clipboard.readText(); + } catch { + // readText() may also be blocked by permission policy — safe to ignore + // since we return whatever we've gathered so far. } } + return { html, text: text || '' }; } diff --git a/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js b/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js index 095909ee6d..5e7b1e7ad4 100644 --- a/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js +++ b/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { ensureClipboardPermission, readFromClipboard } from '../clipboardUtils.js'; +import { ensureClipboardPermission, readClipboardRaw, readFromClipboard } from '../clipboardUtils.js'; // Helper to restore globals after each test const originalNavigator = global.navigator; @@ -69,4 +69,51 @@ describe('clipboardUtils', () => { expect(res).toBe('plain'); }); }); + + describe('readClipboardRaw', () => { + it('returns HTML and text when clipboard.read() succeeds', async () => { + const htmlBlob = new Blob(['

rich

'], { type: 'text/html' }); + const textBlob = new Blob(['rich'], { type: 'text/plain' }); + + const clipboardItem = { + types: ['text/html', 'text/plain'], + getType: vi.fn((type) => { + if (type === 'text/html') return Promise.resolve(htmlBlob); + if (type === 'text/plain') return Promise.resolve(textBlob); + return Promise.reject(new Error('unsupported type')); + }), + }; + + global.navigator = { + clipboard: { + read: vi.fn().mockResolvedValue([clipboardItem]), + readText: vi.fn().mockResolvedValue('rich'), + }, + permissions: { + query: vi.fn().mockResolvedValue({ state: 'granted' }), + }, + }; + + const result = await readClipboardRaw(); + + expect(result).toEqual({ html: '

rich

', text: 'rich' }); + }); + + it('falls back to readText when permission query throws', async () => { + const readTextMock = vi.fn().mockResolvedValue('plain fallback text'); + global.navigator = { + clipboard: { + readText: readTextMock, + }, + permissions: { + query: vi.fn().mockRejectedValue(new Error('unsupported permission name')), + }, + }; + + const result = await readClipboardRaw(); + + expect(readTextMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ html: '', text: 'plain fallback text' }); + }); + }); });