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"