Skip to content

fix(clipboard): use ProseMirror selection state to fix copy/cut inside Shadow DOM#2676

Draft
wielinde wants to merge 1 commit intoTypeCellOS:mainfrom
opf:fix/shadow-dom-clipboard-selection
Draft

fix(clipboard): use ProseMirror selection state to fix copy/cut inside Shadow DOM#2676
wielinde wants to merge 1 commit intoTypeCellOS:mainfrom
opf:fix/shadow-dom-clipboard-selection

Conversation

@wielinde
Copy link
Copy Markdown

Problem

window.getSelection() returns null or a collapsed selection when a BlockNote editor is mounted inside a Shadow DOM (attachShadow({ mode: 'open' })). This causes checkIfSelectionInNonEditableBlock to always return true, which silently skips copyToClipboard. The browser's default copy handler then fires instead, which uses ProseMirror's DOMSerializer — producing <div class="bn-block-content"> wrappers with no <ul>/<ol> — so list bullets, heading levels, bold, and italic are all lost when pasting into external apps (Google Docs, LibreOffice Writer, Gmail, etc.).

Affected browsers: Firefox (all versions), Safari ≤ 16.3, Chromium edge cases.

Reproduction: mount a BlockNote editor inside attachShadow({ mode: 'open' }), select a bullet list, copy, and paste into any external app — formatting is gone.

This is not a theoretical concern: OpenProject embeds BlockNote inside a Shadow DOM to isolate it from the host Angular application. This bug breaks clipboard formatting for all their users.

Root cause

// Before — copyExtension.ts
const checkIfSelectionInNonEditableBlock = () => {
  const selection = window.getSelection();
  if (!selection || selection.isCollapsed) {
    return true; // <-- always fires inside Shadow DOM → clipboard write skipped
  }
  // ...
};

window.getSelection() is not Shadow-DOM-aware and cannot see selections that live inside a shadow root.

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 traversal is retained as a secondary check, but only runs when window.getSelection() actually returns a non-collapsed selection (i.e. in non-Shadow-DOM contexts where it still works).

// After
const checkIfSelectionInNonEditableBlock = (view: EditorView) => {
  // ProseMirror's internal state is Shadow DOM-safe; window.getSelection() is not.
  if (view.state.selection.empty) {
    return true;
  }

  // Keep the non-editable-island check for standard DOM contexts.
  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;
    }
  }

  return false;
};

Both copy and cut call sites are updated to pass view.

Checklist

  • view.state.selection.empty replaces window.getSelection() for the empty-selection guard
  • Non-editable-island DOM traversal is preserved (secondary, only when DOM selection is available)
  • Both copy and cut handlers updated
  • No behaviour change in standard (non-Shadow-DOM) contexts
  • Manual test: Shadow DOM mount → select bullet list → copy → paste into Google Docs/LibreOffice
  • Manual test: cut removes source content after writing clipboard

…ibility

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' }).
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 25, 2026

@wielinde is attempting to deploy a commit to the TypeCell Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f8840c60-fc9c-497c-b7de-c116f7af6e95

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant