Skip to content

Commit 130a916

Browse files
authored
fix(clipboard): use ProseMirror selection state for Shadow DOM compatibility (#2677)
OpenProject embeds BlockNote inside a Shadow DOM (attachShadow({ mode: 'open' })) to isolate it from the host Angular application. In this setup, window.getSelection() returns null or a collapsed selection even when text is selected (Firefox all versions, Safari ≤16.3, Chromium edge cases), causing checkIfSelectionInNonEditableBlock to always return true and skip the clipboard write entirely. The browser's default copy then fires, which uses ProseMirror's DOMSerializer without semantic wrappers — so list formatting, headings, and bold/italic are lost on paste into external apps. Fix: use view.state.selection.empty as the primary empty-selection guard. ProseMirror's internal state is always accurate regardless of DOM mode. The DOM-level non-editable-island check is kept as a secondary guard, but only when window.getSelection() actually returns a non-collapsed selection. Fixes copy/cut for editors mounted inside attachShadow({ mode: 'open' }).
1 parent 38c5515 commit 130a916

1 file changed

Lines changed: 21 additions & 16 deletions

File tree

packages/core/src/api/clipboard/toClipboard/copyExtension.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,13 @@ export function selectedFragmentToHTML<
145145
return { clipboardHTML, externalHTML, markdown };
146146
}
147147

148-
const checkIfSelectionInNonEditableBlock = () => {
149-
// Let browser handle event if selection is empty (nothing
150-
// happens).
151-
const selection = window.getSelection();
152-
if (!selection || selection.isCollapsed) {
148+
const checkIfSelectionInNonEditableBlock = (view: EditorView) => {
149+
// Use ProseMirror's internal selection state to check for empty selection.
150+
// window.getSelection() returns null or a collapsed selection inside Shadow
151+
// DOM (Firefox, Safari, and Chromium edge cases), causing this guard to
152+
// misfire and silently skip clipboard writes. view.state.selection is always
153+
// accurate regardless of DOM mode.
154+
if (view.state.selection.empty) {
153155
return true;
154156
}
155157

@@ -158,16 +160,19 @@ const checkIfSelectionInNonEditableBlock = () => {
158160
// non-editable block. We only need to check one node as it's
159161
// not possible for the browser selection to start in an
160162
// editable block and end in a non-editable one.
161-
let node = selection.focusNode;
162-
while (node) {
163-
if (
164-
node instanceof HTMLElement &&
165-
node.getAttribute("contenteditable") === "false"
166-
) {
167-
return true;
163+
const selection = window.getSelection();
164+
if (selection && !selection.isCollapsed) {
165+
let node = selection.focusNode;
166+
while (node) {
167+
if (
168+
node instanceof HTMLElement &&
169+
node.getAttribute("contenteditable") === "false"
170+
) {
171+
return true;
172+
}
173+
174+
node = node.parentElement;
168175
}
169-
170-
node = node.parentElement;
171176
}
172177

173178
return false;
@@ -213,7 +218,7 @@ export const createCopyToClipboardExtension = <
213218
props: {
214219
handleDOMEvents: {
215220
copy(view, event) {
216-
if (checkIfSelectionInNonEditableBlock()) {
221+
if (checkIfSelectionInNonEditableBlock(view)) {
217222
return true;
218223
}
219224

@@ -222,7 +227,7 @@ export const createCopyToClipboardExtension = <
222227
return true;
223228
},
224229
cut(view, event) {
225-
if (checkIfSelectionInNonEditableBlock()) {
230+
if (checkIfSelectionInNonEditableBlock(view)) {
226231
return true;
227232
}
228233

0 commit comments

Comments
 (0)