From 8301bcf34a258652439d2317d0cbc0fb5f0e399a Mon Sep 17 00:00:00 2001 From: Wieland Lindenthal Date: Sat, 25 Apr 2026 14:58:25 +0200 Subject: [PATCH] fix(clipboard): use ProseMirror selection state for Shadow DOM compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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' }). --- .../clipboard/toClipboard/copyExtension.ts | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index 3a6aeaffd5..7812ae3c2e 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -145,11 +145,13 @@ export function selectedFragmentToHTML< return { clipboardHTML, externalHTML, markdown }; } -const checkIfSelectionInNonEditableBlock = () => { - // Let browser handle event if selection is empty (nothing - // happens). - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) { +const checkIfSelectionInNonEditableBlock = (view: EditorView) => { + // Use ProseMirror's internal selection state to check for empty selection. + // window.getSelection() returns null or a collapsed selection inside Shadow + // DOM (Firefox, Safari, and Chromium edge cases), causing this guard to + // misfire and silently skip clipboard writes. view.state.selection is always + // accurate regardless of DOM mode. + if (view.state.selection.empty) { return true; } @@ -158,16 +160,19 @@ const checkIfSelectionInNonEditableBlock = () => { // non-editable block. We only need to check one node as it's // not possible for the browser selection to start in an // editable block and end in a non-editable one. - let node = selection.focusNode; - while (node) { - if ( - node instanceof HTMLElement && - node.getAttribute("contenteditable") === "false" - ) { - return true; + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) { + let node = selection.focusNode; + while (node) { + if ( + node instanceof HTMLElement && + node.getAttribute("contenteditable") === "false" + ) { + return true; + } + + node = node.parentElement; } - - node = node.parentElement; } return false; @@ -213,7 +218,7 @@ export const createCopyToClipboardExtension = < props: { handleDOMEvents: { copy(view, event) { - if (checkIfSelectionInNonEditableBlock()) { + if (checkIfSelectionInNonEditableBlock(view)) { return true; } @@ -222,7 +227,7 @@ export const createCopyToClipboardExtension = < return true; }, cut(view, event) { - if (checkIfSelectionInNonEditableBlock()) { + if (checkIfSelectionInNonEditableBlock(view)) { return true; }