diff --git a/.changeset/blue-chefs-chew.md b/.changeset/blue-chefs-chew.md new file mode 100644 index 00000000000..06fd0b9cc61 --- /dev/null +++ b/.changeset/blue-chefs-chew.md @@ -0,0 +1,5 @@ +--- +'slate-react': patch +--- + +Update android restoreDOM to use partial dom restoring diff --git a/packages/slate-react/src/components/android/android-editable.tsx b/packages/slate-react/src/components/android/android-editable.tsx index 7ccab2ab73f..b46f7c8d7dd 100644 --- a/packages/slate-react/src/components/android/android-editable.tsx +++ b/packages/slate-react/src/components/android/android-editable.tsx @@ -14,11 +14,9 @@ import { isDOMNode, getDefaultView, getClipboardData, - isPlainTextOnlyPaste, } from '../../utils/dom' import { EDITOR_TO_ELEMENT, - EDITOR_TO_RESTORE_DOM, EDITOR_TO_WINDOW, ELEMENT_TO_NODE, IS_FOCUSED, @@ -37,6 +35,7 @@ import { } from '../editable' import { useAndroidInputManager } from './use-android-input-manager' +import { useContentKey } from '../../hooks/use-content-key' /** * Editable. @@ -72,10 +71,7 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { [] ) - const [contentKey, setContentKey] = useState(0) - const onRestoreDOM = useCallback(() => { - setContentKey(prev => prev + 1) - }, [contentKey]) + const contentKey = useContentKey(editor) // Whenever the editor updates... useIsomorphicLayoutEffect(() => { @@ -87,10 +83,8 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { EDITOR_TO_ELEMENT.set(editor, ref.current) NODE_TO_ELEMENT.set(editor, ref.current) ELEMENT_TO_NODE.set(ref.current, editor) - EDITOR_TO_RESTORE_DOM.set(editor, onRestoreDOM) } else { NODE_TO_ELEMENT.delete(editor) - EDITOR_TO_RESTORE_DOM.delete(editor) } try { diff --git a/packages/slate-react/src/components/android/android-input-manager.ts b/packages/slate-react/src/components/android/android-input-manager.ts index 3e4c751dec9..a85769778e7 100644 --- a/packages/slate-react/src/components/android/android-input-manager.ts +++ b/packages/slate-react/src/components/android/android-input-manager.ts @@ -16,7 +16,6 @@ import { isReplaceExpandedSelection, isTextInsertion, } from './mutation-detection' -import { restoreDOM } from './restore-dom' // Replace with `const debug = console.log` to debug const debug = (...message: any[]) => {} @@ -42,11 +41,13 @@ const debug = (...message: any[]) => {} * - Line breaks * * @param editor + * @param restoreDOM */ export class AndroidInputManager { - constructor(private editor: ReactEditor) { + constructor(private editor: ReactEditor, private restoreDOM: () => void) { this.editor = editor + this.restoreDOM = restoreDOM } /** @@ -65,7 +66,7 @@ export class AndroidInputManager { console.error(err) // Failed to reconcile mutations, restore DOM to its previous state - restoreDOM(this.editor) + this.restoreDOM() } } @@ -135,9 +136,7 @@ export class AndroidInputManager { Editor.insertBreak(this.editor) - // To-do: Need a more granular solution to restoring only a specific portion - // of the document. Restoring the entire document is expensive. - restoreDOM(this.editor) + this.restoreDOM() if (selection) { // Compat: Move selection to the newly inserted block if it has not moved @@ -167,7 +166,7 @@ export class AndroidInputManager { Editor.insertText(this.editor, text) } - restoreDOM(this.editor) + this.restoreDOM() } /** @@ -180,7 +179,7 @@ export class AndroidInputManager { Editor.deleteBackward(this.editor) ReactEditor.focus(this.editor) - restoreDOM(this.editor) + this.restoreDOM() } /** @@ -194,7 +193,7 @@ export class AndroidInputManager { const path = ReactEditor.findPath(this.editor, slateNode) Transforms.delete(this.editor, { at: path }) - restoreDOM(this.editor) + this.restoreDOM() } } } diff --git a/packages/slate-react/src/components/android/restore-dom.ts b/packages/slate-react/src/components/android/restore-dom.ts deleted file mode 100644 index 9c7bbe7dd5d..00000000000 --- a/packages/slate-react/src/components/android/restore-dom.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ReactEditor } from '../..' -import { EDITOR_TO_RESTORE_DOM } from '../../utils/weak-maps' - -export function restoreDOM(editor: ReactEditor) { - try { - const onRestoreDOM = EDITOR_TO_RESTORE_DOM.get(editor) - if (onRestoreDOM) { - onRestoreDOM() - } - } catch (err) { - // eslint-disable-next-line no-console - console.error(err) - } -} diff --git a/packages/slate-react/src/components/android/use-android-input-manager.ts b/packages/slate-react/src/components/android/use-android-input-manager.ts index 7b48411bf18..ea93cdcf7f7 100644 --- a/packages/slate-react/src/components/android/use-android-input-manager.ts +++ b/packages/slate-react/src/components/android/use-android-input-manager.ts @@ -1,8 +1,9 @@ -import { RefObject, useCallback, useRef, useState } from 'react' +import { RefObject, useCallback, useMemo, useRef, useState } from 'react' import { useSlateStatic } from '../../hooks/use-slate-static' import { AndroidInputManager } from './android-input-manager' +import { useRestoreDom } from './use-restore-dom' import { useMutationObserver } from './use-mutation-observer' import { useTrackUserInput } from './use-track-user-input' @@ -15,8 +16,15 @@ const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { export function useAndroidInputManager(node: RefObject) { const editor = useSlateStatic() - const [inputManager] = useState(() => new AndroidInputManager(editor)) + const { receivedUserInput, onUserInput } = useTrackUserInput() + const restoreDom = useRestoreDom(node, receivedUserInput) + + const inputManager = useMemo( + () => new AndroidInputManager(editor, restoreDom), + [restoreDom, editor] + ) + const timeoutId = useRef | null>(null) const isReconciling = useRef(false) const flush = useCallback((mutations: MutationRecord[]) => { diff --git a/packages/slate-react/src/components/android/use-restore-dom.tsx b/packages/slate-react/src/components/android/use-restore-dom.tsx new file mode 100644 index 00000000000..287fe392d9f --- /dev/null +++ b/packages/slate-react/src/components/android/use-restore-dom.tsx @@ -0,0 +1,83 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import { Node as SlateNode, Path } from 'slate' +import { ReactEditor, useSlateStatic } from '../..' +import { DOMNode, isDOMElement } from '../../utils/dom' +import { ELEMENT_TO_NODE, NODE_TO_RESTORE_DOM } from '../../utils/weak-maps' +import { useMutationObserver } from './use-mutation-observer' + +const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { + childList: true, + characterData: true, + subtree: true, +} + +function findClosestKnowSlateNode(domNode: DOMNode): SlateNode | null { + let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement + + if (domEl && !domEl.hasAttribute('data-slate-node')) { + domEl = domEl.closest(`[data-slate-node]`) + } + + const slateNode = domEl && ELEMENT_TO_NODE.get(domEl as HTMLElement) + if (slateNode) { + return slateNode + } + + // Unknown dom element with a slate-slate-node attribute => the IME + // most likely duplicated the node so we have to restore the parent + return domEl?.parentElement + ? findClosestKnowSlateNode(domEl.parentElement) + : null +} + +export function useRestoreDom( + node: React.RefObject, + receivedUserInput: React.RefObject +) { + const editor = useSlateStatic() + const mutatedNodes = useRef>(new Set()) + + const handleDOMMutation = useCallback((mutations: MutationRecord[]) => { + if (!receivedUserInput.current && false) { + return + } + + mutations.forEach(({ target }) => { + const slateNode = findClosestKnowSlateNode(target) + if (!slateNode) { + return + } + + return mutatedNodes.current.add(slateNode) + }) + }, []) + + useMutationObserver(node, handleDOMMutation, MUTATION_OBSERVER_CONFIG) + + useEffect(() => { + // Clear mutated nodes on every render + mutatedNodes.current.clear() + }) + + const restore = useCallback(() => { + const mutated = Array.from(mutatedNodes.current.values()) + + // Filter out child nodes of nodes that will be restored anyway + const nodesToRestore = mutated.filter( + n => + !mutated.some(m => + Path.isParent( + ReactEditor.findPath(editor, m), + ReactEditor.findPath(editor, n) + ) + ) + ) + + nodesToRestore.forEach(n => { + // Force node to re-render + NODE_TO_RESTORE_DOM.get(n)?.() + }) + }, []) + + return restore +} diff --git a/packages/slate-react/src/components/element.tsx b/packages/slate-react/src/components/element.tsx index 20a50f61242..f77e2db1994 100644 --- a/packages/slate-react/src/components/element.tsx +++ b/packages/slate-react/src/components/element.tsx @@ -1,6 +1,6 @@ -import React, { useRef } from 'react' +import React, { Fragment, useRef } from 'react' import getDirection from 'direction' -import { Editor, Node, Range, NodeEntry, Element as SlateElement } from 'slate' +import { Editor, Node, Range, Element as SlateElement } from 'slate' import Text from './text' import useChildren from '../hooks/use-children' @@ -19,6 +19,8 @@ import { RenderLeafProps, RenderPlaceholderProps, } from './editable' +import { useContentKey } from '../hooks/use-content-key' +import { IS_ANDROID } from '../utils/environment' /** * Element. @@ -131,7 +133,14 @@ const Element = (props: { } }) - return renderElement({ attributes, children, element }) + const content = renderElement({ attributes, children, element }) + + if (IS_ANDROID) { + const contentKey = useContentKey(element) + return {content} + } + + return content } const MemoizedElement = React.memo(Element, (prev, next) => { diff --git a/packages/slate-react/src/components/text.tsx b/packages/slate-react/src/components/text.tsx index 6d7fe8d1b8d..0d4abaae758 100644 --- a/packages/slate-react/src/components/text.tsx +++ b/packages/slate-react/src/components/text.tsx @@ -11,6 +11,8 @@ import { EDITOR_TO_KEY_TO_ELEMENT, } from '../utils/weak-maps' import { isDecoratorRangeListEqual } from '../utils/range-list' +import { useContentKey } from '../hooks/use-content-key' +import { IS_ANDROID } from '../utils/environment' /** * Text. @@ -67,8 +69,10 @@ const Text = (props: { } }) + const contentKey = IS_ANDROID ? useContentKey(text) : undefined + return ( - + {children} ) diff --git a/packages/slate-react/src/hooks/use-children.tsx b/packages/slate-react/src/hooks/use-children.tsx index f4b4ae1f9e6..4153e71c327 100644 --- a/packages/slate-react/src/hooks/use-children.tsx +++ b/packages/slate-react/src/hooks/use-children.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Editor, Range, Element, NodeEntry, Ancestor, Descendant } from 'slate' +import { Editor, Range, Element, Ancestor, Descendant } from 'slate' import ElementComponent from '../components/element' import TextComponent from '../components/text' diff --git a/packages/slate-react/src/hooks/use-content-key.ts b/packages/slate-react/src/hooks/use-content-key.ts new file mode 100644 index 00000000000..6c732ba9520 --- /dev/null +++ b/packages/slate-react/src/hooks/use-content-key.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from 'react' +import { Node as SlateNode } from 'slate' +import { NODE_TO_RESTORE_DOM } from '../utils/weak-maps' + +export function useContentKey(node: SlateNode) { + const contentKeyRef = useRef(0) + const updateTimeoutRef = useRef>() + + const [, setForceRerenderCounter] = useState(0) + + useEffect(() => { + NODE_TO_RESTORE_DOM.set(node, () => { + // Only force a re-render if the component isn't re-rendered this tick in order + // to avoid rendering it twice. + updateTimeoutRef.current = setTimeout(() => { + setForceRerenderCounter(state => state + 1) + updateTimeoutRef.current = undefined + }, 0) + + contentKeyRef.current++ + }) + + return () => { + NODE_TO_RESTORE_DOM.delete(node) + } + }, [node]) + + useEffect(() => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current) + updateTimeoutRef.current = undefined + } + }) + + return contentKeyRef.current +} diff --git a/packages/slate-react/src/utils/weak-maps.ts b/packages/slate-react/src/utils/weak-maps.ts index 955dfb2d74b..80454804847 100644 --- a/packages/slate-react/src/utils/weak-maps.ts +++ b/packages/slate-react/src/utils/weak-maps.ts @@ -1,5 +1,4 @@ -import { Node, Ancestor, Editor, Range } from 'slate' - +import { Ancestor, Editor, Node } from 'slate' import { Key } from './key' /** @@ -40,7 +39,7 @@ export const IS_CLICKING: WeakMap = new WeakMap() export const EDITOR_TO_ON_CHANGE = new WeakMap void>() -export const EDITOR_TO_RESTORE_DOM = new WeakMap void>() +export const NODE_TO_RESTORE_DOM = new WeakMap void>() /** * Symbols.