Skip to content

Commit

Permalink
Use shadow dom if available (#3749)
Browse files Browse the repository at this point in the history
* 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 <liuchengshuai001@ke.com>

* 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 <contact@tomdong.io>
Co-authored-by: Jacob <40483898+jacob-lcs@users.noreply.github.com>
Co-authored-by: liuchengshuai001 <liuchengshuai001@ke.com>
Co-authored-by: Andrew Scull <andrew.scull@live.com>
Co-authored-by: Ian Storm Taylor <ian@ianstormtaylor.com>
  • Loading branch information
6 people authored Mar 31, 2021
1 parent 42d99af commit 0473d0b
Show file tree
Hide file tree
Showing 7 changed files with 120 additions and 19 deletions.
5 changes: 5 additions & 0 deletions .changeset/tiny-walls-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate-react': patch
---

Fixes Slate to work with the Shadow DOM.
18 changes: 9 additions & 9 deletions packages/slate-react/src/components/editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
getDefaultView,
isDOMElement,
isDOMNode,
isDOMText,
DOMStaticRange,
isPlainTextOnlyPaste,
} from '../utils/dom'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
52 changes: 44 additions & 8 deletions packages/slate-react/src/plugin/react-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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()
}
},
Expand All @@ -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 })
}
},
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/slate-react/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions packages/slate-react/src/utils/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down
47 changes: 47 additions & 0 deletions site/examples/shadow-dom.js
Original file line number Diff line number Diff line change
@@ -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(<ShadowEditor />, reactRoot)
})

return <div ref={container} />
}

const ShadowEditor = () => {
const [value, setValue] = useState(initialValue)
const editor = useMemo(() => withHistory(withReact(createEditor())), [])

return (
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
<Editable placeholder="Enter some plain text..." />
</Slate>
)
}

const initialValue = [
{
children: [{ text: 'This Editor is rendered within a nested Shadow DOM.' }],
},
]

export default ShadowDOM
4 changes: 2 additions & 2 deletions site/pages/examples/[example].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'],
]
Expand Down

0 comments on commit 0473d0b

Please sign in to comment.