From 2c812f73129f1affc667e1992dededd1758dc79f Mon Sep 17 00:00:00 2001 From: Bryan Valverde Date: Wed, 13 May 2026 14:58:20 -0600 Subject: [PATCH 01/18] Shadow dom support --- .claude/settings.local.json | 19 + demo/scripts/controlsV2/mainPane/MainPane.tsx | 17 +- demo/scripts/index.ts | 3 +- .../lib/coreApi/announce/announce.ts | 1 + .../getDOMSelection/getDOMSelection.ts | 9 +- .../setDOMSelection/setDOMSelection.ts | 32 +- .../coreApi/setEditorStyle/ensureUniqueId.ts | 4 +- .../coreApi/setEditorStyle/setEditorStyle.ts | 2 +- .../lib/corePlugin/cache/CachePlugin.ts | 8 +- .../corePlugin/lifecycle/LifecyclePlugin.ts | 1 + .../corePlugin/selection/SelectionPlugin.ts | 23 +- .../lib/editor/core/DOMHelperImpl.ts | 249 +++++++- .../lib/utils/createAriaLiveElement.ts | 2 - .../lib/parameter/DOMHelper.ts | 44 ++ .../lib/editor/EditorAdapter.ts | 2 +- shadow-dom-plan.md | 546 ++++++++++++++++++ 16 files changed, 912 insertions(+), 50 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 shadow-dom-plan.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..58134a30347c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(head:*)", + "Bash(tail:*)", + "Bash(cat:*)", + "Bash(cd /c/repo/roosterjs && npx tsc -p packages/tsconfig.json --noEmit 2>&1)", + "Bash(cd /c/repo/roosterjs && npx tsc -p packages/tsconfig.test.json --noEmit 2>&1)", + "Bash(grep:*)", + "Bash(cd /c/repo/roosterjs && yarn test:fast 2>&1)", + "Bash(yarn test:fast:*)", + "Bash(ls:*)", + "Bash(sed:*)", + "Bash(awk NR>=1690 && NR<=1710 *)", + "Bash(awk -F: '$1>=1560 && $1<=2700')", + "Bash(awk *)" + ] + } +} diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 6dc8a389581d..8c270ff42890 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -354,10 +354,21 @@ export class MainPane extends React.Component<{}, MainPaneState> { } private resetEditor() { + const useShadowDom = window.location.search.includes('shadowDom'); + this.setState({ - editorCreator: (div: HTMLDivElement, options: EditorOptions) => { - return new Editor(div, options); - }, + editorCreator: useShadowDom + ? (div: HTMLDivElement, options: EditorOptions) => { + const shadowRoot = div.attachShadow({ mode: 'open' }); + const innerDiv = document.createElement('div'); + innerDiv.style.width = '100%'; + innerDiv.style.height = '100%'; + shadowRoot.appendChild(innerDiv); + return new Editor(innerDiv, options); + } + : (div: HTMLDivElement, options: EditorOptions) => { + return new Editor(div, options); + }, }); } diff --git a/demo/scripts/index.ts b/demo/scripts/index.ts index 3630f0ac6fa5..51948d113725 100644 --- a/demo/scripts/index.ts +++ b/demo/scripts/index.ts @@ -1,5 +1,4 @@ import { mount as mountV2 } from './controlsV2/mainPane/MainPane'; const mainPaneDiv = document.getElementById('mainPane'); - -mountV2(mainPaneDiv); +mountV2(mainPaneDiv!); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts b/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts index 59490603bbab..a8767f1b9b83 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts @@ -17,6 +17,7 @@ export const announce: Announce = (core, announceData) => { if (!core.lifecycle.announceContainer) { core.lifecycle.announceContainer = createAriaLiveElement(core.physicalRoot.ownerDocument); + core.domHelper.appendToRoot(core.lifecycle.announceContainer); } if (textToAnnounce && core.lifecycle.announceContainer) { diff --git a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts index 63c77110276c..f83da00bc1a7 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts @@ -16,16 +16,13 @@ export const getDOMSelection: GetDOMSelection = core => { }; function getNewSelection(core: EditorCore): DOMSelection | null { - const selection = core.logicalRoot.ownerDocument.defaultView?.getSelection(); - const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const range = core.domHelper.getSelectionRange(); - return selection && range && core.logicalRoot.contains(range.commonAncestorContainer) + return range && core.logicalRoot.contains(range.commonAncestorContainer) ? { type: 'range', range, - isReverted: - selection.focusNode != range.endContainer || - selection.focusOffset != range.endOffset, + isReverted: core.domHelper.isSelectionReverted(), } : null; } diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index 3676ed67ef24..6864e9169341 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,4 +1,3 @@ -import { addRangeToSelection } from './addRangeToSelection'; import { areSameSelections } from '../../corePlugin/cache/areSameSelections'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { findLastedCoInMergedCell } from './findLastedCoInMergedCell'; @@ -6,7 +5,11 @@ import { findTableCellElement } from './findTableCellElement'; import { getSafeIdSelector, parseTableCells } from 'roosterjs-content-model-dom'; import { setTableCellsStyle } from './setTableCellsStyle'; import { toggleCaret } from './toggleCaret'; -import type { SelectionChangedEvent, SetDOMSelection } from 'roosterjs-content-model-types'; +import type { + EditorCore, + SelectionChangedEvent, + SetDOMSelection, +} from 'roosterjs-content-model-types'; const DOM_SELECTION_CSS_KEY = '_DOMSelection'; const HIDE_SELECTION_CSS_KEY = '_DOMSelectionHideSelection'; @@ -29,7 +32,6 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC // Set skipReselectOnFocus to skip this behavior const skipReselectOnFocus = core.selection.skipReselectOnFocus; - const doc = core.physicalRoot.ownerDocument; const isDarkMode = core.lifecycle.isDarkMode; core.selection.skipReselectOnFocus = true; core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, null /*cssRule*/); @@ -63,7 +65,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC [SELECTION_SELECTOR] ); - setRangeSelection(doc, image, false /* collapse */); + setRangeSelection(core, image, false /* collapse */); break; case 'table': const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; @@ -116,7 +118,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC if (nodeToSelect) { setRangeSelection( - doc, + core, (nodeToSelect as HTMLElement) || undefined, true /* collapse */ ); @@ -124,7 +126,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC break; case 'range': - addRangeToSelection(doc, selection.range, selection.isReverted); + core.domHelper.setSelectionRange(selection.range, selection.isReverted); core.selection.selection = core.domHelper.hasFocus() ? null : selection; break; @@ -147,24 +149,16 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function setRangeSelection(doc: Document, element: HTMLElement | undefined, collapse: boolean) { - if (element && doc.contains(element)) { - const range = doc.createRange(); - let isReverted: boolean | undefined = undefined; +function setRangeSelection(core: EditorCore, element: HTMLElement | undefined, collapse: boolean) { + if (element && core.physicalRoot.contains(element)) { + const range = core.physicalRoot.ownerDocument.createRange(); range.selectNode(element); if (collapse) { range.collapse(); - } else { - const selection = doc.defaultView?.getSelection(); - const range = selection && selection.rangeCount > 0 && selection.getRangeAt(0); - if (selection && range) { - isReverted = - selection.focusNode != range.endContainer || - selection.focusOffset != range.endOffset; - } } - addRangeToSelection(doc, range, isReverted); + const isReverted = collapse ? false : core.domHelper.isSelectionReverted(); + core.domHelper.setSelectionRange(range, isReverted); } } diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts index 00bb8aaeabb0..236f5718d986 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts @@ -6,10 +6,10 @@ import { getSafeIdSelector } from 'roosterjs-content-model-dom'; export function ensureUniqueId(element: HTMLElement, idPrefix: string): string { idPrefix = element.id || idPrefix; - const doc = element.ownerDocument; + const root = element.getRootNode() as Document | ShadowRoot; let i = 0; - while (!element.id || doc.querySelectorAll(getSafeIdSelector(element.id)).length > 1) { + while (!element.id || root.querySelectorAll(getSafeIdSelector(element.id)).length > 1) { element.id = idPrefix + '_' + i++; } diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts index 23255fa19b70..b092bb8b81da 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts @@ -21,7 +21,7 @@ export const setEditorStyle: SetEditorStyle = ( const doc = core.physicalRoot.ownerDocument; styleElement = doc.createElement('style'); - doc.head.appendChild(styleElement); + core.domHelper.appendStyle(styleElement); styleElement.dataset.roosterjsStyleKey = key; core.lifecycle.styleElements[key] = styleElement; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts index 9b98ecad9d5a..36a811e68f88 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -55,7 +55,10 @@ class CachePlugin implements PluginWithState { */ initialize(editor: IEditor) { this.editor = editor; - this.editor.getDocument().addEventListener('selectionchange', this.onNativeSelectionChange); + this.editor + .getDOMHelper() + .getEventRoot() + .addEventListener('selectionchange', this.onNativeSelectionChange); this.state.textMutationObserver.startObserving(); } @@ -70,7 +73,8 @@ class CachePlugin implements PluginWithState { if (this.editor) { this.editor - .getDocument() + .getDOMHelper() + .getEventRoot() .removeEventListener('selectionchange', this.onNativeSelectionChange); this.editor = null; } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts index c038e5776e77..2b4528734088 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts @@ -85,6 +85,7 @@ class LifecyclePlugin implements PluginWithState { // Initialize the Announce container. this.state.announceContainer = createAriaLiveElement(editor.getDocument()); + editor.getDOMHelper().appendToRoot(this.state.announceContainer); } /** diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 921fca1847f7..bdf02e7d181c 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -96,11 +96,13 @@ class SelectionPlugin implements PluginWithState { } const env = this.editor.getEnvironment(); - const document = this.editor.getDocument(); this.isSafari = !!env.isSafari; this.isMac = !!env.isMac; - document.addEventListener('selectionchange', this.onSelectionChange); + this.editor + .getDOMHelper() + .getEventRoot() + .addEventListener('selectionchange', this.onSelectionChange); if (this.isSafari) { this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus }, @@ -116,7 +118,10 @@ class SelectionPlugin implements PluginWithState { } dispose() { - this.editor?.getDocument().removeEventListener('selectionchange', this.onSelectionChange); + this.editor + ?.getDOMHelper() + .getEventRoot() + .removeEventListener('selectionchange', this.onSelectionChange); if (this.disposer) { this.disposer(); @@ -736,19 +741,17 @@ class SelectionPlugin implements PluginWithState { private onSelectionChange = () => { if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { const newSelection = this.editor.getDOMSelection(); + const domHelper = this.editor.getDOMHelper(); //If am image selection changed to a wider range due a keyboard event, we should update the selection - const selection = this.editor.getDocument().getSelection(); - if (selection && selection.focusNode) { - const image = isSingleImageInSelection(selection); + const range = domHelper.getSelectionRange(); + if (range) { + const image = isSingleImageInSelection(range); if (newSelection?.type == 'image' && !image) { - const range = selection.getRangeAt(0); this.editor.setDOMSelection({ type: 'range', range, - isReverted: - selection.focusNode != range.endContainer || - selection.focusOffset != range.endOffset, + isReverted: domHelper.isSelectionReverted(), }); } else if (newSelection?.type !== 'image' && image) { this.editor.setDOMSelection({ diff --git a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts index 016d5a8240b4..433e0275bdb1 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts @@ -6,6 +6,7 @@ import { parseValueWithUnit, toArray, } from 'roosterjs-content-model-dom'; +import { areSameRanges } from '../../corePlugin/cache/areSameSelections'; import type { ContentModelSegmentFormat, DarkColorHandler, @@ -22,8 +23,214 @@ export interface DOMHelperImplOption { cloneIndependentRoot?: boolean; } +/** + * @internal + * Adapter interface for shadow DOM selection operations. + * Resolved once at construction time to avoid repeated feature detection. + */ +interface ShadowSelectionAdapter { + getRange(): Range | null; + getSelection(): Selection | null; + isReverted(): boolean; + setRange(range: Range, isReverted: boolean): void; +} + +/** + * Standard adapter using getComposedRanges (modern Chrome/Firefox/Safari) + */ +class ComposedRangesAdapter implements ShadowSelectionAdapter { + constructor(private shadowRoot: ShadowRoot, private doc: Document) {} + + getSelection(): Selection | null { + return this.doc.defaultView?.getSelection() ?? null; + } + + getRange(): Range | null { + const sel = this.getSelection(); + if (!sel) { + return null; + } + + const staticRanges = (sel as any).getComposedRanges({ + shadowRoots: [this.shadowRoot], + }); + + if (staticRanges?.length > 0) { + const sr = staticRanges[0] as StaticRange; + const range = this.doc.createRange(); + range.setStart(sr.startContainer, sr.startOffset); + range.setEnd(sr.endContainer, sr.endOffset); + return range; + } + return null; + } + + isReverted(): boolean { + // getComposedRanges returns StaticRange which doesn't expose direction + return false; + } + + setRange(range: Range, isReverted: boolean): void { + const sel = this.getSelection(); + if (!sel) { + return; + } + sel.removeAllRanges(); + + if (!isReverted) { + sel.addRange(range); + } else { + sel.setBaseAndExtent( + range.endContainer, + range.endOffset, + range.startContainer, + range.startOffset + ); + } + } +} + +/** + * Deprecated Chromium adapter using shadowRoot.getSelection() (non-standard) + */ +class ShadowRootSelectionAdapter implements ShadowSelectionAdapter { + constructor(private shadowRoot: ShadowRoot) {} + + getSelection(): Selection | null { + return (this.shadowRoot as any).getSelection() ?? null; + } + + getRange(): Range | null { + const sel = this.getSelection(); + if (!sel || sel.rangeCount === 0) { + return null; + } + return sel.getRangeAt(0); + } + + isReverted(): boolean { + const sel = this.getSelection(); + if (!sel || sel.rangeCount === 0) { + return false; + } + const range = sel.getRangeAt(0); + return sel.focusNode != range.endContainer || sel.focusOffset != range.endOffset; + } + + setRange(range: Range, isReverted: boolean): void { + const sel = this.getSelection(); + if (!sel) { + return; + } + + const currentRange = sel.rangeCount > 0 && sel.getRangeAt(0); + if (currentRange && areSameRanges(currentRange, range)) { + return; + } + sel.removeAllRanges(); + + if (!isReverted) { + sel.addRange(range); + } else { + sel.setBaseAndExtent( + range.endContainer, + range.endOffset, + range.startContainer, + range.startOffset + ); + } + } +} + +/** + * Document adapter using document.getSelection() (older Firefox piercing / no shadow DOM) + */ +class DocumentSelectionAdapter implements ShadowSelectionAdapter { + constructor(private doc: Document) {} + + getSelection(): Selection | null { + return this.doc.defaultView?.getSelection() ?? null; + } + + getRange(): Range | null { + const sel = this.getSelection(); + if (!sel || sel.rangeCount === 0) { + return null; + } + return sel.getRangeAt(0); + } + + isReverted(): boolean { + const sel = this.getSelection(); + if (!sel || sel.rangeCount === 0) { + return false; + } + const range = sel.getRangeAt(0); + return sel.focusNode != range.endContainer || sel.focusOffset != range.endOffset; + } + + setRange(range: Range, isReverted: boolean): void { + const sel = this.getSelection(); + if (!sel) { + return; + } + + const currentRange = sel.rangeCount > 0 && sel.getRangeAt(0); + if (currentRange && areSameRanges(currentRange, range)) { + return; + } + sel.removeAllRanges(); + + if (!isReverted) { + sel.addRange(range); + } else { + sel.setBaseAndExtent( + range.endContainer, + range.endOffset, + range.startContainer, + range.startOffset + ); + } + } +} + +/** + * Resolve the correct selection adapter once at construction time + */ +function createSelectionAdapter( + shadowRoot: ShadowRoot | null, + doc: Document +): ShadowSelectionAdapter { + if (!shadowRoot) { + return new DocumentSelectionAdapter(doc); + } + + // 1. Standard: getComposedRanges (modern Chrome/Firefox/Safari) + const sel = doc.defaultView?.getSelection(); + if (sel && 'getComposedRanges' in sel) { + return new ComposedRangesAdapter(shadowRoot, doc); + } + + // 2. Deprecated: shadowRoot.getSelection() (older Chromium) + if ('getSelection' in shadowRoot) { + return new ShadowRootSelectionAdapter(shadowRoot); + } + + // 3. Fallback: document.getSelection() (older Firefox — pierces shadow DOM) + return new DocumentSelectionAdapter(doc); +} + class DOMHelperImpl implements DOMHelper { - constructor(private contentDiv: HTMLElement, options?: DOMHelperImplOption) {} + private shadowRoot: ShadowRoot | null; + private doc: Document; + private selectionAdapter: ShadowSelectionAdapter; + + constructor(private contentDiv: HTMLElement, options?: DOMHelperImplOption) { + const rootNode = contentDiv.getRootNode(); + this.shadowRoot = rootNode instanceof ShadowRoot ? rootNode : null; + this.doc = contentDiv.ownerDocument; + this.selectionAdapter = createSelectionAdapter(this.shadowRoot, this.doc); + } queryElements(selector: string): HTMLElement[] { return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; @@ -97,7 +304,9 @@ class DOMHelperImpl implements DOMHelper { } hasFocus(): boolean { - const activeElement = this.contentDiv.ownerDocument.activeElement; + const activeElement = this.shadowRoot + ? this.shadowRoot.activeElement + : this.doc.activeElement; return !!(activeElement && this.contentDiv.contains(activeElement)); } @@ -185,6 +394,42 @@ class DOMHelperImpl implements DOMHelper { getRangesByText(text: string, matchCase: boolean, wholeWord: boolean): Range[] { return getRangesByText(this.contentDiv, text, matchCase, wholeWord, true /*editableOnly*/); } + + getSelection(): Selection | null { + return this.selectionAdapter.getSelection(); + } + + getSelectionRange(): Range | null { + return this.selectionAdapter.getRange(); + } + + setSelectionRange(range: Range, isReverted: boolean = false): void { + this.selectionAdapter.setRange(range, isReverted); + } + + isSelectionReverted(): boolean { + return this.selectionAdapter.isReverted(); + } + + appendStyle(style: HTMLStyleElement): void { + if (this.shadowRoot) { + this.shadowRoot.appendChild(style); + } else { + this.doc.head.appendChild(style); + } + } + + appendToRoot(element: HTMLElement): void { + if (this.shadowRoot) { + this.shadowRoot.appendChild(element); + } else { + this.doc.body.appendChild(element); + } + } + + getEventRoot(): Document { + return this.doc; + } } /** diff --git a/packages/roosterjs-content-model-core/lib/utils/createAriaLiveElement.ts b/packages/roosterjs-content-model-core/lib/utils/createAriaLiveElement.ts index e255a8d7551f..60edaa9b430f 100644 --- a/packages/roosterjs-content-model-core/lib/utils/createAriaLiveElement.ts +++ b/packages/roosterjs-content-model-core/lib/utils/createAriaLiveElement.ts @@ -13,7 +13,5 @@ export function createAriaLiveElement(document: Document): HTMLDivElement { div.style.width = '1px'; div.ariaLive = 'assertive'; - document.body.appendChild(div); - return div; } diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 1fd76ba4f0c7..3ec0bcc82a7a 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -127,4 +127,48 @@ export interface DOMHelper { * @returns An array of Ranges that match the search criteria */ getRangesByText(text: string, matchCase: boolean, wholeWord: boolean): Range[]; + + /** + * Get the current selection. In shadow DOM, delegates to the resolved + * selection adapter (getComposedRanges → shadowRoot.getSelection → document.getSelection). + */ + getSelection(): Selection | null; + + /** + * Get the current selection range, handling shadow DOM StaticRange conversion. + * Returns a live Range in all browsers. + */ + getSelectionRange(): Range | null; + + /** + * Set the selection to the given range, handling browser differences for shadow DOM. + * @param range The range to set + * @param isReverted Whether the selection is reverted (focus before anchor) + */ + setSelectionRange(range: Range, isReverted?: boolean): void; + + /** + * Detect if the current selection is reverted (focus before anchor). + * In shadow DOM with getComposedRanges, returns false since StaticRange has no direction. + */ + isSelectionReverted(): boolean; + + /** + * Append a style element to the correct root (shadow root or document.head) + * @param style The style element to append + */ + appendStyle(style: HTMLStyleElement): void; + + /** + * Append an element to the correct root container (shadow root or document.body) + * @param element The element to append + */ + appendToRoot(element: HTMLElement): void; + + /** + * Get the root node for event listener registration. + * Returns shadow root when in shadow DOM, document otherwise. + * Used for events like 'selectionchange' that fire on the shadow root. + */ + getEventRoot(): Document; } diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 45b3f2618371..25af344f101e 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -531,7 +531,7 @@ export class EditorAdapter extends Editor implements ILegacyEditor { * Get current focused position. Return null if editor doesn't have focus at this time. */ getFocusedPosition(): NodePosition | null { - const sel = this.getDocument().defaultView?.getSelection(); + const sel = this.getDOMHelper().getSelection(); if (sel?.focusNode && this.contains(sel.focusNode)) { return new Position(sel.focusNode, sel.focusOffset); } diff --git a/shadow-dom-plan.md b/shadow-dom-plan.md new file mode 100644 index 000000000000..7595181f7ec8 --- /dev/null +++ b/shadow-dom-plan.md @@ -0,0 +1,546 @@ +# Shadow DOM Support for RoosterJS + +## Problem + +When the editor is mounted inside a Shadow DOM, `element.ownerDocument` returns the outer `Document` instead of being scoped to the shadow root. This breaks: + +1. **Selection** — `document.getSelection()` doesn't see shadow DOM content (Chrome/Safari); `selection.focusNode`/`anchorNode` return null in Safari shadow DOM +2. **Focus detection** — `document.activeElement` stops at the shadow host +3. **ID uniqueness** — `document.querySelectorAll()` can't find elements inside shadow DOM +4. **Announcer** — aria-live element appended to `document.body` is outside shadow DOM scope +5. **`selectionchange` event** — fires on `ShadowRoot` not `document` in Chrome/Safari +6. **`doc.contains()` in setDOMSelection** — `Document.contains()` won't find elements inside shadow DOM + +> **Note:** `setEditorStyle` appends `