Skip to content

Commit bfca96e

Browse files
palmer-clclarencepalmer
andauthored
fix: add safety check for clipboard usage (#859)
* fix: add safety check for clipboard usage * fix: add tests and remove unused utils --------- Co-authored-by: clarencepalmer <cole@rollprogramcole.com>
1 parent fa62f8b commit bfca96e

File tree

3 files changed

+118
-34
lines changed

3 files changed

+118
-34
lines changed

packages/super-editor/src/components/slash-menu/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export async function getEditorContext(editor, event) {
9090
node = state.doc.nodeAt(pos);
9191
}
9292

93-
// We need to check if we have anything in the clipboard
93+
// We need to check if we have anything in the clipboard and request permission if needed
9494
const clipboardContent = await readFromClipboard(state);
9595

9696
return {

packages/super-editor/src/core/utilities/clipboardUtils.js

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,48 @@
1+
// @ts-nocheck
12
// clipboardUtils.js
23

3-
import { DOMSerializer, DOMParser } from 'prosemirror-model';
4+
import { DOMParser } from 'prosemirror-model';
45

56
/**
6-
* Serializes the current selection in the editor state to HTML and plain text for clipboard use.
7-
* @param {EditorState} state - The ProseMirror editor state containing the current selection.
8-
* @returns {{ htmlString: string, text: string }} An object with the HTML string and plain text of the selection.
7+
* Checks if clipboard read permission is granted and handles permission prompts.
8+
* Returns true if clipboard-read permission is granted. If state is "prompt" it will
9+
* proactively trigger a readText() call which will surface the browser permission
10+
* dialog to the user. Falls back gracefully in older browsers that lack the
11+
* Permissions API.
12+
* @returns {Promise<boolean>} Whether clipboard read permission is granted
913
*/
10-
export function serializeSelectionToClipboard(state) {
11-
const { from, to } = state.selection;
12-
const slice = state.selection.content();
13-
const htmlContainer = document.createElement('div');
14-
htmlContainer.appendChild(DOMSerializer.fromSchema(state.schema).serializeFragment(slice.content));
15-
const htmlString = htmlContainer.innerHTML;
16-
const text = state.doc.textBetween(from, to);
17-
return { htmlString, text };
18-
}
14+
export async function ensureClipboardPermission() {
15+
if (typeof navigator === 'undefined' || !navigator.clipboard) {
16+
return false;
17+
}
18+
19+
// Some older browsers do not expose navigator.permissions – assume granted
20+
if (!navigator.permissions || typeof navigator.permissions.query !== 'function') {
21+
return true;
22+
}
1923

20-
/**
21-
* Writes HTML and plain text data to the system clipboard.
22-
* Uses the Clipboard API if available, otherwise falls back to plain text.
23-
* @param {{ htmlString: string, text: string }} param0 - The HTML and plain text to write to the clipboard.
24-
* @returns {Promise<void>} A promise that resolves when the clipboard write is complete.
25-
*/
26-
export async function writeToClipboard({ htmlString, text }) {
2724
try {
28-
if (navigator.clipboard && window.ClipboardItem) {
29-
const clipboardItem = new window.ClipboardItem({
30-
'text/html': new Blob([htmlString], { type: 'text/html' }),
31-
'text/plain': new Blob([text], { type: 'text/plain' }),
32-
});
33-
await navigator.clipboard.write([clipboardItem]);
34-
} else {
35-
await navigator.clipboard.writeText(text);
25+
// @ts-ignore – string literal is valid at runtime; TS lib DOM typing not available in .js file
26+
const status = await navigator.permissions.query({ name: 'clipboard-read' });
27+
28+
if (status.state === 'granted') {
29+
return true;
30+
}
31+
32+
if (status.state === 'prompt') {
33+
// Trigger a readText() to make the browser show its permission prompt.
34+
try {
35+
await navigator.clipboard.readText();
36+
return true;
37+
} catch {
38+
return false;
39+
}
3640
}
37-
} catch (e) {
38-
console.error('Error writing to clipboard', e);
41+
42+
// If we hit this area this is state === 'denied'
43+
return false;
44+
} catch {
45+
return false;
3946
}
4047
}
4148

@@ -48,7 +55,9 @@ export async function writeToClipboard({ htmlString, text }) {
4855
export async function readFromClipboard(state) {
4956
let html = '';
5057
let text = '';
51-
if (navigator.clipboard && navigator.clipboard.read) {
58+
const hasPermission = await ensureClipboardPermission();
59+
60+
if (hasPermission && navigator.clipboard && navigator.clipboard.read) {
5261
try {
5362
const items = await navigator.clipboard.read();
5463
for (const item of items) {
@@ -60,10 +69,13 @@ export async function readFromClipboard(state) {
6069
}
6170
}
6271
} catch {
63-
text = await navigator.clipboard.readText();
72+
// Fallback to plain text read; may still fail if permission denied
73+
try {
74+
text = await navigator.clipboard.readText();
75+
} catch {}
6476
}
6577
} else {
66-
text = await navigator.clipboard.readText();
78+
// permissions denied or API unavailable; leave content empty
6779
}
6880
let content = null;
6981
if (html) {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
3+
import { ensureClipboardPermission, readFromClipboard } from '../clipboardUtils.js';
4+
5+
// Helper to restore globals after each test
6+
const originalNavigator = global.navigator;
7+
const originalWindowClipboardItem = globalThis.ClipboardItem;
8+
9+
function restoreGlobals() {
10+
if (typeof originalNavigator !== 'undefined') {
11+
global.navigator = originalNavigator;
12+
} else {
13+
delete global.navigator;
14+
}
15+
16+
if (typeof originalWindowClipboardItem !== 'undefined') {
17+
globalThis.ClipboardItem = originalWindowClipboardItem;
18+
} else {
19+
delete globalThis.ClipboardItem;
20+
}
21+
}
22+
23+
afterEach(() => {
24+
restoreGlobals();
25+
vi.restoreAllMocks();
26+
});
27+
28+
describe('clipboardUtils', () => {
29+
describe('ensureClipboardPermission', () => {
30+
it('navigator undefined returns false', async () => {
31+
// Remove navigator entirely
32+
delete global.navigator;
33+
const result = await ensureClipboardPermission();
34+
expect(result).toBe(false);
35+
});
36+
it('permissions absent but clipboard present returns true', async () => {
37+
global.navigator = {
38+
clipboard: {},
39+
};
40+
const result = await ensureClipboardPermission();
41+
expect(result).toBe(true);
42+
});
43+
});
44+
45+
describe('readFromClipboard', () => {
46+
it('navigator.clipboard undefined returns null (no throw)', async () => {
47+
global.navigator = {};
48+
const mockState = { schema: { text: (t) => t } };
49+
const res = await readFromClipboard(mockState);
50+
expect(res).toBeNull();
51+
});
52+
53+
it('read() fails so fallback readText() is used', async () => {
54+
const readTextMock = vi.fn().mockResolvedValue('plain');
55+
global.navigator = {
56+
clipboard: {
57+
read: vi.fn().mockRejectedValue(new Error('fail')),
58+
readText: readTextMock,
59+
},
60+
permissions: {
61+
query: vi.fn().mockResolvedValue({ state: 'granted' }),
62+
},
63+
};
64+
65+
const mockState = { schema: { text: (t) => t } };
66+
const res = await readFromClipboard(mockState);
67+
68+
expect(readTextMock).toHaveBeenCalled();
69+
expect(res).toBe('plain');
70+
});
71+
});
72+
});

0 commit comments

Comments
 (0)