From 0473d0bf93808b0e4e98abe833b7f7f4f5aff3b1 Mon Sep 17 00:00:00 2001 From: David Ruisinger Date: Wed, 31 Mar 2021 21:47:28 +0200 Subject: [PATCH] Use shadow dom if available (#3749) * getDirtyPaths can now be customized by Slate users (#4012) * Moved getDirtyPaths() into the editor object so it can be customized via plugin * docs: Update document in Chinese (#4017) Co-authored-by: liuchengshuai001 * Removed unused import * Use shadowRoot if available * Removed optional chaining * Added workaround for chrom bug in ShadowDOM * Added shadow DOM example * Add a shadow DOM example Shadow DOM brings different behaviours for selection and active elements. This adds an example where the editor is found within a shadow DOM, in fact, the editor is two levels deep in nested shadow DOMs. The handling of selections means that this editor doesn't work properly so Slate will need to be made aware of the shadow DOM in order to fix this. * User DocumentOrShadowRoot for selection and active elements If the editor is within a ShadowDom, the selections and active element APIs are implemented on the ShadowRoot for Chrome. Other browsers still use the Document's version of these APIs for the shadow DOM. Instead of defaulting to `window.document`, find the appropriate root to use for the editor in question. * Add compatibility for Chrome's isCollapsed bug Chrome will always return true for isCollapsed on a selection from the shadow DOM. Work around this by instead computing this property on Chrome. https://bugs.chromium.org/p/chromium/issues/detail?id=447523 * Removed duplicated example * Fixed possible null value * Use existing PlainTextExample * Re-added local Editor to have clear initialValue * Optimize shadowRoot checkup * Remove getDocumentOrShadowRoot util in favor of findDocumentOrShadowRoot * Re-added getDocumentOrShadowRoot * Put selectionchange listener on window.document * Resetted changes from main branch * Create tiny-walls-deliver.md * Update tiny-walls-deliver.md * Update tiny-walls-deliver.md Co-authored-by: Tommy Dong Co-authored-by: Jacob <40483898+jacob-lcs@users.noreply.github.com> Co-authored-by: liuchengshuai001 Co-authored-by: Andrew Scull Co-authored-by: Ian Storm Taylor --- .changeset/tiny-walls-deliver.md | 5 ++ .../slate-react/src/components/editable.tsx | 18 +++---- .../slate-react/src/plugin/react-editor.ts | 52 ++++++++++++++++--- packages/slate-react/src/utils/dom.ts | 10 ++++ packages/slate-react/src/utils/environment.ts | 3 ++ site/examples/shadow-dom.js | 47 +++++++++++++++++ site/pages/examples/[example].tsx | 4 +- 7 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 .changeset/tiny-walls-deliver.md create mode 100644 site/examples/shadow-dom.js diff --git a/.changeset/tiny-walls-deliver.md b/.changeset/tiny-walls-deliver.md new file mode 100644 index 0000000000..db40136d04 --- /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 6c2598745f..b765bfbd1d 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 b2f8ba4600..8a449a44ab 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 feec2b18a2..860e27bdc4 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 a12cdbcaac..3d257ce1ec 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 0000000000..01f0ce6da5 --- /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 7c4c0b9ea3..d69303b5ef 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'], ]