[WIP] [lexical][lexical-clipboard][lexical-playground] Feature: Support DOM shadow roots via platform selection APIs#8660
Draft
etrepum wants to merge 11 commits into
Draft
Conversation
… shadow roots via platform selection APIs Make Lexical editors work when their contentEditable is inside a DOM ShadowRoot, using platform APIs only — no emulation of browser selection facilities. When a selection is inside a shadow tree the browser retargets Selection.anchorNode/focusNode/getRangeAt to the shadow host, and document.activeElement to the host, which previously prevented Lexical from resolving the selection back to its nodes. Core (lexical): - Add getDOMShadowRoots, getComposedStaticRange and getComposedSelectionPoints, which resolve selection boundary points through enclosing shadow roots with the standard Selection.getComposedRanges + Selection.direction; and getActiveElement / getActiveElementDeep, which resolve focus with (Document|ShadowRoot).activeElement. - Route the DOM->model reads (selection resolution, onSelectionChange, composition and mutation handlers) and the model->DOM write/diff in the reconciler through composed points, and resolve the active editor on selectionchange by descending shadow roots. - RangeSelection.modify keeps using native Selection.modify and reads the result via the composed StaticRange, so character/word/line navigation and deletion work inside a shadow root without re-implementing them. - The helpers degrade to the existing light-DOM reads when getComposedRanges is unavailable, so older browsers are unaffected. @lexical/clipboard: resolve the copy guard's selection through shadow roots. Playground: add a "Render in Shadow DOM" setting that portals the editor into an open shadow root, with an e2e spec covering typing, word selection, formatting and deletion. Add a dev-examples/shadow-dom project and browser unit tests, since jsdom does not implement the shadow-DOM selection APIs. https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…M: review hardening, library coverage, and a web component example Deep-review follow-up to the initial DOM shadow root support, also closing the coverage gaps relative to the earlier attempts (facebook#7790, facebook#8659) and the scenarios in facebook#2119, facebook#6709 and facebook#8125. Core (lexical): - getComposedStaticRange falls back to the legacy variadic getComposedRanges(...shadowRoots) call form used by Safari 17–18.1 before the spec switched to an options dictionary. - New getDOMSelectionRange helper: a live Range resolved through shadow roots (getRangeAt(0) is retargeted to the host), for callers that need layout (getBoundingClientRect); falls back to getRangeAt(0) in the light DOM. Replaces the bespoke collapsed-range construction in $updateDOMSelection's scroll-into-view path. - isSelectionCapturedInDecoratorInput resolves the focused element through shadow trees; previously decorator-input protection silently failed in a shadow root because document.activeElement reports the host. - $updateDOMBlockCursorElement uses the tree-scoped activeElement so the block cursor renders inside a shadow root. - $updateDOMSelection computes composed points after the early returns, preserving the documented deferred-selection-read optimization. - onDocumentSelectionChange's active-editor fallback only engages when focus is actually inside a shadow tree, keeping light DOM behavior byte-identical. @lexical/table: resolve composed points/ranges in $fixTableSelectionForSelectedTable, the arrow-key table edge detection, and $getTableEdgeCursorPosition. @lexical/react: LexicalTypeaheadMenuPlugin positions the menu from composed selection points so typeahead works in a shadow root. Playground: getDOMRangeRect, FloatingTextFormatToolbarPlugin, FloatingLinkEditorPlugin and TableActionMenuPlugin resolve composed points/ranges so the floating UIs work with the Shadow DOM setting; the dev error overlay no longer crashes on Error-less events (e.g. the benign ResizeObserver loop warning). Add dev-examples/shadow-dom-web-component: a framework-free <lexical-editor> custom element with toolbar, styles and contentEditable fully encapsulated in an open shadow root, form-associated via ElementInternals, dispatching composed input events — the scenario from the issues above, complementing the React example. Add a browser regression test that drives real keyboard input (typing and backspace) into an editor inside a web component's shadow root. https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…mples Both new dev examples now carry Playwright tests that start their Vite dev server automatically (pinned ports, reuseExistingServer), matching the convention of the existing examples. dev-examples/shadow-dom (React): renders into the shadow root, types, formats a shadow-DOM selection from the light-DOM toolbar, and word deletion. dev-examples/shadow-dom-web-component: two editors in independent shadow roots, typing/formatting with in-shadow toolbar (aria-pressed reflects the selection), editor independence, word deletion, ElementInternals form association, and the composed input event crossing the shadow boundary. Run with `pnpm -C dev-examples/<name> test`. https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…getComposedRanges fallback reach empty results The WebKit (Safari) browser-mode CI run failed two ShadowRootSelection tests. Root cause: the test helper set the selection with Selection.addRange, which WebKit does not reliably register for a Range pointing inside a shadow tree (the sibling test using setBaseAndExtent passed). Switch the helper to setBaseAndExtent. Also harden getComposedStaticRange: a browser that does not understand the dictionary form of getComposedRanges may return an empty array rather than throwing, so fall through to the legacy variadic form (Safari 17–18.1) on an empty result, not only on a thrown error — previously that fallback was unreachable in the empty case. Verified all three engines pass the browser project (Chromium, Firefox, and WebKit). https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…cal-clipboard][lexical-rich-text] Shadow DOM: resolve focus and point-to-node through shadow roots in library packages Several shipped features gate behavior on document.activeElement or document.caretRangeFromPoint, both of which retarget to the shadow host when the editor is in a shadow root. getActiveElement/getActiveElementDeep are identical to document.activeElement in the light DOM, so these are zero-regression fixes. - @lexical/dragon: voice-control commands were dropped entirely (document.activeElement !== rootElement always true in a shadow root). - @lexical/list: checklist keyboard handling (space/enter toggle, arrow navigation, getActiveCheckListItem) resolved the focused <li> via document.activeElement and so never matched in a shadow root. - @lexical/extension AutoFocusExtension and @lexical/react AutoFocusPlugin, LexicalComposer initial-selection, useYjsCollaboration focus state, and LexicalDraggableBlockPlugin (blur-on-menu via getActiveElementDeep, plus the Firefox drag re-focus) all read document.activeElement. - @lexical/clipboard caretFromPoint + @lexical/rich-text drop handling: prefer caretPositionFromPoint with the shadowRoots option so dropped text/files resolve to the real node inside a shadow tree instead of the host. Also revert the playground dev error-overlay tweak from the previous commit: passing the real Error (instead of the ErrorEvent main passes, which made the overlay constructor throw and swallow) caused the overlay to surface a pre-existing dev-only update-recursion warning, intercepting pointer events and breaking e2e tests that type long content. Restore main's behavior. https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…oint-to-node reads through shadow roots The playground is reference material for people and agents, so its DOM reads should be correct under shadow DOM too. Each of these retargets to the shadow host when the editor is in a shadow root; the lexical helpers are identical to the raw reads in the light DOM, so these are zero-regression. activeElement (-> getActiveElement / getActiveElementDeep): - EquationComponent: keep the equation editor open while its input is focused. - ImageComponent: focus the image button only when not already focused. - TableActionMenuPlugin: detect whether anything is focused. - AutocompleteExtension: detect whether the editor is focused. - FloatingLinkEditorPlugin: keep the link editor open while its input is focused. - CommentPlugin: capture/restore the focused element across a selection move. getSelection (-> getDOMSelection + getDOMSelectionRange): - setFloatingElemPosition: read the caret's text element for end-alignment. point-to-node (same retargeting class): - FloatingTextFormatToolbarPlugin: elementFromPoint via the popup's root node. - ImagesExtension: caretRangeFromPoint via @lexical/clipboard's shadow-aware caretFromPoint for image-drop positioning. https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…-null getDOMSelectionPoints getComposedSelectionPoints returned null in the light DOM, so every one of its ~13 call sites repeated `getComposedSelectionPoints(sel, root) || sel`. A Selection already satisfies DOMSelectionPoints, so fold the fallback into the helper: getDOMSelectionPoints returns the composed points in a shadow tree and the Selection itself otherwise, never null. This removes the repeated `|| sel` at every call site and gives a name consistent with getDOMSelection / getDOMSelectionRange. No behavior change — the `|| sel` branch was exactly what the helper now returns internally, and in the light DOM it still returns the Selection object without eagerly reading anchorNode/focusNode (preserving the deferred-read optimization in $updateDOMSelection). https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
…pport and add an iframe regression test Add a "Shadow DOM and iframes" concepts page documenting how to embed an editor in an open shadow root or an iframe, the platform APIs involved (getComposedRanges / ShadowRoot.activeElement / getRootNode), the requirements (open roots only; getComposedRanges-capable browsers, with graceful degradation), styling, and the shadow/iframe-aware helpers (getDOMSelectionPoints, getDOMSelectionRange, getActiveElement, getActiveElementDeep). Add a browser test covering an editor whose root element lives in a same-origin iframe: the top-level document only sees the <iframe> as focused, but getActiveElement resolves through Node.getRootNode to the inner editor, and the iframe's (non-retargeted) selection resolves into the model. This is the same getRootNode-based resolution the activeElement fixes rely on, so iframe-embedded editors get correct focus handling too. https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
… cross-frame focus The iframe test asserted that focusing an element inside a same-origin iframe makes the top-level document.activeElement the <iframe> element. That does not hold in a headless browser (the top-level window never gains focus), so CI failed on Chromium, Firefox and WebKit with document.activeElement === <body>. Assert the actual contract instead: getActiveElement resolves through Node.getRootNode to the iframe's own document (so it equals iframeDoc.activeElement and is never the top-level document.activeElement), which holds regardless of whether cross-frame focus propagates. The selection round-trip already does not depend on focus. https://claude.ai/code/session_01X2Aa2rteqPsVhWs4W6DW6T
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Make Lexical editors work when their contentEditable is inside a DOM ShadowRoot, using platform APIs only — no emulation of browser selection facilities.
When a selection is inside a shadow tree the browser retargets Selection.anchorNode/focusNode/getRangeAt to the shadow host, and document.activeElement to the host, which previously prevented Lexical from resolving the selection back to its nodes.
Core (lexical):
@lexical/clipboard: resolve the copy guard's selection through shadow roots.
Playground: add a "Render in Shadow DOM" setting that portals the editor into an open shadow root, with an e2e spec covering typing, word selection, formatting and deletion.
Add a dev-examples/shadow-dom project and browser unit tests, since jsdom does not implement the shadow-DOM selection APIs.
Closes #7790
Closes #8125
RE #6709 #2119 #8659
Test plan
New browser unit and e2e tests