Skip to content

Commit 83eb39a

Browse files
[lexical][lexical-clipboard][lexical-playground][lexical-react][lexical-selection][lexical-table][lexical-utils] Add Shadow DOM support
1 parent 9f9e737 commit 83eb39a

File tree

25 files changed

+1146
-47
lines changed

25 files changed

+1146
-47
lines changed

packages/lexical-clipboard/src/clipboard.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ export async function copyToClipboard(
469469
const rootElement = editor.getRootElement();
470470
const editorWindow = editor._window || window;
471471
const windowDocument = window.document;
472-
const domSelection = getDOMSelection(editorWindow);
472+
const domSelection = getDOMSelection(editorWindow, rootElement);
473473
if (rootElement === null || domSelection === null) {
474474
return false;
475475
}
@@ -518,7 +518,10 @@ function $copyToClipboardEvent(
518518
data?: LexicalClipboardData,
519519
): boolean {
520520
if (data === undefined) {
521-
const domSelection = getDOMSelection(editor._window);
521+
const domSelection = getDOMSelection(
522+
editor._window,
523+
editor.getRootElement(),
524+
);
522525
if (!domSelection) {
523526
return false;
524527
}

packages/lexical-playground/src/Editor.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import TreeViewPlugin from './plugins/TreeViewPlugin';
7777
import TwitterPlugin from './plugins/TwitterPlugin';
7878
import YouTubePlugin from './plugins/YouTubePlugin';
7979
import ContentEditable from './ui/ContentEditable';
80+
import ShadowDOMWrapper from './ui/ShadowDOMWrapper';
8081

8182
const skipCollaborationInit =
8283
// @ts-expect-error
@@ -95,6 +96,7 @@ export default function Editor(): JSX.Element {
9596
hasLinkAttributes,
9697
isCharLimitUtf8,
9798
isRichText,
99+
isShadowDOM,
98100
showTreeView,
99101
showTableOfContents,
100102
shouldUseLexicalContextMenu,
@@ -160,7 +162,9 @@ export default function Editor(): JSX.Element {
160162
setIsLinkEditMode={setIsLinkEditMode}
161163
/>
162164
)}
163-
<div
165+
<ShadowDOMWrapper
166+
key={`shadow-${isShadowDOM}`}
167+
enabled={isShadowDOM}
164168
className={`editor-container ${showTreeView ? 'tree-view' : ''} ${
165169
!isRichText ? 'plain-text' : ''
166170
}`}>
@@ -282,7 +286,7 @@ export default function Editor(): JSX.Element {
282286
isRichText={isRichText}
283287
shouldPreserveNewLinesInMarkdown={shouldPreserveNewLinesInMarkdown}
284288
/>
285-
</div>
289+
</ShadowDOMWrapper>
286290
{showTreeView && <TreeViewPlugin />}
287291
</>
288292
);

packages/lexical-playground/src/Settings.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export default function Settings(): JSX.Element {
2828
isCharLimit,
2929
isCharLimitUtf8,
3030
isAutocomplete,
31+
isShadowDOM,
3132
showTreeView,
3233
showNestedEditorTreeView,
3334
// disableBeforeInput,
@@ -139,6 +140,14 @@ export default function Settings(): JSX.Element {
139140
checked={isAutocomplete}
140141
text="Autocomplete"
141142
/>
143+
<Switch
144+
onClick={() => {
145+
setOption('isShadowDOM', !isShadowDOM);
146+
setTimeout(() => window.location.reload(), 500);
147+
}}
148+
checked={isShadowDOM}
149+
text="Shadow DOM"
150+
/>
142151
{/* <Switch
143152
onClick={() => {
144153
setOption('disableBeforeInput', !disableBeforeInput);

packages/lexical-playground/src/appSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const DEFAULT_SETTINGS = {
2323
isCollab: false,
2424
isMaxLength: false,
2525
isRichText: true,
26+
isShadowDOM: false,
2627
listStrictIndent: false,
2728
measureTypingPerf: false,
2829
selectionAlwaysOnDisplay: false,

packages/lexical-playground/src/plugins/CommentPlugin/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,10 @@ export default function CommentPlugin({
933933
editor.registerCommand(
934934
INSERT_INLINE_COMMAND,
935935
() => {
936-
const domSelection = getDOMSelection(editor._window);
936+
const domSelection = getDOMSelection(
937+
editor._window,
938+
editor.getRootElement(),
939+
);
937940
if (domSelection !== null) {
938941
domSelection.removeAllRanges();
939942
}

packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ function FloatingLinkEditor({
104104
}
105105

106106
const editorElem = editorRef.current;
107-
const nativeSelection = getDOMSelection(editor._window);
107+
const nativeSelection = getDOMSelection(
108+
editor._window,
109+
editor.getRootElement(),
110+
);
108111
const activeElement = document.activeElement;
109112

110113
if (editorElem === null) {

packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ function TextFormatFloatingToolbar({
122122
const selection = $getSelection();
123123

124124
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
125-
const nativeSelection = getDOMSelection(editor._window);
125+
const nativeSelection = getDOMSelection(
126+
editor._window,
127+
editor.getRootElement(),
128+
);
126129

127130
if (popupCharStylesEditorElem === null) {
128131
return;
@@ -342,7 +345,10 @@ function useFloatingTextFormatToolbar(
342345
return;
343346
}
344347
const selection = $getSelection();
345-
const nativeSelection = getDOMSelection(editor._window);
348+
const nativeSelection = getDOMSelection(
349+
editor._window,
350+
editor.getRootElement(),
351+
);
346352
const rootElement = editor.getRootElement();
347353

348354
if (

packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,10 @@ function TableCellActionMenuContainer({
729729
const $moveMenu = useCallback(() => {
730730
const menu = menuButtonRef.current;
731731
const selection = $getSelection();
732-
const nativeSelection = getDOMSelection(editor._window);
732+
const nativeSelection = getDOMSelection(
733+
editor._window,
734+
editor.getRootElement(),
735+
);
733736
const activeElement = document.activeElement;
734737
function disable() {
735738
if (menu) {

packages/lexical-playground/src/plugins/TestRecorderPlugin/index.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ function useTestRecorder(
172172

173173
const generateTestContent = useCallback(() => {
174174
const rootElement = editor.getRootElement();
175-
const browserSelection = getDOMSelection(editor._window);
175+
const browserSelection = getDOMSelection(
176+
editor._window,
177+
editor.getRootElement(),
178+
);
176179

177180
if (
178181
rootElement == null ||
@@ -327,7 +330,10 @@ ${steps.map(formatStep).join(`\n`)}
327330
dirtyElements.size === 0 &&
328331
!skipNextSelectionChange
329332
) {
330-
const browserSelection = getDOMSelection(editor._window);
333+
const browserSelection = getDOMSelection(
334+
editor._window,
335+
editor.getRootElement(),
336+
);
331337
if (
332338
browserSelection &&
333339
(browserSelection.anchorNode == null ||
@@ -384,7 +390,11 @@ ${steps.map(formatStep).join(`\n`)}
384390
if (!isRecording) {
385391
return;
386392
}
387-
const browserSelection = getDOMSelection(getCurrentEditor()._window);
393+
const currentEditor = getCurrentEditor();
394+
const browserSelection = getDOMSelection(
395+
currentEditor._window,
396+
currentEditor.getRootElement(),
397+
);
388398
if (
389399
browserSelection === null ||
390400
browserSelection.anchorNode == null ||
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
9+
import type {JSX, ReactNode} from 'react';
10+
11+
import {useEffect, useRef, useState} from 'react';
12+
import {createPortal} from 'react-dom';
13+
14+
type ShadowDOMWrapperProps = {
15+
children: ReactNode;
16+
enabled: boolean;
17+
className?: string;
18+
};
19+
20+
export default function ShadowDOMWrapper({
21+
children,
22+
enabled,
23+
className,
24+
}: ShadowDOMWrapperProps): JSX.Element {
25+
const hostRef = useRef<HTMLDivElement>(null);
26+
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null);
27+
const [stylesAdded, setStylesAdded] = useState(false);
28+
29+
useEffect(() => {
30+
if (!enabled || !hostRef.current) {
31+
setShadowRoot(null);
32+
setStylesAdded(false);
33+
return;
34+
}
35+
36+
const host = hostRef.current;
37+
38+
// Create shadow DOM (should be safe with fresh element due to key prop)
39+
try {
40+
const shadow = host.attachShadow({mode: 'open'});
41+
setShadowRoot(shadow);
42+
} catch (error) {
43+
// If shadow already exists, use existing one
44+
if (error instanceof DOMException && error.name === 'NotSupportedError') {
45+
const existingShadow = host.shadowRoot;
46+
if (existingShadow) {
47+
setShadowRoot(existingShadow);
48+
// Clear existing content
49+
existingShadow.innerHTML = '';
50+
}
51+
} else {
52+
console.error('Error creating shadow DOM:', error);
53+
return;
54+
}
55+
}
56+
57+
const shadow = host.shadowRoot;
58+
if (!shadow) {
59+
return;
60+
}
61+
62+
// Copy all document styles to shadow DOM
63+
const documentStyles = Array.from(
64+
document.head.querySelectorAll('style, link[rel="stylesheet"]'),
65+
);
66+
67+
documentStyles.forEach((styleElement) => {
68+
const clonedStyle = styleElement.cloneNode(true) as HTMLElement;
69+
shadow.appendChild(clonedStyle);
70+
});
71+
72+
setStylesAdded(true);
73+
74+
return () => {
75+
// Cleanup is automatic when host element is removed
76+
};
77+
}, [enabled]);
78+
79+
// If shadow DOM is not enabled, render children normally
80+
if (!enabled) {
81+
return <div className={className}>{children}</div>;
82+
}
83+
84+
// Return the host element and portal to shadow DOM
85+
return (
86+
<div style={{position: 'relative'}}>
87+
<div
88+
ref={hostRef}
89+
className={className}
90+
style={{
91+
position: 'relative',
92+
}}
93+
/>
94+
{shadowRoot &&
95+
stylesAdded &&
96+
createPortal(
97+
<div
98+
style={{
99+
display: 'contents', // This ensures the children render properly
100+
}}>
101+
{children}
102+
</div>,
103+
shadowRoot,
104+
)}
105+
</div>
106+
);
107+
}

0 commit comments

Comments
 (0)