diff --git a/.changeset/introduce-android-support.md b/.changeset/introduce-android-support.md new file mode 100644 index 000000000..8ed801dde --- /dev/null +++ b/.changeset/introduce-android-support.md @@ -0,0 +1,9 @@ +--- +'slate-react': minor +--- + +Added support for Android devices using a `MutationObserver` based reconciliation layer. + +Bugs should be expected; translating mutations into a set of operations that need to be reconciled onto the Slate model is not an absolute science, and requires a lot of guesswork and handling of edge cases. There are still edge cases that aren't being handled. + +This reconciliation layer aims to support Android 10 and 11. Earlier versions of Android work to a certain extent, but have more bugs and edge cases that currently aren't well supported. diff --git a/.eslintrc b/.eslintrc index 52d68ca1d..33ad28423 100644 --- a/.eslintrc +++ b/.eslintrc @@ -151,15 +151,6 @@ } ], "use-isnan": "error", - "valid-jsdoc": [ - "error", - { - "prefer": { - "return": "returns" - }, - "requireReturn": false - } - ], "valid-typeof": "error", "yield-star-spacing": [ "error", @@ -179,4 +170,4 @@ } } ] -} \ No newline at end of file +} diff --git a/packages/slate-react/src/components/android/android-editable.tsx b/packages/slate-react/src/components/android/android-editable.tsx new file mode 100644 index 000000000..326a991ec --- /dev/null +++ b/packages/slate-react/src/components/android/android-editable.tsx @@ -0,0 +1,480 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Descendant, Editor, Element, Node, Range, Transforms } from 'slate' +import throttle from 'lodash/throttle' +import scrollIntoView from 'scroll-into-view-if-needed' + +import { DefaultPlaceholder, ReactEditor } from '../..' +import { ReadOnlyContext } from '../../hooks/use-read-only' +import { useSlate } from '../../hooks/use-slate' +import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect' +import { DecorateContext } from '../../hooks/use-decorate' +import { + DOMElement, + isDOMElement, + isDOMNode, + getDefaultView, + isPlainTextOnlyPaste, +} from '../../utils/dom' +import { + EDITOR_TO_ELEMENT, + EDITOR_TO_RESTORE_DOM, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + IS_FOCUSED, + IS_READ_ONLY, + NODE_TO_ELEMENT, + PLACEHOLDER_SYMBOL, +} from '../../utils/weak-maps' +import { EditableProps } from '../editable' +import useChildren from '../../hooks/use-children' +import { + defaultDecorate, + hasEditableTarget, + isEventHandled, + isDOMEventHandled, + isTargetInsideVoid, +} from '../editable' + +import { useAndroidInputManager } from './use-android-input-manager' + +/** + * Editable. + */ + +export const AndroidEditable = (props: EditableProps): JSX.Element => { + const { + autoFocus, + decorate = defaultDecorate, + onDOMBeforeInput: propsOnDOMBeforeInput, + placeholder, + readOnly = false, + renderElement, + renderLeaf, + renderPlaceholder = props => , + style = {}, + as: Component = 'div', + ...attributes + } = props + const editor = useSlate() + const ref = useRef(null) + const inputManager = useAndroidInputManager(ref) + + // Update internal state on each render. + IS_READ_ONLY.set(editor, readOnly) + + // Keep track of some state for the event handler logic. + const state = useMemo( + () => ({ + isUpdatingSelection: false, + latestElement: null as DOMElement | null, + }), + [] + ) + + 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) + 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 { + // 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 + } + + const hasDomSelection = domSelection.type !== 'None' + + // 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 + } + + // 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 + } + } + + // 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 + + const newDomRange = selection && ReactEditor.toDOMRange(editor, selection) + + 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.removeAllRanges() + } + + 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 + // needs to be manually focused. + useEffect(() => { + if (ref.current && autoFocus) { + ref.current.focus() + } + }, [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) + ) { + inputManager.onUserInput() + } + }, + [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 + + // @ts-ignore The `beforeinput` event isn't recognized. + node?.addEventListener('beforeinput', onDOMBeforeInput) + + // @ts-ignore The `beforeinput` event isn't recognized. + 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 + // released. This causes issues in situations where another change happens + // while a selection is being dragged. + const onDOMSelectionChange = useCallback( + throttle(() => { + try { + if ( + !readOnly && + !state.isUpdatingSelection && + !inputManager.isReconciling.current + ) { + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const { activeElement } = root + const el = ReactEditor.toDOMNode(editor, editor) + const domSelection = root.getSelection() + + if (activeElement === el) { + state.latestElement = activeElement + IS_FOCUSED.set(editor, true) + } else { + IS_FOCUSED.delete(editor) + } + + if (!domSelection) { + return Transforms.deselect(editor) + } + + const { anchorNode, focusNode } = domSelection + + const anchorNodeSelectable = + hasEditableTarget(editor, anchorNode) || + isTargetInsideVoid(editor, anchorNode) + + const focusNodeSelectable = + hasEditableTarget(editor, focusNode) || + isTargetInsideVoid(editor, focusNode) + + if (anchorNodeSelectable && focusNodeSelectable) { + const range = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + }) + Transforms.select(editor, range) + } else { + Transforms.deselect(editor) + } + } + } catch { + // Failed to update selection, likely due to reconciliation error + } + }, 100), + [readOnly] + ) + + // Attach a native DOM event handler for `selectionchange`, because React's + // built-in `onSelect` handler doesn't fire for all selection changes. It's a + // leaky polyfill that only fires on keypresses or clicks. Instead, we want to + // fire for any change to the selection inside the editor. (2019/11/04) + // https://github.com/facebook/react/issues/5785 + useIsomorphicLayoutEffect(() => { + const window = ReactEditor.getWindow(editor) + window.document.addEventListener('selectionchange', onDOMSelectionChange) + + return () => { + window.document.removeEventListener( + 'selectionchange', + onDOMSelectionChange + ) + } + }) + + const decorations = decorate([editor, []]) + + if ( + placeholder && + editor.children.length === 1 && + Array.from(Node.texts(editor)).length === 1 && + Node.string(editor) === '' + ) { + const start = Editor.start(editor, []) + decorations.push({ + [PLACEHOLDER_SYMBOL]: true, + placeholder, + anchor: start, + focus: start, + }) + } + + return ( + + + ) => { + if ( + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCopy) + ) { + event.preventDefault() + ReactEditor.setFragmentData(editor, event.clipboardData) + } + }, + [attributes.onCopy] + )} + onCut={useCallback( + (event: React.ClipboardEvent) => { + if ( + !readOnly && + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCut) + ) { + event.preventDefault() + ReactEditor.setFragmentData(editor, event.clipboardData) + const { selection } = editor + + if (selection) { + if (Range.isExpanded(selection)) { + Editor.deleteFragment(editor) + } else { + const node = Node.parent(editor, selection.anchor.path) + if (Editor.isVoid(editor, node)) { + Transforms.delete(editor) + } + } + } + } + }, + [readOnly, attributes.onCut] + )} + onFocus={useCallback( + (event: React.FocusEvent) => { + if ( + !readOnly && + !state.isUpdatingSelection && + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onFocus) + ) { + const root = ReactEditor.findDocumentOrShadowRoot(editor) + state.latestElement = root.activeElement + + IS_FOCUSED.set(editor, true) + } + }, + [readOnly, attributes.onFocus] + )} + onBlur={useCallback( + (event: React.FocusEvent) => { + if ( + readOnly || + state.isUpdatingSelection || + !hasEditableTarget(editor, event.target) || + isEventHandled(event, attributes.onBlur) + ) { + return + } + + // COMPAT: If the current `activeElement` is still the previous + // 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. + const root = ReactEditor.findDocumentOrShadowRoot(editor) + if (state.latestElement === root.activeElement) { + return + } + + const { relatedTarget } = event + const el = ReactEditor.toDOMNode(editor, editor) + + // COMPAT: The event should be ignored if the focus is returning + // to the editor from an embedded editable element (eg. an + // element inside a void node). + if (relatedTarget === el) { + return + } + + // COMPAT: The event should be ignored if the focus is moving from + // the editor to inside a void node's spacer element. + if ( + isDOMElement(relatedTarget) && + relatedTarget.hasAttribute('data-slate-spacer') + ) { + return + } + + // COMPAT: The event should be ignored if the focus is moving to a + // non- editable section of an element that isn't a void node (eg. + // a list item of the check list example). + if ( + relatedTarget != null && + isDOMNode(relatedTarget) && + ReactEditor.hasDOMNode(editor, relatedTarget) + ) { + const node = ReactEditor.toSlateNode(editor, relatedTarget) + + if (Element.isElement(node) && !editor.isVoid(node)) { + return + } + } + + IS_FOCUSED.delete(editor) + }, + [readOnly, attributes.onBlur] + )} + onPaste={useCallback( + (event: React.ClipboardEvent) => { + // This unfortunately needs to be handled with paste events instead. + if ( + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onPaste) && + !readOnly + ) { + event.preventDefault() + ReactEditor.insertData(editor, event.clipboardData) + } + }, + [readOnly, attributes.onPaste] + )} + > + {useChildren({ + decorations, + node: editor, + renderElement, + renderPlaceholder, + renderLeaf, + selection: editor.selection, + })} + + + + ) +} diff --git a/packages/slate-react/src/components/android/android-input-manager.ts b/packages/slate-react/src/components/android/android-input-manager.ts new file mode 100644 index 000000000..87d81d972 --- /dev/null +++ b/packages/slate-react/src/components/android/android-input-manager.ts @@ -0,0 +1,191 @@ +import { ReactEditor } from '../../plugin/react-editor' +import { Editor, Range, Transforms } from 'slate' + +import { DOMNode } from '../../utils/dom' + +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[]) => {} + +/** + * Based loosely on: + * + * https://github.com/facebook/draft-js/blob/master/src/component/handlers/composition/DOMObserver.js + * https://github.com/ProseMirror/prosemirror-view/blob/master/src/domobserver.js + * + * The input manager attempts to map observed mutations on the document to a + * set of operations in order to reconcile Slate's internal value with the DOM. + * + * Mutations are processed synchronously as they come in. Only mutations that occur + * during a user input loop are processed, as other mutations can occur within the + * document that were not initiated by user input. + * + * The mutation reconciliation process attempts to match mutations to the following + * patterns: + * + * - Text updates + * - Deletions + * - Line breaks + * + * @param editor + */ + +export class AndroidInputManager { + constructor(private editor: ReactEditor) { + this.editor = editor + } + + /** + * Handle MutationObserver flush + * + * @param mutations + */ + + flush = (mutations: MutationRecord[]) => { + debug('flush') + + try { + this.reconcileMutations(mutations) + } catch (err) { + // eslint-disable-next-line no-console + console.error(err) + + // Failed to reconcile mutations, restore DOM to its previous state + restoreDOM(this.editor) + } + } + + /** + * Reconcile a batch of mutations + * + * @param mutations + */ + + 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) + } + } + + /** + * Apply text diff + */ + + private insertText = (insertedText: TextInsertion[]) => { + debug('insertText') + + const { selection } = this.editor + + // Insert the batched text diffs + insertedText.forEach(insertion => { + Transforms.insertText(this.editor, insertion.text.insertText, { + at: normalizeTextInsertionRange(this.editor, selection, insertion), + }) + }) + } + + /** + * Handle line breaks + */ + + private insertBreak = () => { + debug('insertBreak') + + const { selection } = this.editor + + 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) + + 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) + } + }, 100) + } + } + + /** + * Handle expanded selection being deleted or replaced by text + */ + + private replaceExpandedSelection = (text: string) => { + debug('replaceExpandedSelection') + + // Delete expanded selection + Editor.deleteFragment(this.editor) + + if (text.length) { + // Selection was replaced by text, insert the entire text diff + Editor.insertText(this.editor, text) + } + + restoreDOM(this.editor) + } + + /** + * Handle `backspace` that merges blocks + */ + + private deleteBackward = () => { + debug('deleteBackward') + + Editor.deleteBackward(this.editor) + ReactEditor.focus(this.editor) + + restoreDOM(this.editor) + } + + /** + * Handle mutations that remove specific leaves + */ + private removeLeafNodes = (nodes: DOMNode[]) => { + for (const node of nodes) { + const slateNode = ReactEditor.toSlateNode(this.editor, node) + + if (slateNode) { + const path = ReactEditor.findPath(this.editor, slateNode) + + Transforms.delete(this.editor, { at: path }) + restoreDOM(this.editor) + } + } + } +} + +export default AndroidInputManager diff --git a/packages/slate-react/src/components/android/diff-text.ts b/packages/slate-react/src/components/android/diff-text.ts new file mode 100644 index 000000000..a7a983956 --- /dev/null +++ b/packages/slate-react/src/components/android/diff-text.ts @@ -0,0 +1,225 @@ +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. + * + * @param prev the previous text + * @param next the next text + * @returns the offset of the start of the difference; null if there is no difference + */ +function getDiffStart(prev: string, next: string): number | null { + const length = Math.min(prev.length, next.length) + + for (let i = 0; i < length; i++) { + if (prev.charAt(i) !== next.charAt(i)) return i + } + + if (prev.length !== next.length) return length + return null +} + +/** + * Returns the number of characters that are the same at the end of the String + * up to `max`. Max prevents double-counting characters when there are + * multiple duplicate characters around the diff area. + * + * @param prev the previous text + * @param next the next text + * @param max the max length to test. + * @returns number of characters that are the same at the end of the string + */ +function getDiffEnd(prev: string, next: string, max: number): number | null { + const prevLength = prev.length + const nextLength = next.length + const length = Math.min(prevLength, nextLength, max) + + for (let i = 0; i < length; i++) { + const prevChar = prev.charAt(prevLength - i - 1) + const nextChar = next.charAt(nextLength - i - 1) + if (prevChar !== nextChar) return i + } + + if (prev.length !== next.length) return length + return null +} + +/** + * Takes two strings and returns an object representing two offsets. The + * first, `start` represents the number of characters that are the same at + * the front of the String. The `end` represents the number of characters + * that are the same at the end of the String. + * + * Returns null if they are identical. + * + * @param prev the previous text + * @param next the next text + * @returns the difference text range; null if there are no differences. + */ +function getDiffOffsets(prev: string, next: string): TextRange | null { + if (prev === next) return null + const start = getDiffStart(prev, next) + if (start === null) return null + const maxEnd = Math.min(prev.length - start, next.length - start) + const end = getDiffEnd(prev, next, maxEnd)! + if (end === null) return null + return { start, end } +} + +/** + * Takes a text string and returns a slice from the string at the given text range + * + * @param text the text + * @param offsets the text range + * @returns the text slice at text range + */ +function sliceText(text: string, offsets: TextRange): string { + return text.slice(offsets.start, text.length - offsets.end) +} + +/** + * Takes two strings and returns a smart diff that can be used to describe the + * change in a way that can be used as operations like inserting, removing or + * replacing text. + * + * @param prev the previous text + * @param next the next text + * @returns the text difference + */ +export function diffText(prev?: string, next?: string): Diff | null { + if (prev === undefined || next === undefined) return null + const offsets = getDiffOffsets(prev, next) + if (offsets == null) return null + const insertText = sliceText(next, offsets) + const removeText = sliceText(prev, offsets) + return { + start: offsets.start, + end: prev.length - offsets.end, + insertText, + removeText, + } +} + +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/index.ts b/packages/slate-react/src/components/android/index.ts new file mode 100644 index 000000000..77dee9271 --- /dev/null +++ b/packages/slate-react/src/components/android/index.ts @@ -0,0 +1 @@ +export { AndroidEditable } from './android-editable' 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 000000000..bdfd70ae6 --- /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 000000000..9c7bbe7dd --- /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 000000000..130a67bb6 --- /dev/null +++ b/packages/slate-react/src/components/android/use-android-input-manager.ts @@ -0,0 +1,46 @@ +import { RefObject, useCallback, useRef, useState } from 'react' + +import { useSlateStatic } from '../../hooks/use-slate-static' + +import { AndroidInputManager } from './android-input-manager' +import { useMutationObserver } from './use-mutation-observer' +import { useTrackUserInput } from './use-track-user-input' + +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)) + const { receivedUserInput, onUserInput } = useTrackUserInput() + const timeoutId = useRef(null) + const isReconciling = useRef(false) + const flush = useCallback((mutations: MutationRecord[]) => { + if (!receivedUserInput.current) { + return + } + + isReconciling.current = true + inputManager.flush(mutations) + + if (timeoutId.current) { + clearTimeout(timeoutId.current) + } + + timeoutId.current = setTimeout(() => { + isReconciling.current = false + timeoutId.current = null + }, 250) + }, []) + + useMutationObserver(node, flush, MUTATION_OBSERVER_CONFIG) + + return { + isReconciling, + onUserInput, + } +} 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 000000000..e7c8e3de8 --- /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) + }) +} diff --git a/packages/slate-react/src/components/android/use-track-user-input.ts b/packages/slate-react/src/components/android/use-track-user-input.ts new file mode 100644 index 000000000..3f84390fc --- /dev/null +++ b/packages/slate-react/src/components/android/use-track-user-input.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useRef } from 'react' + +import { ReactEditor } from '../..' +import { useSlateStatic } from '../../hooks/use-slate-static' + +export function useTrackUserInput() { + const editor = useSlateStatic() + const receivedUserInput = useRef(false) + const animationFrameRef = useRef(null) + const onUserInput = useCallback(() => { + if (receivedUserInput.current === false) { + const window = ReactEditor.getWindow(editor) + + receivedUserInput.current = true + + if (animationFrameRef.current) { + window.cancelAnimationFrame(animationFrameRef.current) + } + + animationFrameRef.current = window.requestAnimationFrame(() => { + receivedUserInput.current = false + animationFrameRef.current = null + }) + } + }, []) + + useEffect(() => { + // Reset user input tracking on every render + if (receivedUserInput.current) { + receivedUserInput.current = false + } + }) + + return { + receivedUserInput, + onUserInput, + } +} diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index e08d2ebd5..50d9d8e53 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -138,8 +138,9 @@ export const Editable = (props: EditableProps) => { [] ) - // Update element-related weak maps with the DOM element ref. + // 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) @@ -149,10 +150,8 @@ export const Editable = (props: EditableProps) => { } else { NODE_TO_ELEMENT.delete(editor) } - }) - // Whenever the editor updates, make sure the DOM selection state is in sync. - useIsomorphicLayoutEffect(() => { + // Make sure the DOM selection state is in sync. const { selection } = editor const root = ReactEditor.findDocumentOrShadowRoot(editor) const domSelection = root.getSelection() @@ -1137,13 +1136,13 @@ export const DefaultPlaceholder = ({ * A default memoized decorate function. */ -const defaultDecorate: (entry: NodeEntry) => Range[] = () => [] +export const defaultDecorate: (entry: NodeEntry) => Range[] = () => [] /** * Check if two DOM range objects are equal. */ -const isRangeEqual = (a: DOMRange, b: DOMRange) => { +export const isRangeEqual = (a: DOMRange, b: DOMRange) => { return ( (a.startContainer === b.startContainer && a.startOffset === b.startOffset && @@ -1160,7 +1159,7 @@ const isRangeEqual = (a: DOMRange, b: DOMRange) => { * Check if the target is in the editor. */ -const hasTarget = ( +export const hasTarget = ( editor: ReactEditor, target: EventTarget | null ): target is DOMNode => { @@ -1171,7 +1170,7 @@ const hasTarget = ( * Check if the target is editable and in the editor. */ -const hasEditableTarget = ( +export const hasEditableTarget = ( editor: ReactEditor, target: EventTarget | null ): target is DOMNode => { @@ -1185,7 +1184,7 @@ const hasEditableTarget = ( * Check if the target is inside void and in the editor. */ -const isTargetInsideVoid = ( +export const isTargetInsideVoid = ( editor: ReactEditor, target: EventTarget | null ): boolean => { @@ -1198,7 +1197,7 @@ const isTargetInsideVoid = ( * Check if an event is overrided by a handler. */ -const isEventHandled = < +export const isEventHandled = < EventType extends React.SyntheticEvent >( event: EventType, @@ -1216,7 +1215,7 @@ const isEventHandled = < * Check if a DOM event is overrided by a handler. */ -const isDOMEventHandled = ( +export const isDOMEventHandled = ( event: E, handler?: (event: E) => void ) => { diff --git a/packages/slate-react/src/index.ts b/packages/slate-react/src/index.ts index 8e9c46b5d..efc26f5e1 100644 --- a/packages/slate-react/src/index.ts +++ b/packages/slate-react/src/index.ts @@ -1,11 +1,18 @@ // Components +// Environment-dependent Editable +import { Editable as DefaultEditable } from './components/editable' +import { AndroidEditable } from './components/android/android-editable' +import { IS_ANDROID } from './utils/environment' + +export const Editable = IS_ANDROID ? AndroidEditable : DefaultEditable export { + Editable as DefaultEditable, RenderElementProps, RenderLeafProps, - Editable, RenderPlaceholderProps, DefaultPlaceholder, } from './components/editable' +export { AndroidEditable } from './components/android/android-editable' export { DefaultElement } from './components/element' export { DefaultLeaf } from './components/leaf' export { Slate } from './components/slate' diff --git a/packages/slate-react/src/utils/environment.ts b/packages/slate-react/src/utils/environment.ts index d11c8d245..f775a30b6 100644 --- a/packages/slate-react/src/utils/environment.ts +++ b/packages/slate-react/src/utils/environment.ts @@ -7,6 +7,9 @@ export const IS_IOS = export const IS_APPLE = typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent) +export const IS_ANDROID = + typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent) + export const IS_FIREFOX = typeof navigator !== 'undefined' && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent) diff --git a/packages/slate-react/src/utils/weak-maps.ts b/packages/slate-react/src/utils/weak-maps.ts index c2fa295ad..b5c294664 100644 --- a/packages/slate-react/src/utils/weak-maps.ts +++ b/packages/slate-react/src/utils/weak-maps.ts @@ -37,6 +37,8 @@ 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>() + /** * Symbols. */