diff --git a/.changeset/tiny-walls-deliver.md b/.changeset/tiny-walls-deliver.md new file mode 100644 index 000000000..db40136d0 --- /dev/null +++ b/.changeset/tiny-walls-deliver.md @@ -0,0 +1,5 @@ +--- +'slate-react': patch +--- + +Fixes Slate to work with the Shadow DOM. diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 6c2598745..b765bfbd1 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -32,7 +32,6 @@ import { getDefaultView, isDOMElement, isDOMNode, - isDOMText, DOMStaticRange, isPlainTextOnlyPaste, } from '../utils/dom' @@ -148,8 +147,8 @@ export const Editable = (props: EditableProps) => { // Whenever the editor updates, make sure the DOM selection state is in sync. useIsomorphicLayoutEffect(() => { const { selection } = editor - const window = ReactEditor.getWindow(editor) - const domSelection = window.getSelection() + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const domSelection = root.getSelection() if (state.isComposing || !domSelection || !ReactEditor.isFocused(editor)) { return @@ -400,10 +399,10 @@ export const Editable = (props: EditableProps) => { const onDOMSelectionChange = useCallback( throttle(() => { if (!readOnly && !state.isComposing && !state.isUpdatingSelection) { - const window = ReactEditor.getWindow(editor) - const { activeElement } = window.document + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const { activeElement } = root const el = ReactEditor.toDOMNode(editor, editor) - const domSelection = window.getSelection() + const domSelection = root.getSelection() if (activeElement === el) { state.latestElement = activeElement @@ -541,7 +540,8 @@ export const Editable = (props: EditableProps) => { // one, this is due to the window being blurred when the tab // itself becomes unfocused, so we want to abort early to allow to // editor to stay focused when the tab becomes focused again. - if (state.latestElement === window.document.activeElement) { + const root = ReactEditor.findDocumentOrShadowRoot(editor) + if (state.latestElement === root.activeElement) { return } @@ -745,8 +745,8 @@ export const Editable = (props: EditableProps) => { !isEventHandled(event, attributes.onFocus) ) { const el = ReactEditor.toDOMNode(editor, editor) - const window = ReactEditor.getWindow(editor) - state.latestElement = window.document.activeElement + const root = ReactEditor.findDocumentOrShadowRoot(editor) + state.latestElement = root.activeElement // COMPAT: If the editor has nested editable elements, the focus // can go to them. In Firefox, this must be prevented because it diff --git a/packages/slate-react/src/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index b2f8ba460..8a449a44a 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -20,9 +20,10 @@ import { DOMSelection, DOMStaticRange, isDOMElement, - normalizeDOMPoint, isDOMSelection, + normalizeDOMPoint, } from '../utils/dom' +import { IS_CHROME } from '../utils/environment' /** * A React and DOM-specific version of the `Editor` interface. @@ -95,6 +96,29 @@ export const ReactEditor = { ) }, + /** + * Find the DOM node that implements DocumentOrShadowRoot for the editor. + */ + + findDocumentOrShadowRoot(editor: ReactEditor): Document | ShadowRoot { + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (!(root instanceof Document || root instanceof ShadowRoot)) + throw new Error( + `Unable to find DocumentOrShadowRoot for editor element: ${el}` + ) + + // COMPAT: Only Chrome implements the DocumentOrShadowRoot mixin for + // ShadowRoot; other browsers still implement it on the Document + // interface. (2020/08/08) + // https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot#Properties + if (root.getSelection === undefined && el.ownerDocument !== null) + return el.ownerDocument + + return root + }, + /** * Check if the editor is focused. */ @@ -117,9 +141,10 @@ export const ReactEditor = { blur(editor: ReactEditor): void { const el = ReactEditor.toDOMNode(editor, editor) + const root = ReactEditor.findDocumentOrShadowRoot(editor) IS_FOCUSED.set(editor, false) - const window = ReactEditor.getWindow(editor) - if (window.document.activeElement === el) { + + if (root.activeElement === el) { el.blur() } }, @@ -130,10 +155,10 @@ export const ReactEditor = { focus(editor: ReactEditor): void { const el = ReactEditor.toDOMNode(editor, editor) + const root = ReactEditor.findDocumentOrShadowRoot(editor) IS_FOCUSED.set(editor, true) - const window = ReactEditor.getWindow(editor) - if (window.document.activeElement !== el) { + if (root.activeElement !== el) { el.focus({ preventScroll: true }) } }, @@ -143,9 +168,10 @@ export const ReactEditor = { */ deselect(editor: ReactEditor): void { + const el = ReactEditor.toDOMNode(editor, editor) const { selection } = editor - const window = ReactEditor.getWindow(editor) - const domSelection = window.getSelection() + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const domSelection = root.getSelection() if (domSelection && domSelection.rangeCount > 0) { domSelection.removeAllRanges() @@ -509,7 +535,17 @@ export const ReactEditor = { anchorOffset = domRange.anchorOffset focusNode = domRange.focusNode focusOffset = domRange.focusOffset - isCollapsed = domRange.isCollapsed + // COMPAT: There's a bug in chrome that always returns `true` for + // `isCollapsed` for a Selection that comes from a ShadowRoot. + // (2020/08/08) + // https://bugs.chromium.org/p/chromium/issues/detail?id=447523 + if (IS_CHROME && hasShadowRoot()) { + isCollapsed = + domRange.anchorNode === domRange.focusNode && + domRange.anchorOffset === domRange.focusOffset + } else { + isCollapsed = domRange.isCollapsed + } } else { anchorNode = domRange.startContainer anchorOffset = domRange.startOffset diff --git a/packages/slate-react/src/utils/dom.ts b/packages/slate-react/src/utils/dom.ts index feec2b18a..860e27bdc 100644 --- a/packages/slate-react/src/utils/dom.ts +++ b/packages/slate-react/src/utils/dom.ts @@ -127,6 +127,16 @@ export const normalizeDOMPoint = (domPoint: DOMPoint): DOMPoint => { return [node, offset] } +/** + * Determines wether the active element is nested within a shadowRoot + */ + +export const hasShadowRoot = () => { + return !!( + window.document.activeElement && window.document.activeElement.shadowRoot + ) +} + /** * Get the nearest editable child at `index` in a `parent`, preferring * `direction`. diff --git a/packages/slate-react/src/utils/environment.ts b/packages/slate-react/src/utils/environment.ts index a12cdbcaa..3d257ce1e 100644 --- a/packages/slate-react/src/utils/environment.ts +++ b/packages/slate-react/src/utils/environment.ts @@ -20,6 +20,9 @@ export const IS_EDGE_LEGACY = typeof navigator !== 'undefined' && /Edge?\/(?:[0-6][0-9]|[0-7][0-8])/i.test(navigator.userAgent) +export const IS_CHROME = + typeof navigator !== 'undefined' && /Chrome/i.test(navigator.userAgent) + // Native beforeInput events don't work well with react on Chrome 75 and older, Chrome 76+ can use beforeInput export const IS_CHROME_LEGACY = typeof navigator !== 'undefined' && diff --git a/site/examples/shadow-dom.js b/site/examples/shadow-dom.js new file mode 100644 index 000000000..01f0ce6da --- /dev/null +++ b/site/examples/shadow-dom.js @@ -0,0 +1,47 @@ +import ReactDOM from 'react-dom' +import React, { useState, useMemo, useRef, useEffect } from 'react' +import { createEditor } from 'slate' +import { Slate, Editable, withReact } from 'slate-react' +import { withHistory } from 'slate-history' + +const ShadowDOM = () => { + const container = useRef(null) + + useEffect(() => { + if (container.current.shadowRoot) return + + // Create a shadow DOM + const outerShadowRoot = container.current.attachShadow({ mode: 'open' }) + const host = document.createElement('div') + outerShadowRoot.appendChild(host) + + // Create a nested shadow DOM + const innerShadowRoot = host.attachShadow({ mode: 'open' }) + const reactRoot = document.createElement('div') + innerShadowRoot.appendChild(reactRoot) + + // Render the editor within the nested shadow DOM + ReactDOM.render(, reactRoot) + }) + + return
+} + +const ShadowEditor = () => { + const [value, setValue] = useState(initialValue) + const editor = useMemo(() => withHistory(withReact(createEditor())), []) + + return ( + setValue(value)}> + + + ) +} + +const initialValue = [ + { + children: [{ text: 'This Editor is rendered within a nested Shadow DOM.' }], + }, +] + +export default ShadowDOM diff --git a/site/pages/examples/[example].tsx b/site/pages/examples/[example].tsx index 7c4c0b9ea..d69303b5e 100644 --- a/site/pages/examples/[example].tsx +++ b/site/pages/examples/[example].tsx @@ -23,7 +23,7 @@ import PlainText from '../../examples/plaintext' import ReadOnly from '../../examples/read-only' import RichText from '../../examples/richtext' import SearchHighlighting from '../../examples/search-highlighting' -import CodeHighlighting from '../../examples/code-highlighting' +import ShadowDOM from '../../examples/shadow-dom' import Tables from '../../examples/tables' import IFrames from '../../examples/iframe' @@ -47,7 +47,7 @@ const EXAMPLES = [ ['Read-only', ReadOnly, 'read-only'], ['Rich Text', RichText, 'richtext'], ['Search Highlighting', SearchHighlighting, 'search-highlighting'], - ['Code Highlighting', CodeHighlighting, 'code-highlighting'], + ['Shadow DOM', ShadowDOM, 'shadow-dom'], ['Tables', Tables, 'tables'], ['Rendering in iframes', IFrames, 'iframe'], ]