diff --git a/packages/super-editor/src/components/slash-menu/utils.js b/packages/super-editor/src/components/slash-menu/utils.js index d7683ba41..20d624934 100644 --- a/packages/super-editor/src/components/slash-menu/utils.js +++ b/packages/super-editor/src/components/slash-menu/utils.js @@ -90,7 +90,7 @@ export async function getEditorContext(editor, event) { node = state.doc.nodeAt(pos); } - // We need to check if we have anything in the clipboard + // We need to check if we have anything in the clipboard and request permission if needed const clipboardContent = await readFromClipboard(state); return { diff --git a/packages/super-editor/src/core/utilities/clipboardUtils.js b/packages/super-editor/src/core/utilities/clipboardUtils.js index 483db510e..67be48e6d 100644 --- a/packages/super-editor/src/core/utilities/clipboardUtils.js +++ b/packages/super-editor/src/core/utilities/clipboardUtils.js @@ -1,41 +1,48 @@ +// @ts-nocheck // clipboardUtils.js -import { DOMSerializer, DOMParser } from 'prosemirror-model'; +import { DOMParser } from 'prosemirror-model'; /** - * Serializes the current selection in the editor state to HTML and plain text for clipboard use. - * @param {EditorState} state - The ProseMirror editor state containing the current selection. - * @returns {{ htmlString: string, text: string }} An object with the HTML string and plain text of the selection. + * Checks if clipboard read permission is granted and handles permission prompts. + * Returns true if clipboard-read permission is granted. If state is "prompt" it will + * proactively trigger a readText() call which will surface the browser permission + * dialog to the user. Falls back gracefully in older browsers that lack the + * Permissions API. + * @returns {Promise} Whether clipboard read permission is granted */ -export function serializeSelectionToClipboard(state) { - const { from, to } = state.selection; - const slice = state.selection.content(); - const htmlContainer = document.createElement('div'); - htmlContainer.appendChild(DOMSerializer.fromSchema(state.schema).serializeFragment(slice.content)); - const htmlString = htmlContainer.innerHTML; - const text = state.doc.textBetween(from, to); - return { htmlString, text }; -} +export async function ensureClipboardPermission() { + if (typeof navigator === 'undefined' || !navigator.clipboard) { + return false; + } + + // Some older browsers do not expose navigator.permissions – assume granted + if (!navigator.permissions || typeof navigator.permissions.query !== 'function') { + return true; + } -/** - * Writes HTML and plain text data to the system clipboard. - * Uses the Clipboard API if available, otherwise falls back to plain text. - * @param {{ htmlString: string, text: string }} param0 - The HTML and plain text to write to the clipboard. - * @returns {Promise} A promise that resolves when the clipboard write is complete. - */ -export async function writeToClipboard({ htmlString, text }) { try { - if (navigator.clipboard && window.ClipboardItem) { - const clipboardItem = new window.ClipboardItem({ - 'text/html': new Blob([htmlString], { type: 'text/html' }), - 'text/plain': new Blob([text], { type: 'text/plain' }), - }); - await navigator.clipboard.write([clipboardItem]); - } else { - await navigator.clipboard.writeText(text); + // @ts-ignore – string literal is valid at runtime; TS lib DOM typing not available in .js file + const status = await navigator.permissions.query({ name: 'clipboard-read' }); + + if (status.state === 'granted') { + return true; + } + + if (status.state === 'prompt') { + // Trigger a readText() to make the browser show its permission prompt. + try { + await navigator.clipboard.readText(); + return true; + } catch { + return false; + } } - } catch (e) { - console.error('Error writing to clipboard', e); + + // If we hit this area this is state === 'denied' + return false; + } catch { + return false; } } @@ -48,7 +55,9 @@ export async function writeToClipboard({ htmlString, text }) { export async function readFromClipboard(state) { let html = ''; let text = ''; - if (navigator.clipboard && navigator.clipboard.read) { + const hasPermission = await ensureClipboardPermission(); + + if (hasPermission && navigator.clipboard && navigator.clipboard.read) { try { const items = await navigator.clipboard.read(); for (const item of items) { @@ -60,10 +69,13 @@ export async function readFromClipboard(state) { } } } catch { - text = await navigator.clipboard.readText(); + // Fallback to plain text read; may still fail if permission denied + try { + text = await navigator.clipboard.readText(); + } catch {} } } else { - text = await navigator.clipboard.readText(); + // permissions denied or API unavailable; leave content empty } let content = null; if (html) { diff --git a/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js b/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js new file mode 100644 index 000000000..095909ee6 --- /dev/null +++ b/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { ensureClipboardPermission, readFromClipboard } from '../clipboardUtils.js'; + +// Helper to restore globals after each test +const originalNavigator = global.navigator; +const originalWindowClipboardItem = globalThis.ClipboardItem; + +function restoreGlobals() { + if (typeof originalNavigator !== 'undefined') { + global.navigator = originalNavigator; + } else { + delete global.navigator; + } + + if (typeof originalWindowClipboardItem !== 'undefined') { + globalThis.ClipboardItem = originalWindowClipboardItem; + } else { + delete globalThis.ClipboardItem; + } +} + +afterEach(() => { + restoreGlobals(); + vi.restoreAllMocks(); +}); + +describe('clipboardUtils', () => { + describe('ensureClipboardPermission', () => { + it('navigator undefined returns false', async () => { + // Remove navigator entirely + delete global.navigator; + const result = await ensureClipboardPermission(); + expect(result).toBe(false); + }); + it('permissions absent but clipboard present returns true', async () => { + global.navigator = { + clipboard: {}, + }; + const result = await ensureClipboardPermission(); + expect(result).toBe(true); + }); + }); + + describe('readFromClipboard', () => { + it('navigator.clipboard undefined returns null (no throw)', async () => { + global.navigator = {}; + const mockState = { schema: { text: (t) => t } }; + const res = await readFromClipboard(mockState); + expect(res).toBeNull(); + }); + + it('read() fails so fallback readText() is used', async () => { + const readTextMock = vi.fn().mockResolvedValue('plain'); + global.navigator = { + clipboard: { + read: vi.fn().mockRejectedValue(new Error('fail')), + readText: readTextMock, + }, + permissions: { + query: vi.fn().mockResolvedValue({ state: 'granted' }), + }, + }; + + const mockState = { schema: { text: (t) => t } }; + const res = await readFromClipboard(mockState); + + expect(readTextMock).toHaveBeenCalled(); + expect(res).toBe('plain'); + }); + }); +});