mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-26 16:44:22 +02:00
Update android restoreDOM to allow partial dom restoring (#4706)
* Update android restoreDOM to allow partial dom restoring * Fix useContentKey re-render timing
This commit is contained in:
5
.changeset/blue-chefs-chew.md
Normal file
5
.changeset/blue-chefs-chew.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'slate-react': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Update android restoreDOM to use partial dom restoring
|
@@ -14,11 +14,9 @@ import {
|
|||||||
isDOMNode,
|
isDOMNode,
|
||||||
getDefaultView,
|
getDefaultView,
|
||||||
getClipboardData,
|
getClipboardData,
|
||||||
isPlainTextOnlyPaste,
|
|
||||||
} from '../../utils/dom'
|
} from '../../utils/dom'
|
||||||
import {
|
import {
|
||||||
EDITOR_TO_ELEMENT,
|
EDITOR_TO_ELEMENT,
|
||||||
EDITOR_TO_RESTORE_DOM,
|
|
||||||
EDITOR_TO_WINDOW,
|
EDITOR_TO_WINDOW,
|
||||||
ELEMENT_TO_NODE,
|
ELEMENT_TO_NODE,
|
||||||
IS_FOCUSED,
|
IS_FOCUSED,
|
||||||
@@ -37,6 +35,7 @@ import {
|
|||||||
} from '../editable'
|
} from '../editable'
|
||||||
|
|
||||||
import { useAndroidInputManager } from './use-android-input-manager'
|
import { useAndroidInputManager } from './use-android-input-manager'
|
||||||
|
import { useContentKey } from '../../hooks/use-content-key'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editable.
|
* Editable.
|
||||||
@@ -72,10 +71,7 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [contentKey, setContentKey] = useState(0)
|
const contentKey = useContentKey(editor)
|
||||||
const onRestoreDOM = useCallback(() => {
|
|
||||||
setContentKey(prev => prev + 1)
|
|
||||||
}, [contentKey])
|
|
||||||
|
|
||||||
// Whenever the editor updates...
|
// Whenever the editor updates...
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
@@ -87,10 +83,8 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
EDITOR_TO_ELEMENT.set(editor, ref.current)
|
EDITOR_TO_ELEMENT.set(editor, ref.current)
|
||||||
NODE_TO_ELEMENT.set(editor, ref.current)
|
NODE_TO_ELEMENT.set(editor, ref.current)
|
||||||
ELEMENT_TO_NODE.set(ref.current, editor)
|
ELEMENT_TO_NODE.set(ref.current, editor)
|
||||||
EDITOR_TO_RESTORE_DOM.set(editor, onRestoreDOM)
|
|
||||||
} else {
|
} else {
|
||||||
NODE_TO_ELEMENT.delete(editor)
|
NODE_TO_ELEMENT.delete(editor)
|
||||||
EDITOR_TO_RESTORE_DOM.delete(editor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@@ -16,7 +16,6 @@ import {
|
|||||||
isReplaceExpandedSelection,
|
isReplaceExpandedSelection,
|
||||||
isTextInsertion,
|
isTextInsertion,
|
||||||
} from './mutation-detection'
|
} from './mutation-detection'
|
||||||
import { restoreDOM } from './restore-dom'
|
|
||||||
|
|
||||||
// Replace with `const debug = console.log` to debug
|
// Replace with `const debug = console.log` to debug
|
||||||
const debug = (...message: any[]) => {}
|
const debug = (...message: any[]) => {}
|
||||||
@@ -42,11 +41,13 @@ const debug = (...message: any[]) => {}
|
|||||||
* - Line breaks
|
* - Line breaks
|
||||||
*
|
*
|
||||||
* @param editor
|
* @param editor
|
||||||
|
* @param restoreDOM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class AndroidInputManager {
|
export class AndroidInputManager {
|
||||||
constructor(private editor: ReactEditor) {
|
constructor(private editor: ReactEditor, private restoreDOM: () => void) {
|
||||||
this.editor = editor
|
this.editor = editor
|
||||||
|
this.restoreDOM = restoreDOM
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,7 +66,7 @@ export class AndroidInputManager {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
||||||
// Failed to reconcile mutations, restore DOM to its previous state
|
// 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)
|
Editor.insertBreak(this.editor)
|
||||||
|
|
||||||
// To-do: Need a more granular solution to restoring only a specific portion
|
this.restoreDOM()
|
||||||
// of the document. Restoring the entire document is expensive.
|
|
||||||
restoreDOM(this.editor)
|
|
||||||
|
|
||||||
if (selection) {
|
if (selection) {
|
||||||
// Compat: Move selection to the newly inserted block if it has not moved
|
// 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)
|
Editor.insertText(this.editor, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreDOM(this.editor)
|
this.restoreDOM()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,7 +179,7 @@ export class AndroidInputManager {
|
|||||||
Editor.deleteBackward(this.editor)
|
Editor.deleteBackward(this.editor)
|
||||||
ReactEditor.focus(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)
|
const path = ReactEditor.findPath(this.editor, slateNode)
|
||||||
|
|
||||||
Transforms.delete(this.editor, { at: path })
|
Transforms.delete(this.editor, { at: path })
|
||||||
restoreDOM(this.editor)
|
this.restoreDOM()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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 { useSlateStatic } from '../../hooks/use-slate-static'
|
||||||
|
|
||||||
import { AndroidInputManager } from './android-input-manager'
|
import { AndroidInputManager } from './android-input-manager'
|
||||||
|
import { useRestoreDom } from './use-restore-dom'
|
||||||
import { useMutationObserver } from './use-mutation-observer'
|
import { useMutationObserver } from './use-mutation-observer'
|
||||||
import { useTrackUserInput } from './use-track-user-input'
|
import { useTrackUserInput } from './use-track-user-input'
|
||||||
|
|
||||||
@@ -15,8 +16,15 @@ const MUTATION_OBSERVER_CONFIG: MutationObserverInit = {
|
|||||||
|
|
||||||
export function useAndroidInputManager(node: RefObject<HTMLElement>) {
|
export function useAndroidInputManager(node: RefObject<HTMLElement>) {
|
||||||
const editor = useSlateStatic()
|
const editor = useSlateStatic()
|
||||||
const [inputManager] = useState(() => new AndroidInputManager(editor))
|
|
||||||
const { receivedUserInput, onUserInput } = useTrackUserInput()
|
const { receivedUserInput, onUserInput } = useTrackUserInput()
|
||||||
|
const restoreDom = useRestoreDom(node, receivedUserInput)
|
||||||
|
|
||||||
|
const inputManager = useMemo(
|
||||||
|
() => new AndroidInputManager(editor, restoreDom),
|
||||||
|
[restoreDom, editor]
|
||||||
|
)
|
||||||
|
|
||||||
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const isReconciling = useRef(false)
|
const isReconciling = useRef(false)
|
||||||
const flush = useCallback((mutations: MutationRecord[]) => {
|
const flush = useCallback((mutations: MutationRecord[]) => {
|
||||||
|
@@ -0,0 +1,81 @@
|
|||||||
|
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<HTMLElement>,
|
||||||
|
receivedUserInput: React.RefObject<boolean>
|
||||||
|
) {
|
||||||
|
const editor = useSlateStatic()
|
||||||
|
const mutatedNodes = useRef<Set<SlateNode>>(new Set())
|
||||||
|
|
||||||
|
const handleDOMMutation = useCallback((mutations: MutationRecord[]) => {
|
||||||
|
if (!receivedUserInput.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mutations.forEach(({ target }) => {
|
||||||
|
const slateNode = findClosestKnowSlateNode(target)
|
||||||
|
if (!slateNode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutatedNodes.current.add(slateNode)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useMutationObserver(node, handleDOMMutation, MUTATION_OBSERVER_CONFIG)
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
NODE_TO_RESTORE_DOM.get(n)?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
mutatedNodes.current.clear()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return restore
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef } from 'react'
|
import React, { Fragment, useRef } from 'react'
|
||||||
import getDirection from 'direction'
|
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 Text from './text'
|
||||||
import useChildren from '../hooks/use-children'
|
import useChildren from '../hooks/use-children'
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
RenderLeafProps,
|
RenderLeafProps,
|
||||||
RenderPlaceholderProps,
|
RenderPlaceholderProps,
|
||||||
} from './editable'
|
} from './editable'
|
||||||
|
import { useContentKey } from '../hooks/use-content-key'
|
||||||
|
import { IS_ANDROID } from '../utils/environment'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Element.
|
* 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 <Fragment key={contentKey}>{content}</Fragment>
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
const MemoizedElement = React.memo(Element, (prev, next) => {
|
const MemoizedElement = React.memo(Element, (prev, next) => {
|
||||||
|
@@ -11,6 +11,8 @@ import {
|
|||||||
EDITOR_TO_KEY_TO_ELEMENT,
|
EDITOR_TO_KEY_TO_ELEMENT,
|
||||||
} from '../utils/weak-maps'
|
} from '../utils/weak-maps'
|
||||||
import { isDecoratorRangeListEqual } from '../utils/range-list'
|
import { isDecoratorRangeListEqual } from '../utils/range-list'
|
||||||
|
import { useContentKey } from '../hooks/use-content-key'
|
||||||
|
import { IS_ANDROID } from '../utils/environment'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text.
|
* Text.
|
||||||
@@ -67,8 +69,10 @@ const Text = (props: {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const contentKey = IS_ANDROID ? useContentKey(text) : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span data-slate-node="text" ref={ref}>
|
<span data-slate-node="text" ref={ref} key={contentKey}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
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 ElementComponent from '../components/element'
|
||||||
import TextComponent from '../components/text'
|
import TextComponent from '../components/text'
|
||||||
|
38
packages/slate-react/src/hooks/use-content-key.ts
Normal file
38
packages/slate-react/src/hooks/use-content-key.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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<number>(0)
|
||||||
|
const updateAnimationFrameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const [, setForceRerenderCounter] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
NODE_TO_RESTORE_DOM.set(node, () => {
|
||||||
|
// Update is already queued and node hasn't re-render yet
|
||||||
|
if (updateAnimationFrameRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimationFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
setForceRerenderCounter(state => state + 1)
|
||||||
|
updateAnimationFrameRef.current = null
|
||||||
|
})
|
||||||
|
|
||||||
|
contentKeyRef.current++
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
NODE_TO_RESTORE_DOM.delete(node)
|
||||||
|
}
|
||||||
|
}, [node])
|
||||||
|
|
||||||
|
// Node was restored => clear scheduled update
|
||||||
|
if (updateAnimationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(updateAnimationFrameRef.current)
|
||||||
|
updateAnimationFrameRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentKeyRef.current
|
||||||
|
}
|
@@ -1,5 +1,4 @@
|
|||||||
import { Node, Ancestor, Editor, Range } from 'slate'
|
import { Ancestor, Editor, Node } from 'slate'
|
||||||
|
|
||||||
import { Key } from './key'
|
import { Key } from './key'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +39,7 @@ export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()
|
|||||||
|
|
||||||
export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()
|
export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()
|
||||||
|
|
||||||
export const EDITOR_TO_RESTORE_DOM = new WeakMap<Editor, () => void>()
|
export const NODE_TO_RESTORE_DOM = new WeakMap<Node, () => void>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Symbols.
|
* Symbols.
|
||||||
|
Reference in New Issue
Block a user