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
2 changes: 1 addition & 1 deletion packages/super-editor/src/components/slash-menu/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
78 changes: 45 additions & 33 deletions packages/super-editor/src/core/utilities/clipboardUtils.js
Original file line number Diff line number Diff line change
@@ -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<boolean>} 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<void>} 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;
}
}

Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
});