diff --git a/packages/slate-react/src/components/android/android-editable.tsx b/packages/slate-react/src/components/android/android-editable.tsx index 6201767033..c25c8d7f92 100644 --- a/packages/slate-react/src/components/android/android-editable.tsx +++ b/packages/slate-react/src/components/android/android-editable.tsx @@ -25,17 +25,18 @@ import { NODE_TO_ELEMENT, PLACEHOLDER_SYMBOL, } from '../../utils/weak-maps' -import { AndroidInputManager } from './android-input-manager' import { EditableProps } from '../editable' -import { ErrorBoundary } from './ErrorBoundary' import useChildren from '../../hooks/use-children' import { defaultDecorate, hasEditableTarget, isEventHandled, + isDOMEventHandled, isTargetInsideVoid, } from '../editable' +import { useAndroidInputManager } from './use-android-input-manager' + export const AndroidEditableNoError = (props: EditableProps): JSX.Element => { return ( @@ -64,7 +65,7 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { } = props const editor = useSlate() const ref = useRef(null) - const inputManager = useMemo(() => new AndroidInputManager(editor), [editor]) + const inputManager = useAndroidInputManager(ref) // Update internal state on each render. IS_READ_ONLY.set(editor, readOnly) @@ -72,32 +73,22 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { // Keep track of some state for the event handler logic. const state = useMemo( () => ({ - isComposing: false, isUpdatingSelection: false, latestElement: null as DOMElement | null, }), [] ) - useEffect(() => { - return () => { - inputManager.onWillUnmount() - } - }, []) - - const prevValue = useRef([]) - - // To-do: updating refs during render phase will eventually be unusafe - // in future versions of React https://github.com/facebook/react/pull/18545 - if (prevValue.current !== editor.children) { - inputManager.onRender() - prevValue.current = editor.children - } + const [contentKey, setContentKey] = useState(0) + const onRestoreDOM = useCallback(() => { + setContentKey(prev => prev + 1) + }, [contentKey]) // Whenever the editor updates... useIsomorphicLayoutEffect(() => { // Update element-related weak maps with the DOM element ref. let window + if (ref.current && (window = getDefaultView(ref.current))) { EDITOR_TO_WINDOW.set(editor, window) EDITOR_TO_ELEMENT.set(editor, ref.current) @@ -109,95 +100,97 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { EDITOR_TO_RESTORE_DOM.delete(editor) } - // Let the input manager know that the editor has re-rendered - inputManager.onDidUpdate() + try { + // Make sure the DOM selection state is in sync. + const { selection } = editor + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const domSelection = root.getSelection() - // Make sure the DOM selection state is in sync. - const { selection } = editor - const root = ReactEditor.findDocumentOrShadowRoot(editor) - const domSelection = root.getSelection() + if (!domSelection || !ReactEditor.isFocused(editor)) { + return + } - if (state.isComposing || !domSelection || !ReactEditor.isFocused(editor)) { - return - } + const hasDomSelection = domSelection.type !== 'None' - const hasDomSelection = domSelection.type !== 'None' + // If the DOM selection is properly unset, we're done. + if (!selection && !hasDomSelection) { + return + } - // If the DOM selection is properly unset, we're done. - if (!selection && !hasDomSelection) { - return - } + // verify that the dom selection is in the editor + const editorElement = EDITOR_TO_ELEMENT.get(editor)! + let hasDomSelectionInEditor = false + if ( + editorElement.contains(domSelection.anchorNode) && + editorElement.contains(domSelection.focusNode) + ) { + hasDomSelectionInEditor = true + } - // verify that the dom selection is in the editor - const editorElement = EDITOR_TO_ELEMENT.get(editor)! - let hasDomSelectionInEditor = false - if ( - editorElement.contains(domSelection.anchorNode) && - editorElement.contains(domSelection.focusNode) - ) { - hasDomSelectionInEditor = true - } + // If the DOM selection is in the editor and the editor selection is already correct, we're done. + if (hasDomSelection && hasDomSelectionInEditor && selection) { + const slateRange = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: true, + }) + if (slateRange && Range.equals(slateRange, selection)) { + return + } + } - // If the DOM selection is in the editor and the editor selection is already correct, we're done. - if (hasDomSelection && hasDomSelectionInEditor && selection) { - const slateRange = ReactEditor.toSlateRange(editor, domSelection, { - exactMatch: true, - }) - if (slateRange && Range.equals(slateRange, selection)) { + // when is being controlled through external value + // then its children might just change - DOM responds to it on its own + // but Slate's value is not being updated through any operation + // and thus it doesn't transform selection on its own + if (selection && !ReactEditor.hasRange(editor, selection)) { + editor.selection = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + }) return } - } - - // when is being controlled through external value - // then its children might just change - DOM responds to it on its own - // but Slate's value is not being updated through any operation - // and thus it doesn't transform selection on its own - if (selection && !ReactEditor.hasRange(editor, selection)) { - editor.selection = ReactEditor.toSlateRange(editor, domSelection, { - exactMatch: false, - }) - return - } - // Otherwise the DOM selection is out of sync, so update it. - const el = ReactEditor.toDOMNode(editor, editor) - state.isUpdatingSelection = true + // Otherwise the DOM selection is out of sync, so update it. + const el = ReactEditor.toDOMNode(editor, editor) + state.isUpdatingSelection = true - const newDomRange = selection && ReactEditor.toDOMRange(editor, selection) + const newDomRange = selection && ReactEditor.toDOMRange(editor, selection) - if (newDomRange) { - if (Range.isBackward(selection!)) { - domSelection.setBaseAndExtent( - newDomRange.endContainer, - newDomRange.endOffset, - newDomRange.startContainer, - newDomRange.startOffset + if (newDomRange) { + if (Range.isBackward(selection!)) { + domSelection.setBaseAndExtent( + newDomRange.endContainer, + newDomRange.endOffset, + newDomRange.startContainer, + newDomRange.startOffset + ) + } else { + domSelection.setBaseAndExtent( + newDomRange.startContainer, + newDomRange.startOffset, + newDomRange.endContainer, + newDomRange.endOffset + ) + } + const leafEl = newDomRange.startContainer.parentElement! + leafEl.getBoundingClientRect = newDomRange.getBoundingClientRect.bind( + newDomRange ) + scrollIntoView(leafEl, { + scrollMode: 'if-needed', + boundary: el, + }) + // @ts-ignore + delete leafEl.getBoundingClientRect } else { - domSelection.setBaseAndExtent( - newDomRange.startContainer, - newDomRange.startOffset, - newDomRange.endContainer, - newDomRange.endOffset - ) + domSelection.removeAllRanges() } - const leafEl = newDomRange.startContainer.parentElement! - leafEl.getBoundingClientRect = newDomRange.getBoundingClientRect.bind( - newDomRange - ) - scrollIntoView(leafEl, { - scrollMode: 'if-needed', - boundary: el, - }) - // @ts-ignore - delete leafEl.getBoundingClientRect - } else { - domSelection.removeAllRanges() - } - setTimeout(() => { + setTimeout(() => { + state.isUpdatingSelection = false + }) + } catch { + // Failed to update selection, likely due to reconciliation error state.isUpdatingSelection = false - }) + } }) // The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it @@ -208,6 +201,34 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { } }, [autoFocus]) + // Listen on the native `beforeinput` event to get real "Level 2" events. This + // is required because React's `beforeinput` is fake and never really attaches + // to the real event sadly. (2019/11/01) + // https://github.com/facebook/react/issues/11211 + const onDOMBeforeInput = useCallback( + (event: InputEvent) => { + if ( + !readOnly && + hasEditableTarget(editor, event.target) && + !isDOMEventHandled(event, propsOnDOMBeforeInput) + ) { + // no-op, parity with `editable.tsx` + } + }, + [readOnly, propsOnDOMBeforeInput] + ) + + // Attach a native DOM event handler for `beforeinput` events, because React's + // built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose + // real `beforeinput` events sadly... (2019/11/04) + useIsomorphicLayoutEffect(() => { + const node = ref.current + + node?.addEventListener('beforeinput', onDOMBeforeInput) + + return () => node?.removeEventListener('beforeinput', onDOMBeforeInput) + }, [contentKey, propsOnDOMBeforeInput]) + // Listen on the native `selectionchange` event to be able to update any time // the selection changes. This is required because React's `onSelect` is leaky // and non-standard so it doesn't fire until after a selection has been @@ -215,9 +236,7 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { // while a selection is being dragged. const onDOMSelectionChange = useCallback( throttle(() => { - if (!readOnly && !state.isComposing && !state.isUpdatingSelection) { - inputManager.onSelect() - + if (!readOnly && !state.isUpdatingSelection) { const root = ReactEditor.findDocumentOrShadowRoot(editor) const { activeElement } = root const el = ReactEditor.toDOMNode(editor, editor) @@ -291,12 +310,6 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { }) } - const [contentKey, setContentKey] = useState(0) - - const onRestoreDOM = useCallback(() => { - setContentKey(prev => prev + 1) - }, [contentKey]) - return ( @@ -324,32 +337,6 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => { // Allow for passed-in styles to override anything. ...style, }} - onCompositionEnd={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionEnd) - ) { - state.isComposing = false - - inputManager.onCompositionEnd() - } - }, - [attributes.onCompositionEnd] - )} - onCompositionStart={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionStart) - ) { - state.isComposing = true - - inputManager.onCompositionStart() - } - }, - [attributes.onCompositionStart] - )} onCopy={useCallback( (event: React.ClipboardEvent) => { if ( 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 73e265a1c6..e483aa82ef 100644 --- a/packages/slate-react/src/components/android/android-input-manager.ts +++ b/packages/slate-react/src/components/android/android-input-manager.ts @@ -1,78 +1,26 @@ import { ReactEditor } from '../../plugin/react-editor' -import { Editor, Node as SlateNode, Path, Range, Transforms } from 'slate' -import { Diff, diffText } from './diff-text' +import { Editor, Range, Transforms } from 'slate' + import { DOMNode } from '../../utils/dom' -import { - EDITOR_TO_ON_CHANGE, - EDITOR_TO_RESTORE_DOM, -} from '../../utils/weak-maps' +import { + normalizeTextInsertionRange, + combineInsertedText, + TextInsertion, +} from './diff-text' +import { + gatherMutationData, + isDeletion, + isLineBreak, + isRemoveLeafNodes, + isReplaceExpandedSelection, + isTextInsertion, +} from './mutation-detection' +import { restoreDOM } from './restore-dom' + +// Replace with `const debug = console.log` to debug const debug = (...message: any[]) => {} -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) - } -} - -function flushController(editor: ReactEditor): void { - try { - const onChange = EDITOR_TO_ON_CHANGE.get(editor) - if (onChange) { - onChange() - } - } catch (err) { - // eslint-disable-next-line no-console - console.error(err) - } -} - -function renderSync(editor: ReactEditor, fn: () => void) { - try { - fn() - flushController(editor) - } catch (err) { - // eslint-disable-next-line no-console - console.error(err) - } -} - -/** - * Takes text from a dom node and an offset within that text and returns an - * object with fixed text and fixed offset which removes zero width spaces - * and adjusts the offset. - * - * Optionally, if an `isLastNode` argument is passed in, it will also remove - * a trailing newline. - */ - -function fixTextAndOffset( - prevText: string, - prevOffset = 0, - isLastNode = false -) { - let nextOffset = prevOffset - let nextText = prevText - - // remove the last newline if we are in the last node of a block - const lastChar = nextText.charAt(nextText.length - 1) - - if (isLastNode && lastChar === '\n') { - nextText = nextText.slice(0, -1) - } - - const maxOffset = nextText.length - - if (nextOffset > maxOffset) nextOffset = maxOffset - return { text: nextText, offset: nextOffset } -} - /** * Based loosely on: * @@ -86,468 +34,143 @@ function fixTextAndOffset( */ export class AndroidInputManager { - /** - * A MutationObserver that flushes to the method `flush` - */ - private readonly observer: MutationObserver - - private rootEl?: HTMLElement = undefined - - /** - * Object that keeps track of the most recent state - */ - - private lastPath?: Path = undefined - private lastDiff?: Diff = undefined - private lastRange?: Range = undefined - private lastDomNode?: Node = undefined - constructor(private editor: ReactEditor) { - this.observer = new MutationObserver(this.flush) - } - - onDidUpdate = () => { - this.connect() + this.editor = editor } /** - * Connect the MutationObserver to a specific editor root element + * Handle MutationObserver flush + * + * @param mutations */ - connect = () => { - debug('connect') - - const rootEl = ReactEditor.toDOMNode(this.editor, this.editor) - if (this.rootEl === rootEl) return - this.rootEl = rootEl - - debug('connect:run') - - this.observer.disconnect() - this.observer.observe(rootEl, { - childList: true, - characterData: true, - subtree: true, - characterDataOldValue: true, - }) - } - - onWillUnmount = () => { - this.disconnect() - } - - disconnect = () => { - debug('disconnect') - this.observer.disconnect() - this.rootEl = undefined - } - - onRender = () => { - this.disconnect() - this.clearDiff() - } - - private clearDiff = () => { - debug('clearDiff') - this.bufferedMutations.length = 0 - this.lastPath = undefined - this.lastDiff = undefined - } - - /** - * Clear the `last` properties related to an action only - */ + flush = (mutations: MutationRecord[]) => { + debug('flush') - private clearAction = () => { - debug('clearAction') + try { + this.reconcileMutations(mutations) + } catch (err) { + // eslint-disable-next-line no-console + console.error(err) - this.bufferedMutations.length = 0 - this.lastDiff = undefined - this.lastDomNode = undefined + // Failed to reconcile mutations, restore DOM to its previous state + restoreDOM(this.editor) + } } /** - * Apply the last `diff` + * Reconcile a batch of mutations * - * We don't want to apply the `diff` at the time it is created because we - * may be in a composition. There are a few things that trigger the applying - * of the saved diff. Sometimes on its own and sometimes immediately before - * doing something else with the Editor. - * - * - `onCompositionEnd` event - * - `onSelect` event only when the user has moved into a different node - * - The user hits `enter` - * - The user hits `backspace` and removes an inline node - * - The user hits `backspace` and merges two blocks + * @param mutations */ - private applyDiff = () => { - debug('applyDiff') - if (this.lastPath === undefined || this.lastDiff === undefined) return - debug('applyDiff:run') - const range: Range = { - anchor: { path: this.lastPath, offset: this.lastDiff.start }, - focus: { path: this.lastPath, offset: this.lastDiff.end }, + private reconcileMutations = (mutations: MutationRecord[]) => { + const mutationData = gatherMutationData(this.editor, mutations) + const { insertedText, removedNodes } = mutationData + + debug('processMutations', mutations, mutationData) + + if (isReplaceExpandedSelection(this.editor, mutationData)) { + const text = combineInsertedText(insertedText) + this.replaceExpandedSelection(text) + } else if (isLineBreak(this.editor, mutationData)) { + this.insertBreak() + } else if (isRemoveLeafNodes(this.editor, mutationData)) { + this.removeLeafNodes(removedNodes) + } else if (isDeletion(this.editor, mutationData)) { + this.deleteBackward() + } else if (isTextInsertion(this.editor, mutationData)) { + this.insertText(insertedText) } - - Transforms.insertText(this.editor, this.lastDiff.insertText, { at: range }) } /** - * Handle `enter` that splits block + * Apply text diff */ - private splitBlock = () => { - debug('splitBlock') - - renderSync(this.editor, () => { - this.applyDiff() - - Transforms.splitNodes(this.editor, { always: true }) - ReactEditor.focus(this.editor) - - this.clearAction() - restoreDOM(this.editor) - flushController(this.editor) - }) - } + private insertText = (insertedText: TextInsertion[]) => { + debug('insertText') - /** - * Handle `backspace` that merges blocks - */ + const { selection } = this.editor - private mergeBlock = () => { - debug('mergeBlock') - - /** - * The delay is required because hitting `enter`, `enter` then `backspace` - * in a word results in the cursor being one position to the right in - * Android 9. - * - * Slate sets the position to `0` and we even check it immediately after - * setting it and it is correct, but somewhere Android moves it to the right. - * - * This happens only when using the virtual keyboard. Hitting enter on a - * hardware keyboard does not trigger this bug. - * - * The call to `focus` is required because when we switch examples then - * merge a block, we lose focus in Android 9 (possibly others). - */ - - window.requestAnimationFrame(() => { - renderSync(this.editor, () => { - this.applyDiff() - - Transforms.select(this.editor, this.lastRange!) - Editor.deleteBackward(this.editor) - ReactEditor.focus(this.editor) - - this.clearAction() - restoreDOM(this.editor) - flushController(this.editor) + // Insert the batched text diffs + insertedText.forEach(insertion => { + Transforms.insertText(this.editor, insertion.text.insertText, { + at: normalizeTextInsertionRange(this.editor, selection, insertion), }) }) } /** - * The requestId used to the save selection - */ - - private onSelectTimeoutId: number | null = null - private bufferedMutations: MutationRecord[] = [] - private startActionFrameId: number | null = null - private isFlushing = false - - /** - * Mark the beginning of an action. The action happens when the - * `requestAnimationFrame` expires. - * - * If `onKeyDown` is called again, it pushes the `action` to a new - * `requestAnimationFrame` and cancels the old one. + * Handle line breaks */ - private startAction = () => { - debug('startAction') - if (this.onSelectTimeoutId) { - window.cancelAnimationFrame(this.onSelectTimeoutId) - this.onSelectTimeoutId = null - } - - this.isFlushing = true - - if (this.startActionFrameId) { - window.cancelAnimationFrame(this.startActionFrameId) - } - - this.startActionFrameId = window.requestAnimationFrame((): void => { - if (this.bufferedMutations.length > 0) { - this.flushAction(this.bufferedMutations) - } - - this.startActionFrameId = null - this.bufferedMutations.length = 0 - this.isFlushing = false - }) - } - - /** - * Handle MutationObserver flush - * - * @param mutations - */ + private insertBreak = () => { + debug('insertBreak') - flush = (mutations: MutationRecord[]) => { - debug('flush') - this.bufferedMutations.push(...mutations) - this.startAction() - } + const { selection } = this.editor - /** - * Handle a `requestAnimationFrame` long batch of mutations. - * - * @param mutations - */ + Editor.insertBreak(this.editor) + restoreDOM(this.editor) - private flushAction = (mutations: MutationRecord[]) => { - try { - debug('flushAction', mutations.length, mutations) - - const removedNodes = mutations.filter( - mutation => mutation.removedNodes.length > 0 - ).length - const addedNodes = mutations.filter( - mutation => mutation.addedNodes.length > 0 - ).length - - if (removedNodes > addedNodes) { - this.mergeBlock() - } else if (addedNodes > removedNodes) { - this.splitBlock() - } else { - this.resolveDOMNode(mutations[0].target.parentNode!) - } - } catch (err) { - // eslint-disable-next-line no-console - console.error(err) + if (selection) { + // Compat: Move selection to the newly inserted block if it has not moved + setTimeout(() => { + if ( + this.editor.selection && + Range.equals(selection, this.editor.selection) + ) { + Transforms.move(this.editor) + } + }, 150) } } /** - * Takes a DOM Node and resolves it against Slate's Document. - * - * Saves the changes to `last.diff` which can be applied later using - * `applyDiff()` - * - * @param domNode + * Handle expanded selection being deleted or replaced by text */ - private resolveDOMNode = (domNode: DOMNode) => { - debug('resolveDOMNode') - let node - try { - node = ReactEditor.toSlateNode(this.editor, domNode) - } catch (e) { - // not in react model yet. - return - } - const path = ReactEditor.findPath(this.editor, node) - const prevText = SlateNode.string(node) - - // COMPAT: If this is the last leaf, and the DOM text ends in a new line, - // we will have added another new line in 's render method to account - // for browsers collapsing a single trailing new lines, so remove it. - const [block] = Editor.parent( - this.editor, - ReactEditor.findPath(this.editor, node) - ) - const isLastNode = block.children[block.children.length - 1] === node + private replaceExpandedSelection = (text: string) => { + debug('replaceExpandedSelection') - const fix = fixTextAndOffset(domNode.textContent!, 0, isLastNode) + // Delete expanded selection + Editor.deleteFragment(this.editor) - const nextText = fix.text - - debug('resolveDOMNode:pre:post', prevText, nextText) - - // If the text is no different, there is no diff. - if (nextText === prevText) { - this.lastDiff = undefined - return + if (text.length) { + // Selection was replaced by text, insert the entire text diff + Editor.insertText(this.editor, text) } - const diff = diffText(prevText, nextText) - if (diff === null) { - this.lastDiff = undefined - return - } - - this.lastPath = path - this.lastDiff = diff - - debug('resolveDOMNode:diff', this.lastDiff) + restoreDOM(this.editor) } /** - * handle `onCompositionStart` + * Handle `backspace` that merges blocks */ - onCompositionStart = () => { - debug('onCompositionStart') - } + private deleteBackward = () => { + debug('deleteBackward') - /** - * handle `onCompositionEnd` - */ + Editor.deleteBackward(this.editor) + ReactEditor.focus(this.editor) - onCompositionEnd = () => { - debug('onCompositionEnd') - - /** - * The timing on the `setTimeout` with `20` ms is sensitive. - * - * It cannot use `requestAnimationFrame` because it is too short. - * - * Android 9, for example, when you type `it ` the space will first trigger - * a `compositionEnd` for the `it` part before the mutation for the ` `. - * This means that we end up with `it` if we trigger too soon because it - * is on the wrong value. - */ - - window.setTimeout(() => { - if (this.lastDiff !== undefined) { - debug('onCompositionEnd:applyDiff') - - renderSync(this.editor, () => { - this.applyDiff() - - const domRange = window.getSelection()!.getRangeAt(0) - const domText = domRange.startContainer.textContent! - const offset = domRange.startOffset - - const fix = fixTextAndOffset(domText, offset) - - let range = ReactEditor.toSlateRange(this.editor, domRange, { - exactMatch: true, - }) - if (range !== null) { - range = { - ...range, - anchor: { - ...range.anchor, - offset: fix.offset, - }, - focus: { - ...range.focus, - offset: fix.offset, - }, - } - - /** - * We must call `restoreDOM` even though this is applying a `diff` which - * should not require it. But if you type `it me. no.` on a blank line - * with a block following it, the next line will merge with the this - * line. A mysterious `keydown` with `input` of backspace appears in the - * event stream which the user not React caused. - * - * `focus` is required as well because otherwise we lose focus on hitting - * `enter` in such a scenario. - */ - - Transforms.select(this.editor, range) - ReactEditor.focus(this.editor) - } - - this.clearAction() - restoreDOM(this.editor) - }) - } - }, 20) + restoreDOM(this.editor) } /** - * Handle `onSelect` event - * - * Save the selection after a `requestAnimationFrame` - * - * - If we're not in the middle of flushing mutations - * - and cancel save if a mutation runs before the `requestAnimationFrame` + * Handle mutations that remove specific leaves */ + private removeLeafNodes = (nodes: DOMNode[]) => { + for (const node of nodes) { + const slateNode = ReactEditor.toSlateNode(this.editor, node) - onSelect = () => { - debug('onSelect:try') - - if (this.onSelectTimeoutId !== null) { - window.cancelAnimationFrame(this.onSelectTimeoutId) - this.onSelectTimeoutId = null - } - - // Don't capture the last selection if the selection was made during the - // flushing of DOM mutations. This means it is all part of one user action. - if (this.isFlushing) return - - this.onSelectTimeoutId = window.requestAnimationFrame(() => { - debug('onSelect:save-selection') - - const domSelection = window.getSelection() - if ( - domSelection === null || - domSelection.anchorNode === null || - domSelection.anchorNode.textContent === null || - domSelection.focusNode === null || - domSelection.focusNode.textContent === null - ) - return - - const { offset: anchorOffset } = fixTextAndOffset( - domSelection.anchorNode.textContent, - domSelection.anchorOffset - ) - const { offset: focusOffset } = fixTextAndOffset( - domSelection.focusNode!.textContent!, - domSelection.focusOffset - ) - let range = ReactEditor.toSlateRange(this.editor, domSelection, { - exactMatch: true, - }) - if (range !== null) { - range = { - focus: { - path: range.focus.path, - offset: focusOffset, - }, - anchor: { - path: range.anchor.path, - offset: anchorOffset, - }, - } - - debug('onSelect:save-data', { - anchorNode: domSelection.anchorNode, - anchorOffset: domSelection.anchorOffset, - focusNode: domSelection.focusNode, - focusOffset: domSelection.focusOffset, - range, - }) + if (slateNode) { + const path = ReactEditor.findPath(this.editor, slateNode) - // If the `domSelection` has moved into a new node, then reconcile with - // `applyDiff` - if ( - domSelection.isCollapsed && - this.lastDomNode !== domSelection.anchorNode && - this.lastDiff !== undefined - ) { - debug('onSelect:applyDiff', this.lastDiff) - this.applyDiff() - Transforms.select(this.editor, range) - - this.clearAction() - flushController(this.editor) - restoreDOM(this.editor) - } - - this.lastRange = range - this.lastDomNode = domSelection.anchorNode + Transforms.delete(this.editor, { at: path }) + restoreDOM(this.editor) } - }) + } } } diff --git a/packages/slate-react/src/components/android/diff-text.ts b/packages/slate-react/src/components/android/diff-text.ts index 72f5530c15..a7a9839565 100644 --- a/packages/slate-react/src/components/android/diff-text.ts +++ b/packages/slate-react/src/components/android/diff-text.ts @@ -1,3 +1,25 @@ +import { Editor, Path, Range, Text } from 'slate' + +import { ReactEditor } from '../../' +import { DOMNode } from '../../utils/dom' + +export type Diff = { + start: number + end: number + insertText: string + removeText: string +} + +export interface TextInsertion { + text: Diff + path: Path +} + +type TextRange = { + start: number + end: number +} + /** * Returns the number of characters that are the same at the beginning of the * String. @@ -42,11 +64,6 @@ function getDiffEnd(prev: string, next: string, max: number): number | null { return null } -type TextRange = { - start: number - end: number -} - /** * Takes two strings and returns an object representing two offsets. The * first, `start` represents the number of characters that are the same at @@ -103,9 +120,106 @@ export function diffText(prev?: string, next?: string): Diff | null { } } -export type Diff = { - start: number - end: number - insertText: string - removeText: string +export function combineInsertedText(insertedText: TextInsertion[]): string { + return insertedText.reduce((acc, { text }) => `${acc}${text.insertText}`, '') +} + +export function getTextInsertion( + editor: T, + domNode: DOMNode +): TextInsertion | undefined { + const node = ReactEditor.toSlateNode(editor, domNode) + + if (!Text.isText(node)) { + return undefined + } + + const prevText = node.text + let nextText = domNode.textContent! + + // textContent will pad an extra \n when the textContent ends with an \n + if (nextText.endsWith('\n')) { + nextText = nextText.slice(0, nextText.length - 1) + } + + // If the text is no different, there is no diff. + if (nextText !== prevText) { + const textDiff = diffText(prevText, nextText) + if (textDiff !== null) { + const textPath = ReactEditor.findPath(editor, node) + + return { + text: textDiff, + path: textPath, + } + } + } + + return undefined +} + +export function normalizeTextInsertionRange( + editor: Editor, + range: Range | null, + { path, text }: TextInsertion +) { + const insertionRange = { + anchor: { path, offset: text.start }, + focus: { path, offset: text.end }, + } + + if (!range || !Range.isCollapsed(range)) { + return insertionRange + } + + const { insertText, removeText } = text + const isSingleCharacterInsertion = + insertText.length === 1 || removeText.length === 1 + + /** + * This code handles edge cases that arise from text diffing when the + * inserted or removed character is a single character, and the character + * right before or after the anchor is the same as the one being inserted or + * removed. + * + * Take this example: hello|o + * + * If another `o` is inserted at the selection's anchor in the example above, + * it should be inserted at the anchor, but using text diffing, we actually + * detect that the character was inserted after the second `o`: + * + * helloo[o]| + * + * Instead, in these very specific edge cases, we assume that the character + * needs to be inserted after the anchor rather than where the diff was found: + * + * hello[o]|o + */ + if (isSingleCharacterInsertion && Path.equals(range.anchor.path, path)) { + const [text] = Array.from( + Editor.nodes(editor, { at: range, match: Text.isText }) + ) + + if (text) { + const [node] = text + const { anchor } = range + const characterBeforeAnchor = node.text[anchor.offset - 1] + const characterAfterAnchor = node.text[anchor.offset] + + if (insertText.length === 1 && insertText === characterAfterAnchor) { + // Assume text should be inserted at the anchor + return range + } + + if (removeText.length === 1 && removeText === characterBeforeAnchor) { + // Assume text should be removed right before the anchor + return { + anchor: { path, offset: anchor.offset - 1 }, + focus: { path, offset: anchor.offset }, + } + } + } + } + + return insertionRange } diff --git a/packages/slate-react/src/components/android/mutation-detection.ts b/packages/slate-react/src/components/android/mutation-detection.ts new file mode 100644 index 0000000000..bdfd70ae64 --- /dev/null +++ b/packages/slate-react/src/components/android/mutation-detection.ts @@ -0,0 +1,142 @@ +import { Editor, Node, Path, Range } from 'slate' + +import { DOMNode } from '../../utils/dom' +import { ReactEditor } from '../..' +import { TextInsertion, getTextInsertion } from './diff-text' + +interface MutationData { + addedNodes: DOMNode[] + removedNodes: DOMNode[] + insertedText: TextInsertion[] + characterDataMutations: MutationRecord[] +} + +type MutationDetection = (editor: Editor, mutationData: MutationData) => boolean + +export function gatherMutationData( + editor: Editor, + mutations: MutationRecord[] +): MutationData { + const addedNodes: DOMNode[] = [] + const removedNodes: DOMNode[] = [] + const insertedText: TextInsertion[] = [] + const characterDataMutations: MutationRecord[] = [] + + mutations.forEach(mutation => { + switch (mutation.type) { + case 'childList': { + if (mutation.addedNodes.length) { + mutation.addedNodes.forEach(addedNode => { + addedNodes.push(addedNode) + }) + } + + mutation.removedNodes.forEach(removedNode => { + removedNodes.push(removedNode) + }) + + break + } + case 'characterData': { + characterDataMutations.push(mutation) + + // Changes to text nodes should consider the parent element + const { parentNode } = mutation.target + + if (!parentNode) { + return + } + + const textInsertion = getTextInsertion(editor, parentNode) + + if (!textInsertion) { + return + } + + // If we've already detected a diff at that path, we can return early + if ( + insertedText.some(({ path }) => Path.equals(path, textInsertion.path)) + ) { + return + } + + // Add the text diff to the array of detected text insertions that need to be reconciled + insertedText.push(textInsertion) + } + } + }) + + return { addedNodes, removedNodes, insertedText, characterDataMutations } +} + +/** + * In general, when a line break occurs, there will be more `addedNodes` than `removedNodes`. + * + * This isn't always the case however. In some cases, there will be more `removedNodes` than + * `addedNodes`. + * + * To account for these edge cases, the most reliable strategy to detect line break mutations + * is to check whether a new block was inserted of the same type as the current block. + */ +export const isLineBreak: MutationDetection = (editor, { addedNodes }) => { + const { selection } = editor + const parentNode = selection + ? Node.parent(editor, selection.anchor.path) + : null + const parentDOMNode = parentNode + ? ReactEditor.toDOMNode(editor, parentNode) + : null + + if (!parentDOMNode) { + return false + } + + return addedNodes.some( + addedNode => + addedNode instanceof HTMLElement && + addedNode.tagName === parentDOMNode?.tagName + ) +} + +/** + * So long as we check for line break mutations before deletion mutations, + * we can safely assume that a set of mutations was a deletion if there are + * removed nodes. + */ +export const isDeletion: MutationDetection = (_, { removedNodes }) => { + return removedNodes.length > 0 +} + +/** + * If the selection was expanded and there are removed nodes, + * the contents of the selection need to be replaced with the diff + */ +export const isReplaceExpandedSelection: MutationDetection = ( + { selection }, + { removedNodes } +) => { + return selection + ? Range.isExpanded(selection) && removedNodes.length > 0 + : false +} + +/** + * Plain text insertion + */ +export const isTextInsertion: MutationDetection = (_, { insertedText }) => { + return insertedText.length > 0 +} + +/** + * Edge case. Detect mutations that remove leaf nodes and also update character data + */ +export const isRemoveLeafNodes: MutationDetection = ( + _, + { addedNodes, characterDataMutations, removedNodes } +) => { + return ( + removedNodes.length > 0 && + addedNodes.length === 0 && + characterDataMutations.length > 0 + ) +} diff --git a/packages/slate-react/src/components/android/restore-dom.ts b/packages/slate-react/src/components/android/restore-dom.ts new file mode 100644 index 0000000000..9c7bbe7dd5 --- /dev/null +++ b/packages/slate-react/src/components/android/restore-dom.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000000..09262fa58c --- /dev/null +++ b/packages/slate-react/src/components/android/use-android-input-manager.ts @@ -0,0 +1,23 @@ +import { RefObject, useEffect, useState } from 'react' +import { Editor } from 'slate' + +import { useSlateStatic } from '../../hooks/use-slate-static' + +import { useMutationObserver } from './use-mutation-observer' +import { AndroidInputManager } from './android-input-manager' + +const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { + childList: true, + characterData: true, + characterDataOldValue: true, + subtree: true, +} + +export function useAndroidInputManager(node: RefObject) { + const editor = useSlateStatic() + const [inputManager] = useState(() => new AndroidInputManager(editor)) + + useMutationObserver(node, inputManager.flush, MUTATION_OBSERVER_CONFIG) + + return inputManager +} diff --git a/packages/slate-react/src/components/android/use-mutation-observer.ts b/packages/slate-react/src/components/android/use-mutation-observer.ts new file mode 100644 index 0000000000..e7c8e3de8d --- /dev/null +++ b/packages/slate-react/src/components/android/use-mutation-observer.ts @@ -0,0 +1,27 @@ +import { RefObject, useEffect, useState } from 'react' +import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect' + +export function useMutationObserver( + node: RefObject, + callback: MutationCallback, + options: MutationObserverInit +) { + const [mutationObserver] = useState(() => new MutationObserver(callback)) + + useIsomorphicLayoutEffect(() => { + // Disconnect mutation observer during render phase + mutationObserver.disconnect() + }) + + useEffect(() => { + if (!node.current) { + throw new Error('Failed to attach MutationObserver, `node` is undefined') + } + + // Attach mutation observer after render phase has finished + mutationObserver.observe(node.current, options) + + // Clean up after effect + return mutationObserver.disconnect.bind(mutationObserver) + }) +}