diff --git a/.changeset/small-laws-remember.md b/.changeset/small-laws-remember.md
new file mode 100644
index 000000000..0114f3acc
--- /dev/null
+++ b/.changeset/small-laws-remember.md
@@ -0,0 +1,5 @@
+---
+'slate-react': minor
+---
+
+Added android keyboard support for slate editor
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/ErrorBoundary.tsx b/packages/slate-react/src/components/android/ErrorBoundary.tsx
new file mode 100644
index 000000000..801113409
--- /dev/null
+++ b/packages/slate-react/src/components/android/ErrorBoundary.tsx
@@ -0,0 +1,20 @@
+import React, { PropsWithChildren } from 'react'
+
+export class ErrorBoundary extends React.Component<
+ PropsWithChildren<{}>,
+ never
+> {
+ static getDerivedStateFromError(error: Error) {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true }
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ // eslint-disable-next-line no-console
+ console.error(error)
+ }
+
+ render() {
+ return this.props.children
+ }
+}
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..d42fb2ac5
--- /dev/null
+++ b/packages/slate-react/src/components/android/android-editable.tsx
@@ -0,0 +1,452 @@
+import React, {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { Descendant, Editor, 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 {
+ DOMElement,
+ 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 { AndroidInputManager } from './android-input-manager'
+import { EditableProps } from '../editable'
+import { ErrorBoundary } from './ErrorBoundary'
+import useChildren from '../../hooks/use-children'
+import {
+ defaultDecorate,
+ hasEditableTarget,
+ isEventHandled,
+ isTargetInsideVoid,
+} from '../editable'
+import { IS_FIREFOX } from '../../utils/environment'
+
+export const AndroidEditableNoError = (props: EditableProps): JSX.Element => {
+ return (
+
+
+
+ )
+}
+
+/**
+ * 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 = useMemo(() => new AndroidInputManager(editor), [editor])
+
+ // 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(
+ () => ({
+ isComposing: false,
+ isUpdatingSelection: false,
+ latestElement: null as DOMElement | null,
+ }),
+ []
+ )
+
+ // Update element-related weak maps with the DOM element ref.
+ useIsomorphicLayoutEffect(() => {
+ 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)
+ } else {
+ NODE_TO_ELEMENT.delete(editor)
+ }
+ })
+
+ // Update element-related weak maps with the DOM element ref.
+ useIsomorphicLayoutEffect(() => {
+ if (ref.current) {
+ EDITOR_TO_ELEMENT.set(editor, ref.current)
+ NODE_TO_ELEMENT.set(editor, ref.current)
+ ELEMENT_TO_NODE.set(ref.current, editor)
+ } else {
+ NODE_TO_ELEMENT.delete(editor)
+ }
+ })
+
+ // Whenever the editor updates, make sure the DOM selection state is in sync.
+ useIsomorphicLayoutEffect(() => {
+ const { selection } = editor
+ const root = ReactEditor.findDocumentOrShadowRoot(editor)
+ const domSelection = root.getSelection()
+
+ if (state.isComposing || !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(() => {
+ // COMPAT: In Firefox, it's not enough to create a range, you also need
+ // to focus the contenteditable element too. (2016/11/16)
+ if (newDomRange && IS_FIREFOX) {
+ el.focus()
+ }
+
+ state.isUpdatingSelection = false
+ })
+ })
+
+ useLayoutEffect(() => {
+ inputManager.onDidMount()
+ return () => {
+ inputManager.onWillUnmount()
+ }
+ }, [])
+
+ const prevValue = useRef([])
+ if (prevValue.current !== editor.children) {
+ inputManager.onRender()
+ prevValue.current = editor.children
+ }
+
+ useLayoutEffect(() => {
+ inputManager.onDidUpdate()
+ })
+
+ // 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 `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(() => {
+ if (!readOnly && !state.isComposing && !state.isUpdatingSelection) {
+ inputManager.onSelect()
+
+ 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)
+ }
+ }
+ }, 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(() => {
+ window.document.addEventListener('selectionchange', onDOMSelectionChange)
+
+ return () => {
+ window.document.removeEventListener(
+ 'selectionchange',
+ onDOMSelectionChange
+ )
+ }
+ }, [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,
+ })
+ }
+
+ const [contentKey, setContentKey] = useState(0)
+
+ const onRestoreDOM = useCallback(() => {
+ setContentKey(prev => prev + 1)
+ }, [contentKey])
+ EDITOR_TO_RESTORE_DOM.set(editor, onRestoreDOM)
+ useEffect(() => {
+ return () => {
+ EDITOR_TO_RESTORE_DOM.delete(editor)
+ }
+ }, [])
+
+ return (
+
+ ) => {
+ 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 (
+ 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 && Range.isExpanded(selection)) {
+ Editor.deleteFragment(editor)
+ }
+ }
+ },
+ [readOnly, attributes.onCut]
+ )}
+ onFocus={useCallback(
+ (event: React.FocusEvent) => {
+ if (
+ !readOnly &&
+ !state.isUpdatingSelection &&
+ hasEditableTarget(editor, event.target) &&
+ !isEventHandled(event, attributes.onFocus)
+ ) {
+ const el = ReactEditor.toDOMNode(editor, editor)
+ state.latestElement = window.document.activeElement
+
+ IS_FOCUSED.set(editor, true)
+ }
+ },
+ [readOnly, attributes.onFocus]
+ )}
+ onKeyDown={useCallback(
+ (event: React.KeyboardEvent) => {},
+ [readOnly, attributes.onKeyDown]
+ )}
+ onPaste={useCallback(
+ (event: React.ClipboardEvent) => {
+ // This unfortunately needs to be handled with paste events instead.
+ if (
+ hasEditableTarget(editor, event.target) &&
+ !isEventHandled(event, attributes.onPaste) &&
+ isPlainTextOnlyPaste(event.nativeEvent) &&
+ !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..972b264b9
--- /dev/null
+++ b/packages/slate-react/src/components/android/android-input-manager.ts
@@ -0,0 +1,558 @@
+import { ReactEditor } from '../../plugin/react-editor'
+import { Editor, Node as SlateNode, Path, Range, Transforms } from 'slate'
+import { Diff, diffText } from './diff-text'
+import { DOMNode } from '../../utils/dom'
+import {
+ EDITOR_TO_ON_CHANGE,
+ EDITOR_TO_RESTORE_DOM,
+} from '../../utils/weak-maps'
+
+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:
+ *
+ * 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
+ *
+ * But is an analysis mainly for `backspace` and `enter` as we handle
+ * compositions as a single operation.
+ *
+ * @param editor
+ */
+
+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)
+ }
+
+ onDidMount = () => {
+ this.connect()
+ }
+
+ onDidUpdate = () => {
+ this.connect()
+ }
+
+ /**
+ * Connect the MutationObserver to a specific editor root element
+ */
+
+ 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
+ */
+
+ private clearAction = () => {
+ debug('clearAction')
+
+ this.bufferedMutations.length = 0
+ this.lastDiff = undefined
+ this.lastDomNode = undefined
+ }
+
+ /**
+ * Apply the last `diff`
+ *
+ * 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
+ */
+
+ 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 },
+ }
+
+ Transforms.insertText(this.editor, this.lastDiff.insertText, { at: range })
+ }
+
+ /**
+ * Handle `enter` that splits block
+ */
+
+ 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)
+ })
+ }
+
+ /**
+ * Handle `backspace` that merges blocks
+ */
+
+ 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)
+ })
+ })
+ }
+
+ /**
+ * 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.
+ */
+
+ 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
+ */
+
+ flush = (mutations: MutationRecord[]) => {
+ debug('flush')
+ this.bufferedMutations.push(...mutations)
+ this.startAction()
+ }
+
+ /**
+ * Handle a `requestAnimationFrame` long batch of mutations.
+ *
+ * @param mutations
+ */
+
+ 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)
+ }
+ }
+
+ /**
+ * 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
+ */
+
+ 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
+
+ const fix = fixTextAndOffset(domNode.textContent!, 0, isLastNode)
+
+ 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
+ }
+
+ const diff = diffText(prevText, nextText)
+ if (diff === null) {
+ this.lastDiff = undefined
+ return
+ }
+
+ this.lastPath = path
+ this.lastDiff = diff
+
+ debug('resolveDOMNode:diff', this.lastDiff)
+ }
+
+ /**
+ * handle `onCompositionStart`
+ */
+
+ onCompositionStart = () => {
+ debug('onCompositionStart')
+ }
+
+ /**
+ * handle `onCompositionEnd`
+ */
+
+ 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)
+ }
+
+ /**
+ * 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`
+ */
+
+ 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 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
+ }
+ })
+ }
+}
+
+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..72f5530c1
--- /dev/null
+++ b/packages/slate-react/src/components/android/diff-text.ts
@@ -0,0 +1,111 @@
+/**
+ * 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
+}
+
+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
+ * 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 type Diff = {
+ start: number
+ end: number
+ insertText: string
+ removeText: string
+}
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..4f6f30ec8
--- /dev/null
+++ b/packages/slate-react/src/components/android/index.ts
@@ -0,0 +1 @@
+export { AndroidEditable, AndroidEditableNoError } from './android-editable'
diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx
index e08d2ebd5..b2fe5139c 100644
--- a/packages/slate-react/src/components/editable.tsx
+++ b/packages/slate-react/src/components/editable.tsx
@@ -1137,13 +1137,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 +1160,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 +1171,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 +1185,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 +1198,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 +1216,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..1bd9c44a0 100644
--- a/packages/slate-react/src/index.ts
+++ b/packages/slate-react/src/index.ts
@@ -1,8 +1,12 @@
// Components
+// Environment-dependent Editable
+import { Editable as DefaultEditable } from './components/editable'
+import { AndroidEditableNoError as AndroidEditable } from './components/android/android-editable'
+import { IS_ANDROID } from './utils/environment'
+
export {
RenderElementProps,
RenderLeafProps,
- Editable,
RenderPlaceholderProps,
DefaultPlaceholder,
} from './components/editable'
@@ -21,3 +25,4 @@ export { useSlate } from './hooks/use-slate'
// Plugin
export { ReactEditor } from './plugin/react-editor'
export { withReact } from './plugin/with-react'
+export const Editable = !IS_ANDROID ? DefaultEditable : AndroidEditable
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/lines.ts b/packages/slate-react/src/utils/lines.ts
index 960d77a55..48bd592b2 100644
--- a/packages/slate-react/src/utils/lines.ts
+++ b/packages/slate-react/src/utils/lines.ts
@@ -26,9 +26,9 @@ const areRangesSameLine = (
* A helper utility that returns the end portion of a `Range`
* which is located on a single line.
*
- * @param {Editor} editor The editor object to compare against
- * @param {Range} parentRange The parent range to compare against
- * @returns {Range} A valid portion of the parentRange which is one a single line
+ * @param editor The editor object to compare against
+ * @param parentRange The parent range to compare against
+ * @returns A valid portion of the parentRange which is one a single line
*/
export const findCurrentLineRange = (
editor: ReactEditor,
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.
*/
diff --git a/site/examples/images.tsx b/site/examples/images.tsx
index 595377c96..b5f920f62 100644
--- a/site/examples/images.tsx
+++ b/site/examples/images.tsx
@@ -1,13 +1,13 @@
-import React, { useState, useMemo } from 'react'
+import React, { useMemo, useState } from 'react'
import imageExtensions from 'image-extensions'
import isUrl from 'is-url'
import { Transforms, createEditor, Descendant } from 'slate'
import {
- Slate,
Editable,
- useSlateStatic,
- useSelected,
+ Slate,
useFocused,
+ useSelected,
+ useSlateStatic,
withReact,
} from 'slate-react'
import { withHistory } from 'slate-history'
diff --git a/site/examples/richtext.tsx b/site/examples/richtext.tsx
index 243a182b0..21193fec0 100644
--- a/site/examples/richtext.tsx
+++ b/site/examples/richtext.tsx
@@ -1,12 +1,12 @@
import React, { useCallback, useMemo, useState } from 'react'
import isHotkey from 'is-hotkey'
-import { Editable, withReact, useSlate, Slate } from 'slate-react'
+import { Editable, Slate, useSlate, withReact } from 'slate-react'
import {
- Editor,
- Transforms,
createEditor,
Descendant,
+ Editor,
Element as SlateElement,
+ Transforms,
} from 'slate'
import { withHistory } from 'slate-history'
@@ -28,7 +28,7 @@ const RichTextExample = () => {
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
return (
- setValue(value)}>
+
diff --git a/yarn.lock b/yarn.lock
index 771e2ca19..9d67dbb42 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2592,18 +2592,19 @@
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/react-dom@^16.9.4":
- version "16.9.8"
- resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
- integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
+ version "16.9.11"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.11.tgz#752e223a1592a2c10f2668b215a0e0667f4faab1"
+ integrity sha512-3UuR4MoWf5spNgrG6cwsmT9DdRghcR4IDFOzNZ6+wcmacxkFykcb5ji0nNVm9ckBT4BCxvCrJJbM4+EYsEEVIg==
dependencies:
- "@types/react" "*"
+ "@types/react" "^16"
-"@types/react@*", "@types/react@^16.9.13":
- version "16.9.46"
- resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.46.tgz#f0326cd7adceda74148baa9bff6e918632f5069e"
- integrity sha512-dbHzO3aAq1lB3jRQuNpuZ/mnu+CdD3H0WVaaBQA8LTT3S33xhVBUj232T8M3tAhSWJs/D/UqORYUlJNl/8VQZg==
+"@types/react@^16", "@types/react@^16.9.13":
+ version "16.14.5"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.5.tgz#2c39b5cadefaf4829818f9219e5e093325979f4d"
+ integrity sha512-YRRv9DNZhaVTVRh9Wmmit7Y0UFhEVqXqCSw3uazRWMxa2x85hWQZ5BN24i7GXZbaclaLXEcodEeIHsjBA8eAMw==
dependencies:
"@types/prop-types" "*"
+ "@types/scheduler" "*"
csstype "^3.0.2"
"@types/resolve@0.0.8":
@@ -2613,6 +2614,11 @@
dependencies:
"@types/node" "*"
+"@types/scheduler@*":
+ version "0.16.1"
+ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275"
+ integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==
+
"@types/semver@^6.0.0":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.2.2.tgz#5c27df09ca39e3c9beb4fae6b95f4d71426df0a9"
@@ -9539,9 +9545,9 @@ randomfill@^1.0.3:
safe-buffer "^5.1.0"
react-dom@^16.12.0:
- version "16.13.1"
- resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
- integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==
+ version "16.14.0"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
+ integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -9603,9 +9609,9 @@ react-values@^0.3.0:
integrity sha512-K0SWzJBIuEDwWtDDZqbEm8XWaSy3LUJB7hZm1iHUo6wTwWQWD28TEn/T9YkbrJHrw2PNwZFL3nMKjkk09BmbqA==
react@^16.12.0:
- version "16.13.1"
- resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
- integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
+ version "16.14.0"
+ resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
+ integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"