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(