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.
*/