diff --git a/.changeset/fresh-taxis-itch.md b/.changeset/fresh-taxis-itch.md new file mode 100644 index 000000000..576262756 --- /dev/null +++ b/.changeset/fresh-taxis-itch.md @@ -0,0 +1,6 @@ +--- +'slate-react': minor +'slate': patch +--- + +Android input handling rewrite, replace composition insert prefixes with decoration based mark placeholders diff --git a/packages/slate-react/src/components/android/android-editable.tsx b/packages/slate-react/src/components/android/android-editable.tsx deleted file mode 100644 index 9761b4265..000000000 --- a/packages/slate-react/src/components/android/android-editable.tsx +++ /dev/null @@ -1,617 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Editor, Element, Node, Range, Transforms, Path, Text } from 'slate' -import throttle from 'lodash/throttle' -import debounce from 'lodash/debounce' -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, - getClipboardData, -} from '../../utils/dom' -import { - EDITOR_TO_ELEMENT, - EDITOR_TO_WINDOW, - ELEMENT_TO_NODE, - IS_FOCUSED, - IS_READ_ONLY, - NODE_TO_ELEMENT, - PLACEHOLDER_SYMBOL, - IS_COMPOSING, - IS_ON_COMPOSITION_END, - EDITOR_ON_COMPOSITION_TEXT, -} from '../../utils/weak-maps' -import { normalizeTextInsertionRange } from './diff-text' - -import { EditableProps, hasTarget } from '../editable' -import useChildren from '../../hooks/use-children' -import { - defaultDecorate, - hasEditableTarget, - isEventHandled, - isDOMEventHandled, - isTargetInsideNonReadonlyVoid, -} from '../editable' - -import { useAndroidInputManager } from './use-android-input-manager' -import { useContentKey } from '../../hooks/use-content-key' - -/** - * Editable. - */ - -// https://github.com/facebook/draft-js/blob/main/src/component/handlers/composition/DraftEditorCompositionHandler.js#L41 -// When using keyboard English association function, conpositionEnd triggered too fast, resulting in after `insertText` still maintain association state. -const RESOLVE_DELAY = 20 - -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() - // Rerender editor when composition status changed - const [isComposing, setIsComposing] = useState(false) - 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( - () => ({ - isComposing: false, - isUpdatingSelection: false, - latestElement: null as DOMElement | null, - }), - [] - ) - - const contentKey = useContentKey(editor) - - // 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) - } else { - NODE_TO_ELEMENT.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 ( - 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, - suppressThrow: 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, - suppressThrow: 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 `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 ( - !state.isComposing && - !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) || - isTargetInsideNonReadonlyVoid(editor, anchorNode) - - const focusNodeSelectable = - hasEditableTarget(editor, focusNode) || - isTargetInsideNonReadonlyVoid(editor, focusNode) - - if (anchorNodeSelectable && focusNodeSelectable) { - const range = ReactEditor.toSlateRange(editor, domSelection, { - exactMatch: false, - suppressThrow: false, - }) - Transforms.select(editor, range) - } else { - Transforms.deselect(editor) - } - } - } catch { - // Failed to update selection, likely due to reconciliation error - } - }, 100), - [readOnly] - ) - - const scheduleOnDOMSelectionChange = useMemo( - () => debounce(onDOMSelectionChange, 0), - [onDOMSelectionChange] - ) - - // 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) - ) { - // Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before - // triggering a `beforeinput` expecting the change to be applied to the immediately before - // set selection. - scheduleOnDOMSelectionChange.flush() - - 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]) - - // 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', - scheduleOnDOMSelectionChange - ) - - return () => { - window.document.removeEventListener( - 'selectionchange', - scheduleOnDOMSelectionChange - ) - } - }, [scheduleOnDOMSelectionChange]) - - const decorations = decorate([editor, []]) - - if ( - placeholder && - editor.children.length === 1 && - Array.from(Node.texts(editor)).length === 1 && - Node.string(editor) === '' && - !isComposing - ) { - 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, 'copy') - } - }, - [attributes.onCopy] - )} - onCut={useCallback( - (event: React.ClipboardEvent) => { - if ( - !readOnly && - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCut) - ) { - event.preventDefault() - ReactEditor.setFragmentData(editor, event.clipboardData, 'cut') - 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] - )} - onClick={useCallback( - (event: React.MouseEvent) => { - if ( - !readOnly && - hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onClick) && - isDOMNode(event.target) - ) { - const node = ReactEditor.toSlateNode(editor, event.target) - const path = ReactEditor.findPath(editor, node) - - // At this time, the Slate document may be arbitrarily different, - // because onClick handlers can change the document before we get here. - // Therefore we must check that this path actually exists, - // and that it still refers to the same node. - if (Editor.hasPath(editor, path)) { - const lookupNode = Node.get(editor, path) - if (lookupNode === node) { - const start = Editor.start(editor, path) - const end = Editor.end(editor, path) - - const startVoid = Editor.void(editor, { at: start }) - const endVoid = Editor.void(editor, { at: end }) - - if ( - startVoid && - endVoid && - Path.equals(startVoid[1], endVoid[1]) - ) { - const range = Editor.range(editor, start) - Transforms.select(editor, range) - } - } - } - } - }, - [readOnly, attributes.onClick] - )} - onCompositionEnd={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionEnd) - ) { - scheduleOnDOMSelectionChange.flush() - setTimeout(() => { - state.isComposing && setIsComposing(false) - state.isComposing = false - - IS_COMPOSING.set(editor, false) - IS_ON_COMPOSITION_END.set(editor, true) - - const insertedText = - EDITOR_ON_COMPOSITION_TEXT.get(editor) || [] - - // `insertedText` is set in `MutationObserver` constructor. - // If open phone keyboard association function, `CompositionEvent` will be triggered. - if (!insertedText.length) { - return - } - - EDITOR_ON_COMPOSITION_TEXT.set(editor, []) - - const { selection } = editor - - insertedText.forEach(insertion => { - const text = insertion.text.insertText - const at = normalizeTextInsertionRange( - editor, - selection, - insertion - ) - Transforms.setSelection(editor, at) - Editor.insertText(editor, text) - }) - }, RESOLVE_DELAY) - } - }, - [attributes.onCompositionEnd] - )} - onCompositionUpdate={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionUpdate) - ) { - !state.isComposing && setIsComposing(true) - state.isComposing = true - IS_COMPOSING.set(editor, true) - } - }, - [attributes.onCompositionUpdate] - )} - onCompositionStart={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionStart) - ) { - !state.isComposing && setIsComposing(true) - state.isComposing = true - IS_COMPOSING.set(editor, true) - } - }, - [attributes.onCompositionStart] - )} - onPaste={useCallback( - (event: React.ClipboardEvent) => { - // this will make application/x-slate-fragment exist when onPaste attributes is passed - event.clipboardData = getClipboardData(event.clipboardData) - // 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 deleted file mode 100644 index 90cd53161..000000000 --- a/packages/slate-react/src/components/android/android-input-manager.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { ReactEditor } from '../../plugin/react-editor' -import { Editor, Range, Transforms, Text } from 'slate' -import { - IS_ON_COMPOSITION_END, - EDITOR_ON_COMPOSITION_TEXT, -} from '../../utils/weak-maps' - -import { DOMNode } from '../../utils/dom' - -import { - normalizeTextInsertionRange, - combineInsertedText, - TextInsertion, -} from './diff-text' -import { - gatherMutationData, - isDeletion, - isLineBreak, - isRemoveLeafNodes, - isReplaceExpandedSelection, - isTextInsertion, -} from './mutation-detection' - -// 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 - * @param restoreDOM - */ - -export class AndroidInputManager { - constructor(private editor: ReactEditor, private restoreDOM: () => void) { - this.editor = editor - this.restoreDOM = restoreDOM - } - - /** - * 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 - this.restoreDOM() - } - } - - /** - * 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 - - // If it is in composing or after `onCompositionend`, set `EDITOR_ON_COMPOSITION_TEXT` and return. - // Text will be inserted on compositionend event. - if ( - ReactEditor.isComposing(this.editor) || - IS_ON_COMPOSITION_END.get(this.editor) - ) { - EDITOR_ON_COMPOSITION_TEXT.set(this.editor, insertedText) - IS_ON_COMPOSITION_END.set(this.editor, false) - return - } - - // Insert the batched text diffs - insertedText.forEach(insertion => { - const text = insertion.text.insertText - const at = normalizeTextInsertionRange(this.editor, selection, insertion) - Transforms.setSelection(this.editor, at) - Editor.insertText(this.editor, text) - }) - } - - /** - * Handle line breaks - */ - - private insertBreak = () => { - debug('insertBreak') - - const { selection } = this.editor - - Editor.insertBreak(this.editor) - - this.restoreDOM() - - 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) - } - - this.restoreDOM() - } - - /** - * Handle `backspace` that merges blocks - */ - - private deleteBackward = () => { - debug('deleteBackward') - - Editor.deleteBackward(this.editor) - ReactEditor.focus(this.editor) - - this.restoreDOM() - } - - /** - * 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 }) - this.restoreDOM() - } - } - } -} - -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 deleted file mode 100644 index a7a983956..000000000 --- a/packages/slate-react/src/components/android/diff-text.ts +++ /dev/null @@ -1,225 +0,0 @@ -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 deleted file mode 100644 index 77dee9271..000000000 --- a/packages/slate-react/src/components/android/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index bdfd70ae6..000000000 --- a/packages/slate-react/src/components/android/mutation-detection.ts +++ /dev/null @@ -1,142 +0,0 @@ -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/use-android-input-manager.ts b/packages/slate-react/src/components/android/use-android-input-manager.ts deleted file mode 100644 index ea93cdcf7..000000000 --- a/packages/slate-react/src/components/android/use-android-input-manager.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { RefObject, useCallback, useMemo, useRef, useState } from 'react' - -import { useSlateStatic } from '../../hooks/use-slate-static' - -import { AndroidInputManager } from './android-input-manager' -import { useRestoreDom } from './use-restore-dom' -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 { receivedUserInput, onUserInput } = useTrackUserInput() - const restoreDom = useRestoreDom(node, receivedUserInput) - - const inputManager = useMemo( - () => new AndroidInputManager(editor, restoreDom), - [restoreDom, editor] - ) - - const timeoutId = useRef | null>(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-restore-dom.tsx b/packages/slate-react/src/components/android/use-restore-dom.tsx deleted file mode 100644 index 386ac9427..000000000 --- a/packages/slate-react/src/components/android/use-restore-dom.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, { useCallback, useEffect, useRef } from 'react' -import { Node as SlateNode, Path } from 'slate' -import { ReactEditor, useSlateStatic } from '../..' -import { DOMNode, isDOMElement } from '../../utils/dom' -import { ELEMENT_TO_NODE, NODE_TO_RESTORE_DOM } from '../../utils/weak-maps' -import { useMutationObserver } from './use-mutation-observer' - -const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { - childList: true, - characterData: true, - subtree: true, -} - -function findClosestKnowSlateNode(domNode: DOMNode): SlateNode | null { - let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement - - if (domEl && !domEl.hasAttribute('data-slate-node')) { - domEl = domEl.closest(`[data-slate-node]`) - } - - const slateNode = domEl && ELEMENT_TO_NODE.get(domEl as HTMLElement) - if (slateNode) { - return slateNode - } - - // Unknown dom element with a slate-slate-node attribute => the IME - // most likely duplicated the node so we have to restore the parent - return domEl?.parentElement - ? findClosestKnowSlateNode(domEl.parentElement) - : null -} - -export function useRestoreDom( - node: React.RefObject, - receivedUserInput: React.RefObject -) { - const editor = useSlateStatic() - const mutatedNodes = useRef>(new Set()) - - const handleDOMMutation = useCallback((mutations: MutationRecord[]) => { - if (!receivedUserInput.current) { - return - } - - mutations.forEach(({ target }) => { - const slateNode = findClosestKnowSlateNode(target) - if (!slateNode) { - return - } - - return mutatedNodes.current.add(slateNode) - }) - }, []) - - useMutationObserver(node, handleDOMMutation, MUTATION_OBSERVER_CONFIG) - - // Clear mutated nodes on every render - mutatedNodes.current.clear() - const restore = useCallback(() => { - const mutated = Array.from(mutatedNodes.current.values()) - - // Filter out child nodes of nodes that will be restored anyway - const nodesToRestore = mutated.filter( - n => - !mutated.some(m => - Path.isParent( - ReactEditor.findPath(editor, m), - ReactEditor.findPath(editor, n) - ) - ) - ) - - nodesToRestore.forEach(n => { - NODE_TO_RESTORE_DOM.get(n)?.() - }) - - mutatedNodes.current.clear() - }, []) - - return restore -} 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 deleted file mode 100644 index 3f84390fc..000000000 --- a/packages/slate-react/src/components/android/use-track-user-input.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 63a964daf..d03a0bf42 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -1,62 +1,74 @@ -import React, { useEffect, useRef, useMemo, useCallback, useState } from 'react' -import { - Editor, - Element, - NodeEntry, - Node, - Range, - Text, - Transforms, - Path, - RangeRef, -} from 'slate' import getDirection from 'direction' import debounce from 'lodash/debounce' import throttle from 'lodash/throttle' +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' import scrollIntoView from 'scroll-into-view-if-needed' - -import useChildren from '../hooks/use-children' -import Hotkeys from '../utils/hotkeys' import { - HAS_BEFORE_INPUT_SUPPORT, - IS_IOS, - IS_CHROME, - IS_FIREFOX, - IS_FIREFOX_LEGACY, - IS_QQBROWSER, - IS_SAFARI, - IS_UC_MOBILE, - IS_WECHATBROWSER, - CAN_USE_DOM, -} from '../utils/environment' -import { ReactEditor } from '..' + Editor, + Element, + Node, + NodeEntry, + Path, + Range, + Text, + Transforms, +} from 'slate' +import { ReactEditor } from '../plugin/react-editor' +import useChildren from '../hooks/use-children' +import { DecorateContext } from '../hooks/use-decorate' +import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' 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 { TRIPLE_CLICK } from '../utils/constants' import { DOMElement, DOMNode, DOMRange, + DOMText, getDefaultView, isDOMElement, isDOMNode, isPlainTextOnlyPaste, - DOMText, } from '../utils/dom' - +import { + CAN_USE_DOM, + HAS_BEFORE_INPUT_SUPPORT, + IS_ANDROID, + IS_CHROME, + IS_FIREFOX, + IS_FIREFOX_LEGACY, + IS_IOS, + IS_QQBROWSER, + IS_SAFARI, + IS_UC_MOBILE, + IS_WECHATBROWSER, +} from '../utils/environment' +import Hotkeys from '../utils/hotkeys' import { EDITOR_TO_ELEMENT, - ELEMENT_TO_NODE, - IS_READ_ONLY, - NODE_TO_ELEMENT, - IS_FOCUSED, - PLACEHOLDER_SYMBOL, - EDITOR_TO_WINDOW, + EDITOR_TO_FORCE_RENDER, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_USER_MARKS, EDITOR_TO_USER_SELECTION, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, IS_COMPOSING, + IS_FOCUSED, + IS_READ_ONLY, + MARK_PLACEHOLDER_SYMBOL, + NODE_TO_ELEMENT, + PLACEHOLDER_SYMBOL, } from '../utils/weak-maps' -import { TRIPLE_CLICK } from '../utils/constants' +import { RestoreDOM } from './restore-dom/restore-dom' +import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager' +import { useTrackUserInput } from '../hooks/use-track-user-input' type DeferredOperation = () => void @@ -136,126 +148,25 @@ export const Editable = (props: EditableProps) => { const ref = useRef(null) const deferredOperations = useRef([]) + const { onUserInput, receivedUserInput } = useTrackUserInput() + + const [, forceRender] = useReducer(s => s + 1, 0) + EDITOR_TO_FORCE_RENDER.set(editor, forceRender) + // 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( () => ({ - hasInsertPrefixInCompositon: false, isDraggingInternally: false, isUpdatingSelection: false, latestElement: null as DOMElement | null, + hasMarkPlaceholder: false, }), [] ) - // Whenever the editor updates, sync the DOM selection with the slate selection - 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) - } else { - NODE_TO_ELEMENT.delete(editor) - } - - // Make sure the DOM selection state is in sync. - const { selection } = editor - const root = ReactEditor.findDocumentOrShadowRoot(editor) - const domSelection = root.getSelection() - - if ( - ReactEditor.isComposing(editor) || - !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, - - // domSelection is not necessarily a valid Slate range - // (e.g. when clicking on contentEditable:false element) - suppressThrow: 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, - suppressThrow: false, - }) - return - } - - // Otherwise the DOM selection is out of sync, so update it. - 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 - ) - } - scrollSelectionIntoView(editor, newDomRange) - } 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) { - const el = ReactEditor.toDOMNode(editor, editor) - el.focus() - } - - state.isUpdatingSelection = false - }) - }) - // The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it // needs to be manually focused. useEffect(() => { @@ -272,8 +183,8 @@ export const Editable = (props: EditableProps) => { const onDOMSelectionChange = useCallback( throttle(() => { if ( - !ReactEditor.isComposing(editor) && - !state.isUpdatingSelection && + (IS_ANDROID || !ReactEditor.isComposing(editor)) && + (!state.isUpdatingSelection || androidInputManager?.isFlushing()) && !state.isDraggingInternally ) { const root = ReactEditor.findDocumentOrShadowRoot(editor) @@ -305,9 +216,20 @@ export const Editable = (props: EditableProps) => { if (anchorNodeSelectable && focusNodeSelectable) { const range = ReactEditor.toSlateRange(editor, domSelection, { exactMatch: false, - suppressThrow: false, + suppressThrow: true, }) - Transforms.select(editor, range) + + if (range) { + if ( + !ReactEditor.isComposing(editor) && + !androidInputManager?.hasPendingDiffs() && + !androidInputManager?.isFlushing() + ) { + Transforms.select(editor, range) + } else { + androidInputManager?.handleUserSelect(range) + } + } } } }, 100), @@ -319,17 +241,202 @@ export const Editable = (props: EditableProps) => { [onDOMSelectionChange] ) + const androidInputManager = useAndroidInputManager({ + node: ref, + onDOMSelectionChange, + scheduleOnDOMSelectionChange, + }) + + 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) + } else { + NODE_TO_ELEMENT.delete(editor) + } + + // 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) || + androidInputManager?.hasPendingAction() + ) { + return + } + + const setDomSelection = (forceChange?: boolean) => { + 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 && + !forceChange + ) { + const slateRange = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: true, + + // domSelection is not necessarily a valid Slate range + // (e.g. when clicking on contentEditable:false element) + suppressThrow: true, + }) + + if (slateRange && Range.equals(slateRange, selection)) { + if (!state.hasMarkPlaceholder) { + return + } + + // Ensure selection is inside the mark placeholder + const { anchorNode } = domSelection + if ( + anchorNode?.parentElement?.hasAttribute( + 'data-slate-mark-placeholder' + ) + ) { + 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, + suppressThrow: true, + }) + return + } + + // Otherwise the DOM selection is out of sync, so update it. + state.isUpdatingSelection = true + + const newDomRange: DOMRange | null = + 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 + ) + } + scrollSelectionIntoView(editor, newDomRange) + } else { + domSelection.removeAllRanges() + } + + return newDomRange + } + + const newDomRange = setDomSelection() + const ensureSelection = androidInputManager?.isFlushing() === 'action' + + if (!IS_ANDROID || !ensureSelection) { + 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) { + const el = ReactEditor.toDOMNode(editor, editor) + el.focus() + } + + state.isUpdatingSelection = false + }) + return + } + + let timeoutId: ReturnType | null = null + const animationFrameId = requestAnimationFrame(() => { + if (ensureSelection) { + const ensureDomSelection = (forceChange?: boolean) => { + try { + const el = ReactEditor.toDOMNode(editor, editor) + el.focus() + + setDomSelection(forceChange) + } catch (e) { + // Ignore, dom and state might be out of sync + } + } + + // Compat: Android IMEs try to force their selection by manually re-applying it even after we set it. + // This essentially would make setting the slate selection during an update meaningless, so we force it + // again here. We can't only do it in the setTimeout after the animation frame since that would cause a + // visible flicker. + ensureDomSelection() + + timeoutId = setTimeout(() => { + // COMPAT: While setting the selection in an animation frame visually correctly sets the selection, + // it doesn't update GBoards spellchecker state. We have to manually trigger a selection change after + // the animation frame to ensure it displays the correct state. + ensureDomSelection(true) + state.isUpdatingSelection = false + }) + } + }) + + return () => { + cancelAnimationFrame(animationFrameId) + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }) + // 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) => { + onUserInput() + if ( !readOnly && hasEditableTarget(editor, event.target) && !isDOMEventHandled(event, propsOnDOMBeforeInput) ) { + // COMPAT: BeforeInput events aren't cancelable on android, so we have to handle them differently using the android input manager. + if (androidInputManager) { + return androidInputManager.handleDOMBeforeInput(event) + } + // Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before // triggering a `beforeinput` expecting the change to be applied to the immediately before // set selection. @@ -616,6 +723,7 @@ export const Editable = (props: EditableProps) => { // https://github.com/facebook/react/issues/5785 useIsomorphicLayoutEffect(() => { const window = ReactEditor.getWindow(editor) + window.document.addEventListener( 'selectionchange', scheduleOnDOMSelectionChange @@ -647,785 +755,839 @@ export const Editable = (props: EditableProps) => { }) } + const { marks } = editor + state.hasMarkPlaceholder = false + + if (editor.selection && Range.isCollapsed(editor.selection) && marks) { + const { anchor } = editor.selection + const { text, ...rest } = Node.leaf(editor, anchor.path) + + if (!Text.equals(rest as Text, marks as Text, { loose: true })) { + state.hasMarkPlaceholder = true + + const unset = Object.fromEntries( + Object.keys(rest).map(mark => [mark, null]) + ) + + decorations.push({ + [MARK_PLACEHOLDER_SYMBOL]: true, + ...unset, + ...marks, + + anchor, + focus: anchor, + }) + } + } + + // Update EDITOR_TO_MARK_PLACEHOLDER_MARKS in setTimeout useEffect to ensure we don't set it + // before we receive the composition end event. + useEffect(() => { + setTimeout(() => { + if (marks) { + EDITOR_TO_PENDING_INSERTION_MARKS.set(editor, marks) + } else { + EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) + } + }) + }) + return ( - ) => { - // COMPAT: Certain browsers don't support the `beforeinput` event, so we - // fall back to React's leaky polyfill instead just for it. It - // only works for the `insertText` input type. - if ( - !HAS_BEFORE_INPUT_SUPPORT && - !readOnly && - !isEventHandled(event, attributes.onBeforeInput) && - hasEditableTarget(editor, event.target) - ) { - event.preventDefault() - if (!ReactEditor.isComposing(editor)) { - const text = (event as any).data as string - Editor.insertText(editor, text) - } - } - }, - [readOnly] - )} - onInput={useCallback((event: React.SyntheticEvent) => { - // Flush native operations, as native events will have propogated - // and we can correctly compare DOM text values in components - // to stop rendering, so that browser functions like autocorrect - // and spellcheck work as expected. - for (const op of deferredOperations.current) { - op() + + ) => { - 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 - } - } - - // COMPAT: Safari doesn't always remove the selection even if the content- - // editable element no longer has focus. Refer to: - // https://stackoverflow.com/questions/12353247/force-contenteditable-div-to-stop-accepting-input-after-it-loses-focus-under-web - if (IS_SAFARI) { - const domSelection = root.getSelection() - domSelection?.removeAllRanges() - } - - IS_FOCUSED.delete(editor) - }, - [readOnly, attributes.onBlur] - )} - onClick={useCallback( - (event: React.MouseEvent) => { - if ( - hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onClick) && - isDOMNode(event.target) - ) { - const node = ReactEditor.toSlateNode(editor, event.target) - const path = ReactEditor.findPath(editor, node) - - // At this time, the Slate document may be arbitrarily different, - // because onClick handlers can change the document before we get here. - // Therefore we must check that this path actually exists, - // and that it still refers to the same node. + autoCorrect={ + HAS_BEFORE_INPUT_SUPPORT || !CAN_USE_DOM + ? attributes.autoCorrect + : 'false' + } + autoCapitalize={ + HAS_BEFORE_INPUT_SUPPORT || !CAN_USE_DOM + ? attributes.autoCapitalize + : 'false' + } + data-slate-editor + data-slate-node="value" + // explicitly set this + contentEditable={!readOnly} + // in some cases, a decoration needs access to the range / selection to decorate a text node, + // then you will select the whole text node when you select part the of text + // this magic zIndex="-1" will fix it + zindex={-1} + suppressContentEditableWarning + ref={ref} + style={{ + // Allow positioning relative to the editable element. + position: 'relative', + // Prevent the default outline styles. + outline: 'none', + // Preserve adjacent whitespace and new lines. + whiteSpace: 'pre-wrap', + // Allow words to break if they are too long. + wordWrap: 'break-word', + // Allow for passed-in styles to override anything. + ...style, + }} + onBeforeInput={useCallback( + (event: React.FormEvent) => { + // COMPAT: Certain browsers don't support the `beforeinput` event, so we + // fall back to React's leaky polyfill instead just for it. It + // only works for the `insertText` input type. if ( - !Editor.hasPath(editor, path) || - Node.get(editor, path) !== node + !HAS_BEFORE_INPUT_SUPPORT && + !readOnly && + !isEventHandled(event, attributes.onBeforeInput) && + hasEditableTarget(editor, event.target) ) { - return - } - - if (event.detail === TRIPLE_CLICK && path.length >= 1) { - let blockPath = path - if (!Editor.isBlock(editor, node)) { - const block = Editor.above(editor, { - match: n => Editor.isBlock(editor, n), - at: path, - }) - - blockPath = block?.[1] ?? path.slice(0, 1) - } - - const range = Editor.range(editor, blockPath) - Transforms.select(editor, range) - return - } - - if (readOnly) { - return - } - - const start = Editor.start(editor, path) - const end = Editor.end(editor, path) - const startVoid = Editor.void(editor, { at: start }) - const endVoid = Editor.void(editor, { at: end }) - - if ( - startVoid && - endVoid && - Path.equals(startVoid[1], endVoid[1]) - ) { - const range = Editor.range(editor, start) - Transforms.select(editor, range) - } - } - }, - [readOnly, attributes.onClick] - )} - onCompositionEnd={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionEnd) - ) { - if (ReactEditor.isComposing(editor)) { - setIsComposing(false) - IS_COMPOSING.set(editor, false) - } - - // COMPAT: In Chrome, `beforeinput` events for compositions - // aren't correct and never fire the "insertFromComposition" - // type that we need. So instead, insert whenever a composition - // ends since it will already have been committed to the DOM. - if ( - !IS_SAFARI && - !IS_FIREFOX_LEGACY && - !IS_IOS && - !IS_QQBROWSER && - !IS_WECHATBROWSER && - !IS_UC_MOBILE && - event.data - ) { - Editor.insertText(editor, event.data) - } - - if (editor.selection && Range.isCollapsed(editor.selection)) { - const leafPath = editor.selection.anchor.path - const currentTextNode = Node.leaf(editor, leafPath) - if (state.hasInsertPrefixInCompositon) { - state.hasInsertPrefixInCompositon = false - Editor.withoutNormalizing(editor, () => { - // remove Unicode BOM prefix added in `onCompositionStart` - const text = currentTextNode.text.replace(/^\uFEFF/, '') - Transforms.delete(editor, { - distance: currentTextNode.text.length, - reverse: true, - }) - Editor.insertText(editor, text) - }) - } - } - } - }, - [attributes.onCompositionEnd] - )} - onCompositionUpdate={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionUpdate) - ) { - if (!ReactEditor.isComposing(editor)) { - setIsComposing(true) - IS_COMPOSING.set(editor, true) - } - } - }, - [attributes.onCompositionUpdate] - )} - onCompositionStart={useCallback( - (event: React.CompositionEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionStart) - ) { - const { selection, marks } = editor - if (selection) { - if (Range.isExpanded(selection)) { - Editor.deleteFragment(editor) - return - } - const inline = Editor.above(editor, { - match: n => Editor.isInline(editor, n), - mode: 'highest', - }) - if (inline) { - const [, inlinePath] = inline - if (Editor.isEnd(editor, selection.anchor, inlinePath)) { - const point = Editor.after(editor, inlinePath)! - Transforms.setSelection(editor, { - anchor: point, - focus: point, - }) - } - } - // insert new node in advance to ensure composition text will insert - // along with final input text - // add Unicode BOM prefix to avoid normalize removing this node - if (marks) { - state.hasInsertPrefixInCompositon = true - Transforms.insertNodes( - editor, - { - text: '\uFEFF', - ...marks, - }, - { - select: true, - } - ) - } - } - } - }, - [attributes.onCompositionStart] - )} - onCopy={useCallback( - (event: React.ClipboardEvent) => { - if ( - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCopy) - ) { - event.preventDefault() - ReactEditor.setFragmentData(editor, event.clipboardData, 'copy') - } - }, - [attributes.onCopy] - )} - onCut={useCallback( - (event: React.ClipboardEvent) => { - if ( - !readOnly && - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCut) - ) { - event.preventDefault() - ReactEditor.setFragmentData(editor, event.clipboardData, 'cut') - 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] - )} - onDragOver={useCallback( - (event: React.DragEvent) => { - if ( - hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onDragOver) - ) { - // Only when the target is void, call `preventDefault` to signal - // that drops are allowed. Editable content is droppable by - // default, and calling `preventDefault` hides the cursor. - const node = ReactEditor.toSlateNode(editor, event.target) - - if (Editor.isVoid(editor, node)) { event.preventDefault() - } - } - }, - [attributes.onDragOver] - )} - onDragStart={useCallback( - (event: React.DragEvent) => { - if ( - !readOnly && - hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onDragStart) - ) { - const node = ReactEditor.toSlateNode(editor, event.target) - const path = ReactEditor.findPath(editor, node) - const voidMatch = - Editor.isVoid(editor, node) || - Editor.void(editor, { at: path, voids: true }) - - // If starting a drag on a void node, make sure it is selected - // so that it shows up in the selection's fragment. - if (voidMatch) { - const range = Editor.range(editor, path) - Transforms.select(editor, range) - } - - state.isDraggingInternally = true - - ReactEditor.setFragmentData(editor, event.dataTransfer, 'drag') - } - }, - [readOnly, attributes.onDragStart] - )} - onDrop={useCallback( - (event: React.DragEvent) => { - if ( - !readOnly && - hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onDrop) - ) { - event.preventDefault() - - // Keep a reference to the dragged range before updating selection - const draggedRange = editor.selection - - // Find the range where the drop happened - const range = ReactEditor.findEventRange(editor, event) - const data = event.dataTransfer - - Transforms.select(editor, range) - - if (state.isDraggingInternally) { - if ( - draggedRange && - !Range.equals(draggedRange, range) && - !Editor.void(editor, { at: range, voids: true }) - ) { - Transforms.delete(editor, { - at: draggedRange, - }) + if (!ReactEditor.isComposing(editor)) { + const text = (event as any).data as string + Editor.insertText(editor, text) } } + }, + [readOnly] + )} + onInput={useCallback((event: React.SyntheticEvent) => { + if (androidInputManager) { + androidInputManager.handleInput() + return + } - ReactEditor.insertData(editor, data) - - // When dragging from another source into the editor, it's possible - // that the current editor does not have focus. - if (!ReactEditor.isFocused(editor)) { - ReactEditor.focus(editor) + // Flush native operations, as native events will have propogated + // and we can correctly compare DOM text values in components + // to stop rendering, so that browser functions like autocorrect + // and spellcheck work as expected. + for (const op of deferredOperations.current) { + op() + } + deferredOperations.current = [] + }, [])} + onBlur={useCallback( + (event: React.FocusEvent) => { + if ( + readOnly || + state.isUpdatingSelection || + !hasEditableTarget(editor, event.target) || + isEventHandled(event, attributes.onBlur) + ) { + return } - } - state.isDraggingInternally = false - }, - [readOnly, attributes.onDrop] - )} - onDragEnd={useCallback( - (event: React.DragEvent) => { - if ( - !readOnly && - state.isDraggingInternally && - attributes.onDragEnd && - hasTarget(editor, event.target) - ) { - attributes.onDragEnd(event) - } - - // When dropping on a different droppable element than the current editor, - // `onDrop` is not called. So we need to clean up in `onDragEnd` instead. - // Note: `onDragEnd` is only called when `onDrop` is not called - state.isDraggingInternally = false - }, - [readOnly, attributes.onDragEnd] - )} - onFocus={useCallback( - (event: React.FocusEvent) => { - if ( - !readOnly && - !state.isUpdatingSelection && - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onFocus) - ) { - const el = ReactEditor.toDOMNode(editor, editor) + // 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) - state.latestElement = root.activeElement - - // COMPAT: If the editor has nested editable elements, the focus - // can go to them. In Firefox, this must be prevented because it - // results in issues with keyboard navigation. (2017/03/30) - if (IS_FIREFOX && event.target !== el) { - el.focus() + if (state.latestElement === root.activeElement) { return } - IS_FOCUSED.set(editor, true) - } - }, - [readOnly, attributes.onFocus] - )} - onKeyDown={useCallback( - (event: React.KeyboardEvent) => { - if (!readOnly && hasEditableTarget(editor, event.target)) { - const { nativeEvent } = event + const { relatedTarget } = event + const el = ReactEditor.toDOMNode(editor, editor) - // COMPAT: The composition end event isn't fired reliably in all browsers, - // so we sometimes might end up stuck in a composition state even though we - // aren't composing any more. - if ( - ReactEditor.isComposing(editor) && - nativeEvent.isComposing === false - ) { - IS_COMPOSING.set(editor, false) - setIsComposing(false) + // 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 ( - isEventHandled(event, attributes.onKeyDown) || - ReactEditor.isComposing(editor) + isDOMElement(relatedTarget) && + relatedTarget.hasAttribute('data-slate-spacer') ) { return } - const { selection } = editor - const element = - editor.children[ - selection !== null ? selection.focus.path[0] : 0 - ] - const isRTL = getDirection(Node.string(element)) === 'rtl' + // 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) - // COMPAT: Since we prevent the default behavior on - // `beforeinput` events, the browser doesn't think there's ever - // any history stack to undo or redo, so we have to manage these - // hotkeys ourselves. (2019/11/06) - if (Hotkeys.isRedo(nativeEvent)) { - event.preventDefault() - const maybeHistoryEditor: any = editor - - if (typeof maybeHistoryEditor.redo === 'function') { - maybeHistoryEditor.redo() + if (Element.isElement(node) && !editor.isVoid(node)) { + return } - - return } - if (Hotkeys.isUndo(nativeEvent)) { - event.preventDefault() - const maybeHistoryEditor: any = editor - - if (typeof maybeHistoryEditor.undo === 'function') { - maybeHistoryEditor.undo() - } - - return + // COMPAT: Safari doesn't always remove the selection even if the content- + // editable element no longer has focus. Refer to: + // https://stackoverflow.com/questions/12353247/force-contenteditable-div-to-stop-accepting-input-after-it-loses-focus-under-web + if (IS_SAFARI) { + const domSelection = root.getSelection() + domSelection?.removeAllRanges() } - // COMPAT: Certain browsers don't handle the selection updates - // properly. In Chrome, the selection isn't properly extended. - // And in Firefox, the selection isn't properly collapsed. - // (2017/10/17) - if (Hotkeys.isMoveLineBackward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { unit: 'line', reverse: true }) - return - } + IS_FOCUSED.delete(editor) + }, + [readOnly, attributes.onBlur] + )} + onClick={useCallback( + (event: React.MouseEvent) => { + if ( + hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onClick) && + isDOMNode(event.target) + ) { + const node = ReactEditor.toSlateNode(editor, event.target) + const path = ReactEditor.findPath(editor, node) - if (Hotkeys.isMoveLineForward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { unit: 'line' }) - return - } - - if (Hotkeys.isExtendLineBackward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { - unit: 'line', - edge: 'focus', - reverse: true, - }) - return - } - - if (Hotkeys.isExtendLineForward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { unit: 'line', edge: 'focus' }) - return - } - - // COMPAT: If a void node is selected, or a zero-width text node - // adjacent to an inline is selected, we need to handle these - // hotkeys manually because browsers won't be able to skip over - // the void node with the zero-width space not being an empty - // string. - if (Hotkeys.isMoveBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isCollapsed(selection)) { - Transforms.move(editor, { reverse: !isRTL }) - } else { - Transforms.collapse(editor, { edge: 'start' }) - } - - return - } - - if (Hotkeys.isMoveForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isCollapsed(selection)) { - Transforms.move(editor, { reverse: isRTL }) - } else { - Transforms.collapse(editor, { edge: 'end' }) - } - - return - } - - if (Hotkeys.isMoveWordBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Transforms.collapse(editor, { edge: 'focus' }) - } - - Transforms.move(editor, { unit: 'word', reverse: !isRTL }) - return - } - - if (Hotkeys.isMoveWordForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Transforms.collapse(editor, { edge: 'focus' }) - } - - Transforms.move(editor, { unit: 'word', reverse: isRTL }) - return - } - - // COMPAT: Certain browsers don't support the `beforeinput` event, so we - // fall back to guessing at the input intention for hotkeys. - // COMPAT: In iOS, some of these hotkeys are handled in the - if (!HAS_BEFORE_INPUT_SUPPORT) { - // We don't have a core behavior for these, but they change the - // DOM if we don't prevent them, so we have to. + // At this time, the Slate document may be arbitrarily different, + // because onClick handlers can change the document before we get here. + // Therefore we must check that this path actually exists, + // and that it still refers to the same node. if ( - Hotkeys.isBold(nativeEvent) || - Hotkeys.isItalic(nativeEvent) || - Hotkeys.isTransposeCharacter(nativeEvent) + !Editor.hasPath(editor, path) || + Node.get(editor, path) !== node ) { - event.preventDefault() return } - if (Hotkeys.isSoftBreak(nativeEvent)) { - event.preventDefault() - Editor.insertSoftBreak(editor) - return - } + if (event.detail === TRIPLE_CLICK && path.length >= 1) { + let blockPath = path + if (!Editor.isBlock(editor, node)) { + const block = Editor.above(editor, { + match: n => Editor.isBlock(editor, n), + at: path, + }) - if (Hotkeys.isSplitBlock(nativeEvent)) { - event.preventDefault() - Editor.insertBreak(editor) - return - } - - if (Hotkeys.isDeleteBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor) + blockPath = block?.[1] ?? path.slice(0, 1) } + const range = Editor.range(editor, blockPath) + Transforms.select(editor, range) return } - if (Hotkeys.isDeleteForward(nativeEvent)) { - event.preventDefault() + if (readOnly) { + return + } - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor) + const start = Editor.start(editor, path) + const end = Editor.end(editor, path) + const startVoid = Editor.void(editor, { at: start }) + const endVoid = Editor.void(editor, { at: end }) + + if ( + startVoid && + endVoid && + Path.equals(startVoid[1], endVoid[1]) + ) { + const range = Editor.range(editor, start) + Transforms.select(editor, range) + } + } + }, + [readOnly, attributes.onClick] + )} + onCompositionEnd={useCallback( + (event: React.CompositionEvent) => { + if (hasEditableTarget(editor, event.target)) { + if (ReactEditor.isComposing(editor)) { + setIsComposing(false) + IS_COMPOSING.set(editor, false) + } + + androidInputManager?.handleCompositionEnd(event) + + if ( + isEventHandled(event, attributes.onCompositionEnd) || + IS_ANDROID + ) { + return + } + + // COMPAT: In Chrome, `beforeinput` events for compositions + // aren't correct and never fire the "insertFromComposition" + // type that we need. So instead, insert whenever a composition + // ends since it will already have been committed to the DOM. + if ( + !IS_SAFARI && + !IS_FIREFOX_LEGACY && + !IS_IOS && + !IS_QQBROWSER && + !IS_WECHATBROWSER && + !IS_UC_MOBILE && + event.data + ) { + const placeholderMarks = EDITOR_TO_PENDING_INSERTION_MARKS.get( + editor + ) + EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) + + // Ensure we insert text with the marks the user was actually seeing + if (placeholderMarks !== undefined) { + EDITOR_TO_USER_MARKS.set(editor, editor.marks) + editor.marks = placeholderMarks } - return - } + Editor.insertText(editor, event.data) - if (Hotkeys.isDeleteLineBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor, { unit: 'line' }) + const userMarks = EDITOR_TO_USER_MARKS.get(editor) + EDITOR_TO_USER_MARKS.delete(editor) + if (userMarks !== undefined) { + editor.marks = userMarks } + } + } + }, + [attributes.onCompositionEnd] + )} + onCompositionUpdate={useCallback( + (event: React.CompositionEvent) => { + if ( + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCompositionUpdate) + ) { + if (!ReactEditor.isComposing(editor)) { + setIsComposing(true) + IS_COMPOSING.set(editor, true) + } + } + }, + [attributes.onCompositionUpdate] + )} + onCompositionStart={useCallback( + (event: React.CompositionEvent) => { + if (hasEditableTarget(editor, event.target)) { + androidInputManager?.handleCompositionStart(event) + if ( + isEventHandled(event, attributes.onCompositionStart) || + IS_ANDROID + ) { return } - if (Hotkeys.isDeleteLineForward(nativeEvent)) { - event.preventDefault() + setIsComposing(true) - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor, { unit: 'line' }) + const { selection } = editor + if (selection) { + if (Range.isExpanded(selection)) { + Editor.deleteFragment(editor) + return } - - return - } - - if (Hotkeys.isDeleteWordBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor, { unit: 'word' }) - } - - return - } - - if (Hotkeys.isDeleteWordForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor, { unit: 'word' }) - } - - return - } - } else { - if (IS_CHROME || IS_SAFARI) { - // COMPAT: Chrome and Safari support `beforeinput` event but do not fire - // an event when deleting backwards in a selected void inline node - if ( - selection && - (Hotkeys.isDeleteBackward(nativeEvent) || - Hotkeys.isDeleteForward(nativeEvent)) && - Range.isCollapsed(selection) - ) { - const currentNode = Node.parent( - editor, - selection.anchor.path - ) - - if ( - Element.isElement(currentNode) && - Editor.isVoid(editor, currentNode) && - Editor.isInline(editor, currentNode) - ) { - event.preventDefault() - Editor.deleteBackward(editor, { unit: 'block' }) - - return + const inline = Editor.above(editor, { + match: n => Editor.isInline(editor, n), + mode: 'highest', + }) + if (inline) { + const [, inlinePath] = inline + if (Editor.isEnd(editor, selection.anchor, inlinePath)) { + const point = Editor.after(editor, inlinePath)! + Transforms.setSelection(editor, { + anchor: point, + focus: point, + }) } } } } - } - }, - [readOnly, attributes.onKeyDown] - )} - onPaste={useCallback( - (event: React.ClipboardEvent) => { - if ( - !readOnly && - hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onPaste) - ) { - // COMPAT: Certain browsers don't support the `beforeinput` event, so we - // fall back to React's `onPaste` here instead. - // COMPAT: Firefox, Chrome and Safari don't emit `beforeinput` events - // when "paste without formatting" is used, so fallback. (2020/02/20) + }, + [attributes.onCompositionStart] + )} + onCopy={useCallback( + (event: React.ClipboardEvent) => { if ( - !HAS_BEFORE_INPUT_SUPPORT || - isPlainTextOnlyPaste(event.nativeEvent) + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCopy) ) { event.preventDefault() - ReactEditor.insertData(editor, event.clipboardData) + ReactEditor.setFragmentData( + editor, + event.clipboardData, + 'copy' + ) } - } - }, - [readOnly, attributes.onPaste] - )} - > - - + }, + [attributes.onCopy] + )} + onCut={useCallback( + (event: React.ClipboardEvent) => { + if ( + !readOnly && + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCut) + ) { + event.preventDefault() + ReactEditor.setFragmentData( + editor, + event.clipboardData, + 'cut' + ) + 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] + )} + onDragOver={useCallback( + (event: React.DragEvent) => { + if ( + hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onDragOver) + ) { + // Only when the target is void, call `preventDefault` to signal + // that drops are allowed. Editable content is droppable by + // default, and calling `preventDefault` hides the cursor. + const node = ReactEditor.toSlateNode(editor, event.target) + + if (Editor.isVoid(editor, node)) { + event.preventDefault() + } + } + }, + [attributes.onDragOver] + )} + onDragStart={useCallback( + (event: React.DragEvent) => { + if ( + !readOnly && + hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onDragStart) + ) { + const node = ReactEditor.toSlateNode(editor, event.target) + const path = ReactEditor.findPath(editor, node) + const voidMatch = + Editor.isVoid(editor, node) || + Editor.void(editor, { at: path, voids: true }) + + // If starting a drag on a void node, make sure it is selected + // so that it shows up in the selection's fragment. + if (voidMatch) { + const range = Editor.range(editor, path) + Transforms.select(editor, range) + } + + state.isDraggingInternally = true + + ReactEditor.setFragmentData( + editor, + event.dataTransfer, + 'drag' + ) + } + }, + [readOnly, attributes.onDragStart] + )} + onDrop={useCallback( + (event: React.DragEvent) => { + if ( + !readOnly && + hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onDrop) + ) { + event.preventDefault() + + // Keep a reference to the dragged range before updating selection + const draggedRange = editor.selection + + // Find the range where the drop happened + const range = ReactEditor.findEventRange(editor, event) + const data = event.dataTransfer + + Transforms.select(editor, range) + + if (state.isDraggingInternally) { + if ( + draggedRange && + !Range.equals(draggedRange, range) && + !Editor.void(editor, { at: range, voids: true }) + ) { + Transforms.delete(editor, { + at: draggedRange, + }) + } + } + + ReactEditor.insertData(editor, data) + + // When dragging from another source into the editor, it's possible + // that the current editor does not have focus. + if (!ReactEditor.isFocused(editor)) { + ReactEditor.focus(editor) + } + } + + state.isDraggingInternally = false + }, + [readOnly, attributes.onDrop] + )} + onDragEnd={useCallback( + (event: React.DragEvent) => { + if ( + !readOnly && + state.isDraggingInternally && + attributes.onDragEnd && + hasTarget(editor, event.target) + ) { + attributes.onDragEnd(event) + } + + // When dropping on a different droppable element than the current editor, + // `onDrop` is not called. So we need to clean up in `onDragEnd` instead. + // Note: `onDragEnd` is only called when `onDrop` is not called + state.isDraggingInternally = false + }, + [readOnly, attributes.onDragEnd] + )} + onFocus={useCallback( + (event: React.FocusEvent) => { + if ( + !readOnly && + !state.isUpdatingSelection && + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onFocus) + ) { + const el = ReactEditor.toDOMNode(editor, editor) + const root = ReactEditor.findDocumentOrShadowRoot(editor) + state.latestElement = root.activeElement + + // COMPAT: If the editor has nested editable elements, the focus + // can go to them. In Firefox, this must be prevented because it + // results in issues with keyboard navigation. (2017/03/30) + if (IS_FIREFOX && event.target !== el) { + el.focus() + return + } + + IS_FOCUSED.set(editor, true) + } + }, + [readOnly, attributes.onFocus] + )} + onKeyDown={useCallback( + (event: React.KeyboardEvent) => { + if (!readOnly && hasEditableTarget(editor, event.target)) { + const { nativeEvent } = event + + // COMPAT: The composition end event isn't fired reliably in all browsers, + // so we sometimes might end up stuck in a composition state even though we + // aren't composing any more. + if ( + ReactEditor.isComposing(editor) && + nativeEvent.isComposing === false + ) { + IS_COMPOSING.set(editor, false) + setIsComposing(false) + } + + if ( + isEventHandled(event, attributes.onKeyDown) || + ReactEditor.isComposing(editor) + ) { + return + } + + const { selection } = editor + const element = + editor.children[ + selection !== null ? selection.focus.path[0] : 0 + ] + const isRTL = getDirection(Node.string(element)) === 'rtl' + + // COMPAT: Since we prevent the default behavior on + // `beforeinput` events, the browser doesn't think there's ever + // any history stack to undo or redo, so we have to manage these + // hotkeys ourselves. (2019/11/06) + if (Hotkeys.isRedo(nativeEvent)) { + event.preventDefault() + const maybeHistoryEditor: any = editor + + if (typeof maybeHistoryEditor.redo === 'function') { + maybeHistoryEditor.redo() + } + + return + } + + if (Hotkeys.isUndo(nativeEvent)) { + event.preventDefault() + const maybeHistoryEditor: any = editor + + if (typeof maybeHistoryEditor.undo === 'function') { + maybeHistoryEditor.undo() + } + + return + } + + // COMPAT: Certain browsers don't handle the selection updates + // properly. In Chrome, the selection isn't properly extended. + // And in Firefox, the selection isn't properly collapsed. + // (2017/10/17) + if (Hotkeys.isMoveLineBackward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { unit: 'line', reverse: true }) + return + } + + if (Hotkeys.isMoveLineForward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { unit: 'line' }) + return + } + + if (Hotkeys.isExtendLineBackward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { + unit: 'line', + edge: 'focus', + reverse: true, + }) + return + } + + if (Hotkeys.isExtendLineForward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { unit: 'line', edge: 'focus' }) + return + } + + // COMPAT: If a void node is selected, or a zero-width text node + // adjacent to an inline is selected, we need to handle these + // hotkeys manually because browsers won't be able to skip over + // the void node with the zero-width space not being an empty + // string. + if (Hotkeys.isMoveBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isCollapsed(selection)) { + Transforms.move(editor, { reverse: !isRTL }) + } else { + Transforms.collapse(editor, { edge: 'start' }) + } + + return + } + + if (Hotkeys.isMoveForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isCollapsed(selection)) { + Transforms.move(editor, { reverse: isRTL }) + } else { + Transforms.collapse(editor, { edge: 'end' }) + } + + return + } + + if (Hotkeys.isMoveWordBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Transforms.collapse(editor, { edge: 'focus' }) + } + + Transforms.move(editor, { unit: 'word', reverse: !isRTL }) + return + } + + if (Hotkeys.isMoveWordForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Transforms.collapse(editor, { edge: 'focus' }) + } + + Transforms.move(editor, { unit: 'word', reverse: isRTL }) + return + } + + // COMPAT: Certain browsers don't support the `beforeinput` event, so we + // fall back to guessing at the input intention for hotkeys. + // COMPAT: In iOS, some of these hotkeys are handled in the + if (!HAS_BEFORE_INPUT_SUPPORT) { + // We don't have a core behavior for these, but they change the + // DOM if we don't prevent them, so we have to. + if ( + Hotkeys.isBold(nativeEvent) || + Hotkeys.isItalic(nativeEvent) || + Hotkeys.isTransposeCharacter(nativeEvent) + ) { + event.preventDefault() + return + } + + if (Hotkeys.isSoftBreak(nativeEvent)) { + event.preventDefault() + Editor.insertSoftBreak(editor) + return + } + + if (Hotkeys.isSplitBlock(nativeEvent)) { + event.preventDefault() + Editor.insertBreak(editor) + return + } + + if (Hotkeys.isDeleteBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { direction: 'backward' }) + } else { + Editor.deleteBackward(editor) + } + + return + } + + if (Hotkeys.isDeleteForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { direction: 'forward' }) + } else { + Editor.deleteForward(editor) + } + + return + } + + if (Hotkeys.isDeleteLineBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { direction: 'backward' }) + } else { + Editor.deleteBackward(editor, { unit: 'line' }) + } + + return + } + + if (Hotkeys.isDeleteLineForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { direction: 'forward' }) + } else { + Editor.deleteForward(editor, { unit: 'line' }) + } + + return + } + + if (Hotkeys.isDeleteWordBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { direction: 'backward' }) + } else { + Editor.deleteBackward(editor, { unit: 'word' }) + } + + return + } + + if (Hotkeys.isDeleteWordForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { direction: 'forward' }) + } else { + Editor.deleteForward(editor, { unit: 'word' }) + } + + return + } + } else { + if (IS_CHROME || IS_SAFARI) { + // COMPAT: Chrome and Safari support `beforeinput` event but do not fire + // an event when deleting backwards in a selected void inline node + if ( + selection && + (Hotkeys.isDeleteBackward(nativeEvent) || + Hotkeys.isDeleteForward(nativeEvent)) && + Range.isCollapsed(selection) + ) { + const currentNode = Node.parent( + editor, + selection.anchor.path + ) + + if ( + Element.isElement(currentNode) && + Editor.isVoid(editor, currentNode) && + Editor.isInline(editor, currentNode) + ) { + event.preventDefault() + Editor.deleteBackward(editor, { unit: 'block' }) + + return + } + } + } + } + } + }, + [readOnly, attributes.onKeyDown] + )} + onPaste={useCallback( + (event: React.ClipboardEvent) => { + if ( + !readOnly && + hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onPaste) + ) { + // COMPAT: Certain browsers don't support the `beforeinput` event, so we + // fall back to React's `onPaste` here instead. + // COMPAT: Firefox, Chrome and Safari don't emit `beforeinput` events + // when "paste without formatting" is used, so fallback. (2020/02/20) + if ( + !HAS_BEFORE_INPUT_SUPPORT || + isPlainTextOnlyPaste(event.nativeEvent) + ) { + event.preventDefault() + ReactEditor.insertData(editor, event.clipboardData) + } + } + }, + [readOnly, attributes.onPaste] + )} + > + + + ) @@ -1479,6 +1641,7 @@ const defaultScrollSelectionIntoView = ( scrollIntoView(leafEl, { scrollMode: 'if-needed', }) + // @ts-expect-error an unorthodox delete D: delete leafEl.getBoundingClientRect } diff --git a/packages/slate-react/src/components/element.tsx b/packages/slate-react/src/components/element.tsx index 8fcc6d145..f1ee0790e 100644 --- a/packages/slate-react/src/components/element.tsx +++ b/packages/slate-react/src/components/element.tsx @@ -19,7 +19,6 @@ import { RenderLeafProps, RenderPlaceholderProps, } from './editable' -import { useContentKey } from '../hooks/use-content-key' import { IS_ANDROID } from '../utils/environment' /** @@ -133,14 +132,7 @@ const Element = (props: { } }) - const content = renderElement({ attributes, children, element }) - - if (IS_ANDROID) { - const contentKey = useContentKey(element) - return {content} - } - - return content + return renderElement({ attributes, children, element }) } const MemoizedElement = React.memo(Element, (prev, next) => { diff --git a/packages/slate-react/src/components/leaf.tsx b/packages/slate-react/src/components/leaf.tsx index 704fb2c73..11d2a65d7 100644 --- a/packages/slate-react/src/components/leaf.tsx +++ b/packages/slate-react/src/components/leaf.tsx @@ -1,8 +1,12 @@ import React, { useRef, useEffect } from 'react' import { Element, Text } from 'slate' import String from './string' -import { PLACEHOLDER_SYMBOL } from '../utils/weak-maps' +import { + PLACEHOLDER_SYMBOL, + EDITOR_TO_PLACEHOLDER_ELEMENT, +} from '../utils/weak-maps' import { RenderLeafProps, RenderPlaceholderProps } from './editable' +import { useSlateStatic } from '../hooks/use-slate-static' /** * Individual leaves in a text node with unique formatting. @@ -26,6 +30,7 @@ const Leaf = (props: { } = props const placeholderRef = useRef(null) + const editor = useSlateStatic() useEffect(() => { const placeholderEl = placeholderRef?.current @@ -38,9 +43,11 @@ const Leaf = (props: { } editorEl.style.minHeight = `${placeholderEl.clientHeight}px` + EDITOR_TO_PLACEHOLDER_ELEMENT.set(editor, placeholderEl) return () => { editorEl.style.minHeight = 'auto' + EDITOR_TO_PLACEHOLDER_ELEMENT.delete(editor) } }, [placeholderRef, leaf]) diff --git a/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts new file mode 100644 index 000000000..37285dd67 --- /dev/null +++ b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts @@ -0,0 +1,58 @@ +import { RefObject } from 'react' +import { ReactEditor } from '../../plugin/react-editor' +import { isTrackedMutation } from '../../utils/dom' + +export type RestoreDOMManager = { + registerMutations: (mutations: MutationRecord[]) => void + restoreDOM: () => void + clear: () => void +} + +export const createRestoreDomManager = ( + editor: ReactEditor, + receivedUserInput: RefObject +): RestoreDOMManager => { + let bufferedMutations: MutationRecord[] = [] + + const clear = () => { + bufferedMutations = [] + } + + const registerMutations = (mutations: MutationRecord[]) => { + if (!receivedUserInput.current) { + return + } + + const trackedMutations = mutations.filter(mutation => + isTrackedMutation(editor, mutation, mutations) + ) + + bufferedMutations.push(...trackedMutations) + } + + function restoreDOM() { + bufferedMutations.reverse().forEach(mutation => { + if (mutation.type === 'characterData') { + mutation.target.textContent = mutation.oldValue + return + } + + mutation.removedNodes.forEach(node => { + mutation.target.insertBefore(node, mutation.nextSibling) + }) + + mutation.addedNodes.forEach(node => { + mutation.target.removeChild(node) + }) + }) + + // Clear buffered mutations to ensure we don't undo them twice + clear() + } + + return { + registerMutations, + restoreDOM, + clear, + } +} diff --git a/packages/slate-react/src/components/restore-dom/restore-dom.tsx b/packages/slate-react/src/components/restore-dom/restore-dom.tsx new file mode 100644 index 000000000..571b54e1a --- /dev/null +++ b/packages/slate-react/src/components/restore-dom/restore-dom.tsx @@ -0,0 +1,77 @@ +import React, { Component, ComponentType, ContextType, RefObject } from 'react' +import { EditorContext } from '../../hooks/use-slate-static' +import { IS_ANDROID } from '../../utils/environment' +import { + createRestoreDomManager, + RestoreDOMManager, +} from './restore-dom-manager' + +const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { + subtree: true, + childList: true, + characterData: true, + characterDataOldValue: true, +} + +type RestoreDOMProps = { + receivedUserInput: RefObject + node: RefObject +} + +// We have to use a class component here since we rely on `getSnapshotBeforeUpdate` which has no FC equivalent +// to run code synchronously immediately before react commits the component update to the DOM. +class RestoreDOMComponent extends Component { + static contextType = EditorContext + context: ContextType = null + + private manager: RestoreDOMManager | null = null + private mutationObserver: MutationObserver | null = null + + observe() { + const { node } = this.props + if (!node.current) { + throw new Error('Failed to attach MutationObserver, `node` is undefined') + } + + this.mutationObserver?.observe(node.current, MUTATION_OBSERVER_CONFIG) + } + + componentDidMount() { + const { receivedUserInput } = this.props + const editor = this.context! + + this.manager = createRestoreDomManager(editor, receivedUserInput) + this.mutationObserver = new MutationObserver(this.manager.registerMutations) + + this.observe() + } + + getSnapshotBeforeUpdate() { + const pendingMutations = this.mutationObserver?.takeRecords() + if (pendingMutations?.length) { + this.manager?.registerMutations(pendingMutations) + } + + this.mutationObserver?.disconnect() + this.manager?.restoreDOM() + + return null + } + + componentDidUpdate() { + this.manager?.clear() + this.observe() + } + + componentWillUnmount() { + this.mutationObserver?.disconnect() + } + + render() { + return this.props.children + } +} + +export const RestoreDOM: ComponentType = IS_ANDROID + ? RestoreDOMComponent + : ({ children }) => <>{children} diff --git a/packages/slate-react/src/components/string.tsx b/packages/slate-react/src/components/string.tsx index 7bd09c988..b66de3791 100644 --- a/packages/slate-react/src/components/string.tsx +++ b/packages/slate-react/src/components/string.tsx @@ -3,6 +3,8 @@ import { Editor, Text, Path, Element, Node } from 'slate' import { ReactEditor, useSlateStatic } from '..' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' +import { IS_ANDROID } from '../utils/environment' +import { MARK_PLACEHOLDER_SYMBOL } from '../utils/weak-maps' /** * Leaf content strings. @@ -18,6 +20,7 @@ const String = (props: { const editor = useSlateStatic() const path = ReactEditor.findPath(editor, text) const parentPath = Path.parent(path) + const isMarkPlaceholder = leaf[MARK_PLACEHOLDER_SYMBOL] === true // COMPAT: Render text inside void nodes with a zero-width space. // So the node can contain selection but the text is not visible. @@ -34,14 +37,14 @@ const String = (props: { !editor.isInline(parent) && Editor.string(editor, parentPath) === '' ) { - return + return } // COMPAT: If the text is empty, it's because it's on the edge of an inline // node, so we render a zero-width space so that the selection can be // inserted next to it still. if (leaf.text === '') { - return + return } // COMPAT: Browsers will collapse trailing new lines at the end of blocks, @@ -104,14 +107,25 @@ const TextString = (props: { text: string; isTrailing?: boolean }) => { * Leaf strings without text, render as zero-width strings. */ -const ZeroWidthString = (props: { length?: number; isLineBreak?: boolean }) => { - const { length = 0, isLineBreak = false } = props +export const ZeroWidthString = (props: { + length?: number + isLineBreak?: boolean + isMarkPlaceholder?: boolean +}) => { + const { length = 0, isLineBreak = false, isMarkPlaceholder = false } = props + + const attributes = { + 'data-slate-zero-width': isLineBreak ? 'n' : 'z', + 'data-slate-length': length, + } + + if (isMarkPlaceholder) { + attributes['data-slate-mark-placeholder'] = true + } + return ( - - {'\uFEFF'} + + {!IS_ANDROID || !isLineBreak ? '\uFEFF' : null} {isLineBreak ?
: null}
) diff --git a/packages/slate-react/src/components/text.tsx b/packages/slate-react/src/components/text.tsx index 0d4abaae7..1e0a05437 100644 --- a/packages/slate-react/src/components/text.tsx +++ b/packages/slate-react/src/components/text.tsx @@ -1,18 +1,15 @@ import React, { useRef } from 'react' -import { Range, Element, Text as SlateText } from 'slate' - -import Leaf from './leaf' +import { Element, Range, Text as SlateText } from 'slate' import { ReactEditor, useSlateStatic } from '..' -import { RenderLeafProps, RenderPlaceholderProps } from './editable' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' -import { - NODE_TO_ELEMENT, - ELEMENT_TO_NODE, - EDITOR_TO_KEY_TO_ELEMENT, -} from '../utils/weak-maps' import { isDecoratorRangeListEqual } from '../utils/range-list' -import { useContentKey } from '../hooks/use-content-key' -import { IS_ANDROID } from '../utils/environment' +import { + EDITOR_TO_KEY_TO_ELEMENT, + ELEMENT_TO_NODE, + NODE_TO_ELEMENT, +} from '../utils/weak-maps' +import { RenderLeafProps, RenderPlaceholderProps } from './editable' +import Leaf from './leaf' /** * Text. @@ -69,10 +66,8 @@ const Text = (props: { } }) - const contentKey = IS_ANDROID ? useContentKey(text) : undefined - return ( - + {children} ) diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts new file mode 100644 index 000000000..eb1b32a7e --- /dev/null +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -0,0 +1,644 @@ +import { DebouncedFunc } from 'lodash' +import { Editor, Node, Path, Point, Range, Text, Transforms } from 'slate' +import { ReactEditor } from '../../plugin/react-editor' +import { + mergeStringDiffs, + normalizePoint, + normalizeRange, + normalizeStringDiff, + StringDiff, + targetRange, + TextDiff, + verifyDiffState, +} from '../../utils/diff-text' +import { isDOMSelection, isTrackedMutation } from '../../utils/dom' +import { + EDITOR_TO_FORCE_RENDER, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_PLACEHOLDER_ELEMENT, + EDITOR_TO_USER_MARKS, + IS_COMPOSING, +} from '../../utils/weak-maps' + +export type Action = { at: Point | Range; run: () => void } + +// https://github.com/facebook/draft-js/blob/main/src/component/handlers/composition/DraftEditorCompositionHandler.js#L41 +// When using keyboard English association function, conpositionEnd triggered too fast, resulting in after `insertText` still maintain association state. +const RESOLVE_DELAY = 25 + +// Time with no user interaction before the current user action is considered as done. +const FLUSH_DELAY = 200 + +// Replace with `const debug = console.log` to debug +const debug = (..._: unknown[]) => {} + +export type CreateAndroidInputManagerOptions = { + editor: ReactEditor + + scheduleOnDOMSelectionChange: DebouncedFunc<() => void> + onDOMSelectionChange: DebouncedFunc<() => void> +} + +export type AndroidInputManager = { + flush: () => void + scheduleFlush: () => void + + hasPendingDiffs: () => boolean + hasPendingAction: () => boolean + isFlushing: () => boolean | 'action' + + handleUserSelect: (range: Range | null) => void + handleCompositionEnd: (event: React.CompositionEvent) => void + handleCompositionStart: ( + event: React.CompositionEvent + ) => void + handleDOMBeforeInput: (event: InputEvent) => void + + handleDomMutations: (mutations: MutationRecord[]) => void + handleInput: () => void +} + +export function forceSwiftKeyUpdate(editor: ReactEditor) { + const { document } = ReactEditor.getWindow(editor) + debug('force ime update') + + const div = document.createElement('div') + div.setAttribute('contenteditable', 'true') + div.setAttribute('display', 'none') + div.setAttribute('position', 'absolute') + div.setAttribute('top', '0') + div.setAttribute('left', '0') + div.textContent = ' ' + + document.body.appendChild(div) + const range = document.createRange() + range.selectNodeContents(div) + const selection = window.getSelection() + + selection?.removeAllRanges() + selection?.addRange(range) + div.parentElement?.removeChild(div) + + ReactEditor.focus(editor) +} + +export function createAndroidInputManager({ + editor, + scheduleOnDOMSelectionChange, + onDOMSelectionChange, +}: CreateAndroidInputManagerOptions): AndroidInputManager { + let flushing: 'action' | boolean = false + + let compositionEndTimeoutId: ReturnType | null = null + let flushTimeoutId: ReturnType | null = null + let actionTimeoutId: ReturnType | null = null + let idCounter = 0 + let isInsertAfterMarkPlaceholder = false + + const applyPendingSelection = () => { + const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(editor) + EDITOR_TO_PENDING_SELECTION.delete(editor) + + if (pendingSelection) { + const { selection } = editor + const normalized = normalizeRange(editor, pendingSelection) + + debug('apply pending selection', pendingSelection, normalized) + + if (normalized && (!selection || !Range.equals(normalized, selection))) { + Transforms.select(editor, normalized) + } + } + } + + const performAction = () => { + const action = EDITOR_TO_PENDING_ACTION.get(editor) + EDITOR_TO_PENDING_ACTION.delete(editor) + if (!action) { + return + } + + const target = Point.isPoint(action.at) + ? normalizePoint(editor, action.at) + : normalizeRange(editor, action.at) + + if (!target) { + return + } + + const targetRange = Editor.range(editor, target) + if (!editor.selection || !Range.equals(editor.selection, targetRange)) { + Transforms.select(editor, target) + } + + action.run() + } + + const flush = () => { + if (flushTimeoutId) { + clearTimeout(flushTimeoutId) + flushTimeoutId = null + } + if (actionTimeoutId) { + clearTimeout(actionTimeoutId) + actionTimeoutId = null + } + + if (!hasPendingDiffs() && !hasPendingAction()) { + applyPendingSelection() + return + } + + if (!flushing) { + flushing = true + setTimeout(() => (flushing = false)) + } + if (hasPendingAction()) { + flushing = 'action' + } + + const selectionRef = + editor.selection && + Editor.rangeRef(editor, editor.selection, { affinity: 'forward' }) + EDITOR_TO_USER_MARKS.set(editor, editor.marks) + + debug( + 'flush', + EDITOR_TO_PENDING_ACTION.get(editor), + EDITOR_TO_PENDING_DIFFS.get(editor) + ) + + let scheduleSelectionChange = !!EDITOR_TO_PENDING_DIFFS.get(editor)?.length + + let diff: TextDiff | undefined + while ((diff = EDITOR_TO_PENDING_DIFFS.get(editor)?.[0])) { + const pendingMarks = EDITOR_TO_PENDING_INSERTION_MARKS.get(editor) + + if (pendingMarks !== undefined) { + EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) + editor.marks = pendingMarks + } + + if (pendingMarks) { + isInsertAfterMarkPlaceholder = true + } + + const range = targetRange(diff) + if (!editor.selection || !Range.equals(editor.selection, range)) { + Transforms.select(editor, range) + } + + if (diff.diff.text) { + Editor.insertText(editor, diff.diff.text) + } else { + Editor.deleteFragment(editor) + } + + // Remove diff only after we have applied it to account for it when transforming + // pending ranges. + EDITOR_TO_PENDING_DIFFS.set( + editor, + EDITOR_TO_PENDING_DIFFS.get(editor)?.filter( + ({ id }) => id !== diff!.id + )! + ) + + if (!verifyDiffState(editor, diff)) { + debug('invalid diff state') + scheduleSelectionChange = false + EDITOR_TO_PENDING_ACTION.delete(editor) + EDITOR_TO_USER_MARKS.delete(editor) + flushing = 'action' + + // Ensure we don't restore the pending user (dom) selection + // since the document and dom state do not match. + EDITOR_TO_PENDING_SELECTION.delete(editor) + scheduleOnDOMSelectionChange.cancel() + onDOMSelectionChange.cancel() + selectionRef?.unref() + } + } + + const selection = selectionRef?.unref() + if ( + selection && + (!editor.selection || !Range.equals(selection, editor.selection)) + ) { + Transforms.select(editor, selection) + } + + if (hasPendingAction()) { + performAction() + return + } + + // COMPAT: The selectionChange event is fired after the action is performed, + // so we have to manually schedule it to ensure we don't 'throw away' the selection + // while rendering if we have pending changes. + if (scheduleSelectionChange) { + debug('scheduleOnDOMSelectionChange pending changes') + scheduleOnDOMSelectionChange() + } + + scheduleOnDOMSelectionChange.flush() + onDOMSelectionChange.flush() + + applyPendingSelection() + + const userMarks = EDITOR_TO_USER_MARKS.get(editor) + EDITOR_TO_USER_MARKS.delete(editor) + if (userMarks !== undefined) { + editor.marks = userMarks + } + } + + const handleCompositionEnd = ( + _event: React.CompositionEvent + ) => { + if (compositionEndTimeoutId) { + clearTimeout(compositionEndTimeoutId) + } + + compositionEndTimeoutId = setTimeout(() => { + IS_COMPOSING.set(editor, false) + flush() + }, RESOLVE_DELAY) + } + + const handleCompositionStart = ( + _event: React.CompositionEvent + ) => { + debug('composition start') + + IS_COMPOSING.set(editor, true) + + if (compositionEndTimeoutId) { + clearTimeout(compositionEndTimeoutId) + compositionEndTimeoutId = null + } + } + + const updatePlaceholderVisibility = () => { + const placeholderElement = EDITOR_TO_PLACEHOLDER_ELEMENT.get(editor) + if (!placeholderElement) { + return + } + + if (hasPendingDiffs()) { + placeholderElement.style.visibility = 'hidden' + return + } + + placeholderElement.style.removeProperty('visibility') + } + + const storeDiff = (path: Path, diff: StringDiff) => { + debug('storeDiff', path, diff) + + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) ?? [] + EDITOR_TO_PENDING_DIFFS.set(editor, pendingDiffs) + + const target = Node.leaf(editor, path) + const idx = pendingDiffs.findIndex(change => Path.equals(change.path, path)) + if (idx < 0) { + const normalized = normalizeStringDiff(target.text, diff) + if (normalized) { + pendingDiffs.push({ path, diff, id: idCounter++ }) + } + + updatePlaceholderVisibility() + return + } + + const merged = mergeStringDiffs(target.text, pendingDiffs[idx].diff, diff) + if (!merged) { + pendingDiffs.splice(idx, 1) + updatePlaceholderVisibility() + return + } + + pendingDiffs[idx] = { + ...pendingDiffs[idx], + diff: merged, + } + } + + const scheduleAction = (at: Point | Range, run: () => void): void => { + debug('scheduleAction', { at, run }) + + EDITOR_TO_PENDING_SELECTION.delete(editor) + scheduleOnDOMSelectionChange.cancel() + onDOMSelectionChange.cancel() + + if (hasPendingAction()) { + flush() + } + + EDITOR_TO_PENDING_ACTION.set(editor, { at, run }) + + // COMPAT: When deleting before a non-contenteditable element chrome only fires a beforeinput, + // (no input) and doesn't perform any dom mutations. Without a flush timeout we would never flush + // in this case and thus never actually perform the action. + actionTimeoutId = setTimeout(flush) + } + + const handleDOMBeforeInput = (event: InputEvent): void => { + if (flushTimeoutId) { + clearTimeout(flushTimeoutId) + flushTimeoutId = null + } + + const { inputType: type } = event + let targetRange: Range | null = null + const data = (event as any).dataTransfer || event.data || undefined + + let [nativeTargetRange] = (event as any).getTargetRanges() + if (nativeTargetRange) { + targetRange = ReactEditor.toSlateRange(editor, nativeTargetRange, { + exactMatch: false, + suppressThrow: true, + }) + } + + // COMPAT: SelectionChange event is fired after the action is performed, so we + // have to manually get the selection here to ensure it's up-to-date. + const window = ReactEditor.getWindow(editor) + const domSelection = window.getSelection() + if (!targetRange && domSelection) { + nativeTargetRange = domSelection + targetRange = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + suppressThrow: true, + }) + } + + targetRange = targetRange ?? editor.selection + if (!targetRange) { + return + } + + if (Range.isExpanded(targetRange) && type.startsWith('delete')) { + const [start, end] = Range.edges(targetRange) + const leaf = Node.leaf(editor, start.path) + + if (leaf.text.length === start.offset && end.offset === 0) { + const next = Editor.next(editor, { at: start.path, match: Text.isText }) + if (next && Path.equals(next[1], end.path)) { + targetRange = { anchor: end, focus: end } + } + } + } + + if (Range.isExpanded(targetRange) && type.startsWith('delete')) { + if (Path.equals(targetRange.anchor.path, targetRange.focus.path)) { + const [start, end] = Range.edges(targetRange) + return storeDiff(targetRange.anchor.path, { + text: '', + end: end.offset, + start: start.offset, + }) + } + + const direction = type.endsWith('Backward') ? 'backward' : 'forward' + return scheduleAction(targetRange, () => + Editor.deleteFragment(editor, { direction }) + ) + } + + switch (type) { + case 'deleteByComposition': + case 'deleteByCut': + case 'deleteByDrag': { + return scheduleAction(targetRange, () => Editor.deleteFragment(editor)) + } + + case 'deleteContent': + case 'deleteContentForward': { + const { anchor } = targetRange + if (Range.isCollapsed(targetRange)) { + const targetNode = Node.leaf(editor, anchor.path) + + if (anchor.offset < targetNode.text.length) { + return storeDiff(anchor.path, { + text: '', + start: anchor.offset, + end: anchor.offset + 1, + }) + } + } + + return scheduleAction(targetRange, () => Editor.deleteForward(editor)) + } + + case 'deleteContentBackward': { + const { anchor } = targetRange + + // If we have a mismatch between the native and slate selection being collapsed + // we are most likely deleting a zero-width placeholder and thus should perform it + // as an action to ensure correct behavior (mostly happens with mark placeholders) + const nativeCollapsed = isDOMSelection(nativeTargetRange) + ? nativeTargetRange.isCollapsed + : !!nativeTargetRange?.collapsed + + if ( + nativeCollapsed && + Range.isCollapsed(targetRange) && + anchor.offset > 0 + ) { + return storeDiff(anchor.path, { + text: '', + start: anchor.offset - 1, + end: anchor.offset, + }) + } + + return scheduleAction(targetRange, () => Editor.deleteBackward(editor)) + } + + case 'deleteEntireSoftLine': { + return scheduleAction(targetRange, () => { + Editor.deleteBackward(editor, { unit: 'line' }) + Editor.deleteForward(editor, { unit: 'line' }) + }) + } + + case 'deleteHardLineBackward': { + return scheduleAction(targetRange, () => + Editor.deleteBackward(editor, { unit: 'block' }) + ) + } + + case 'deleteSoftLineBackward': { + return scheduleAction(targetRange, () => + Editor.deleteBackward(editor, { unit: 'line' }) + ) + } + + case 'deleteHardLineForward': { + return scheduleAction(targetRange, () => + Editor.deleteForward(editor, { unit: 'block' }) + ) + } + + case 'deleteSoftLineForward': { + return scheduleAction(targetRange, () => + Editor.deleteForward(editor, { unit: 'line' }) + ) + } + + case 'deleteWordBackward': { + return scheduleAction(targetRange, () => + Editor.deleteBackward(editor, { unit: 'word' }) + ) + } + + case 'deleteWordForward': { + return scheduleAction(targetRange, () => + Editor.deleteForward(editor, { unit: 'word' }) + ) + } + + case 'insertLineBreak': { + return scheduleAction(targetRange, () => Editor.insertSoftBreak(editor)) + } + + case 'insertParagraph': { + return scheduleAction(targetRange, () => Editor.insertBreak(editor)) + } + case 'insertCompositionText': + case 'deleteCompositionText': + case 'insertFromComposition': + case 'insertFromDrop': + case 'insertFromPaste': + case 'insertFromYank': + case 'insertReplacementText': + case 'insertText': { + if (data?.constructor.name === 'DataTransfer') { + return scheduleAction(targetRange, () => + ReactEditor.insertData(editor, data) + ) + } + + if (typeof data === 'string' && data.includes('\n')) { + return scheduleAction(Range.end(targetRange), () => + Editor.insertSoftBreak(editor) + ) + } + + let text = data ?? '' + + // COMPAT: If we are writing inside a placeholder, the ime inserts the text inside + // the placeholder itself and thus includes the zero-width space inside edit events. + if (EDITOR_TO_PENDING_INSERTION_MARKS.get(editor)) { + text = text.replace('\uFEFF', '') + } + + if (Path.equals(targetRange.anchor.path, targetRange.focus.path)) { + // COMPAT: Swiftkey has a weird bug where the target range of the 2nd word + // inserted after a mark placeholder is inserted with a anchor offset off by 1. + // So writing 'some text' will result in 'some ttext'. If we force a IME update + // after inserting the first word, swiftkey will insert with the correct offset + if (text.endsWith(' ') && isInsertAfterMarkPlaceholder) { + isInsertAfterMarkPlaceholder = false + forceSwiftKeyUpdate(editor) + return scheduleAction(targetRange, () => + Editor.insertText(editor, text) + ) + } + + const [start, end] = Range.edges(targetRange) + return storeDiff(start.path, { + start: start.offset, + end: end.offset, + text, + }) + } + + return scheduleAction(targetRange, () => + Editor.insertText(editor, text) + ) + } + } + } + + const hasPendingAction = () => { + return !!EDITOR_TO_PENDING_ACTION.get(editor) || !!actionTimeoutId + } + + const hasPendingDiffs = () => { + return !!EDITOR_TO_PENDING_DIFFS.get(editor)?.length + } + + const isFlushing = () => { + return flushing + } + + const handleUserSelect = (range: Range | null) => { + EDITOR_TO_PENDING_SELECTION.set(editor, range) + + if (flushTimeoutId) { + clearTimeout(flushTimeoutId) + flushTimeoutId = null + } + + const pathChanged = + range && + (!editor.selection || + !Path.equals(editor.selection.anchor.path, range?.anchor.path)) + + if (pathChanged) { + isInsertAfterMarkPlaceholder = false + } + + if (pathChanged || !hasPendingDiffs()) { + flushTimeoutId = setTimeout(flush, FLUSH_DELAY) + } + } + + const handleInput = () => { + if (hasPendingAction() || !hasPendingDiffs()) { + debug('flush input') + flush() + } + } + + const scheduleFlush = () => { + if (!hasPendingAction()) { + actionTimeoutId = setTimeout(flush) + } + } + + const handleDomMutations = (mutations: MutationRecord[]) => { + if (hasPendingDiffs() || hasPendingAction()) { + return + } + + if ( + mutations.some(mutation => isTrackedMutation(editor, mutation, mutations)) + ) { + // Cause a re-render to restore the dom state if we encounter tracked mutations without + // a corresponding pending action. + EDITOR_TO_FORCE_RENDER.get(editor)?.() + } + } + + return { + flush, + scheduleFlush, + + hasPendingDiffs, + hasPendingAction, + isFlushing, + + handleUserSelect, + handleCompositionEnd, + handleCompositionStart, + handleDOMBeforeInput, + + handleDomMutations, + handleInput, + } +} diff --git a/packages/slate-react/src/hooks/android-input-manager/use-android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/use-android-input-manager.ts new file mode 100644 index 000000000..7091c3b75 --- /dev/null +++ b/packages/slate-react/src/hooks/android-input-manager/use-android-input-manager.ts @@ -0,0 +1,55 @@ +import { RefObject, useState } from 'react' +import { useSlateStatic } from '../use-slate-static' +import { IS_ANDROID } from '../../utils/environment' +import { EDITOR_TO_SCHEDULE_FLUSH } from '../../utils/weak-maps' +import { + createAndroidInputManager, + CreateAndroidInputManagerOptions, +} from './android-input-manager' +import { useIsMounted } from '../use-is-mounted' +import { useMutationObserver } from '../use-mutation-observer' + +type UseAndroidInputManagerOptions = { + node: RefObject +} & Omit< + CreateAndroidInputManagerOptions, + 'editor' | 'onUserInput' | 'receivedUserInput' +> + +const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { + subtree: true, + childList: true, + characterData: true, +} + +export function useAndroidInputManager({ + node, + ...options +}: UseAndroidInputManagerOptions) { + if (!IS_ANDROID) { + return null + } + + const editor = useSlateStatic() + const isMounted = useIsMounted() + + const [inputManager] = useState(() => + createAndroidInputManager({ + editor, + ...options, + }) + ) + + useMutationObserver( + node, + inputManager.handleDomMutations, + MUTATION_OBSERVER_CONFIG + ) + + EDITOR_TO_SCHEDULE_FLUSH.set(editor, inputManager.scheduleFlush) + if (isMounted) { + inputManager.flush() + } + + return inputManager +} diff --git a/packages/slate-react/src/hooks/use-children.tsx b/packages/slate-react/src/hooks/use-children.tsx index 4153e71c3..e9997b94a 100644 --- a/packages/slate-react/src/hooks/use-children.tsx +++ b/packages/slate-react/src/hooks/use-children.tsx @@ -3,7 +3,7 @@ import { Editor, Range, Element, Ancestor, Descendant } from 'slate' import ElementComponent from '../components/element' import TextComponent from '../components/text' -import { ReactEditor } from '..' +import { ReactEditor } from '../plugin/react-editor' import { useSlateStatic } from './use-slate-static' import { useDecorate } from './use-decorate' import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps' diff --git a/packages/slate-react/src/hooks/use-content-key.ts b/packages/slate-react/src/hooks/use-content-key.ts deleted file mode 100644 index f601947b2..000000000 --- a/packages/slate-react/src/hooks/use-content-key.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { Node as SlateNode } from 'slate' -import { NODE_TO_RESTORE_DOM } from '../utils/weak-maps' - -export function useContentKey(node: SlateNode) { - const contentKeyRef = useRef(0) - const updateAnimationFrameRef = useRef(null) - - const [, setForceRerenderCounter] = useState(0) - - useEffect(() => { - NODE_TO_RESTORE_DOM.set(node, () => { - // Update is already queued and node hasn't re-render yet - if (updateAnimationFrameRef.current) { - return - } - - updateAnimationFrameRef.current = requestAnimationFrame(() => { - setForceRerenderCounter(state => state + 1) - updateAnimationFrameRef.current = null - }) - - contentKeyRef.current++ - }) - - return () => { - NODE_TO_RESTORE_DOM.delete(node) - } - }, [node]) - - // Node was restored => clear scheduled update - if (updateAnimationFrameRef.current) { - cancelAnimationFrame(updateAnimationFrameRef.current) - updateAnimationFrameRef.current = null - } - - return contentKeyRef.current -} diff --git a/packages/slate-react/src/hooks/use-is-mounted.tsx b/packages/slate-react/src/hooks/use-is-mounted.tsx new file mode 100644 index 000000000..8c583a843 --- /dev/null +++ b/packages/slate-react/src/hooks/use-is-mounted.tsx @@ -0,0 +1,14 @@ +import { useEffect, useRef } from 'react' + +export function useIsMounted() { + const isMountedRef = useRef(false) + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + return isMountedRef.current +} diff --git a/packages/slate-react/src/components/android/use-mutation-observer.ts b/packages/slate-react/src/hooks/use-mutation-observer.ts similarity index 53% rename from packages/slate-react/src/components/android/use-mutation-observer.ts rename to packages/slate-react/src/hooks/use-mutation-observer.ts index e7c8e3de8..c7341cf58 100644 --- a/packages/slate-react/src/components/android/use-mutation-observer.ts +++ b/packages/slate-react/src/hooks/use-mutation-observer.ts @@ -1,5 +1,7 @@ import { RefObject, useEffect, useState } from 'react' -import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect' +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect' +import { isDOMElement } from '../utils/dom' +import { ReactEditor } from '../plugin/react-editor' export function useMutationObserver( node: RefObject, @@ -9,8 +11,9 @@ export function useMutationObserver( const [mutationObserver] = useState(() => new MutationObserver(callback)) useIsomorphicLayoutEffect(() => { - // Disconnect mutation observer during render phase - mutationObserver.disconnect() + // Discard mutations caused during render phase. This works due to react calling + // useLayoutEffect synchronously after the render phase before the next tick. + mutationObserver.takeRecords() }) useEffect(() => { @@ -18,10 +21,7 @@ export function useMutationObserver( 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) - }) + return () => mutationObserver.disconnect() + }, []) } diff --git a/packages/slate-react/src/hooks/use-track-user-input.ts b/packages/slate-react/src/hooks/use-track-user-input.ts new file mode 100644 index 000000000..5725eab2b --- /dev/null +++ b/packages/slate-react/src/hooks/use-track-user-input.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useEffect } from 'react' +import { ReactEditor } from '../plugin/react-editor' +import { useSlateStatic } from './use-slate-static' + +export function useTrackUserInput() { + const editor = useSlateStatic() + + const receivedUserInput = useRef(false) + const animationFrameIdRef = useRef(0) + + const onUserInput = useCallback(() => { + if (receivedUserInput.current) { + return + } + + receivedUserInput.current = true + + const window = ReactEditor.getWindow(editor) + window.cancelAnimationFrame(animationFrameIdRef.current) + + animationFrameIdRef.current = window.requestAnimationFrame(() => { + receivedUserInput.current = false + }) + }, []) + + useEffect(() => () => cancelAnimationFrame(animationFrameIdRef.current), []) + + return { + receivedUserInput, + onUserInput, + } +} diff --git a/packages/slate-react/src/index.ts b/packages/slate-react/src/index.ts index 8a96651ea..d8cb331f2 100644 --- a/packages/slate-react/src/index.ts +++ b/packages/slate-react/src/index.ts @@ -1,18 +1,12 @@ // 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, + Editable, RenderElementProps, RenderLeafProps, 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/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index ea1ea3138..fb19eeb3b 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -21,6 +21,8 @@ import { EDITOR_TO_WINDOW, EDITOR_TO_KEY_TO_ELEMENT, IS_COMPOSING, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_PENDING_DIFFS, } from '../utils/weak-maps' import { DOMElement, @@ -34,7 +36,7 @@ import { normalizeDOMPoint, hasShadowRoot, } from '../utils/dom' -import { IS_CHROME, IS_FIREFOX } from '../utils/environment' +import { IS_CHROME, IS_FIREFOX, IS_ANDROID } from '../utils/environment' /** * A React and DOM-specific version of the `Editor` interface. @@ -324,7 +326,8 @@ export const ReactEditor = { const texts = Array.from(el.querySelectorAll(selector)) let start = 0 - for (const text of texts) { + for (let i = 0; i < texts.length; i++) { + const text = texts[i] const domNode = text.childNodes[0] as HTMLElement if (domNode == null || domNode.textContent == null) { @@ -336,6 +339,20 @@ export const ReactEditor = { const trueLength = attr == null ? length : parseInt(attr, 10) const end = start + trueLength + // Prefer putting the selection inside the mark placeholder to ensure + // composed text is displayed with the correct marks. + const nextText = texts[i + 1] + if ( + point.offset === end && + nextText?.hasAttribute('data-slate-mark-placeholder') + ) { + domPoint = [ + nextText, + nextText.textContent?.startsWith('\uFEFF') ? 1 : 0, + ] + break + } + if (point.offset <= end) { const offset = Math.min(length, Math.max(0, point.offset - start)) domPoint = [domNode, offset] @@ -540,6 +557,22 @@ export const ReactEditor = { ] removals.forEach(el => { + // COMPAT: While composing at the start of a text node, some keyboards put + // the text content inside the zero width space. + if ( + IS_ANDROID && + !exactMatch && + el.hasAttribute('data-slate-zero-width') && + el.textContent.length > 0 && + el.textContext !== '\uFEFF' + ) { + if (el.textContent.startsWith('\uFEFF')) { + el.textContent = el.textContent.slice(1) + } + + return + } + el!.parentNode!.removeChild(el) }) @@ -580,6 +613,11 @@ export const ReactEditor = { if ( domNode && offset === domNode.textContent!.length && + // COMPAT: Android IMEs might remove the zero width space while composing, + // and we don't add it for line-breaks. + IS_ANDROID && + domNode.getAttribute('data-slate-zero-width') === 'z' && + domNode.textContent?.startsWith('\uFEFF') && // COMPAT: If the parent node is a Slate zero-width space, editor is // because the text node should have no characters. However, during IME // composition the ASCII characters will be prepended to the zero-width @@ -595,6 +633,26 @@ export const ReactEditor = { } } + if (IS_ANDROID && !textNode && !exactMatch) { + const node = parentNode.hasAttribute('data-slate-node') + ? parentNode + : parentNode.closest('[data-slate-node]') + + if (node && ReactEditor.hasDOMNode(editor, node, { editable: true })) { + const slateNode = ReactEditor.toSlateNode(editor, node) + let { path, offset } = Editor.start( + editor, + ReactEditor.findPath(editor, slateNode) + ) + + if (!node.querySelector('[data-slate-leaf]')) { + offset = nearestOffset + } + + return { path, offset } as T extends true ? Point | null : Point + } + } + if (!textNode) { if (suppressThrow) { return null as T extends true ? Point | null : Point @@ -713,4 +771,18 @@ export const ReactEditor = { Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path) ) }, + + /** + * Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time. + */ + androidScheduleFlush(editor: Editor) { + EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.() + }, + + /** + * Experimental and android specific: Get pending diffs + */ + androidPendingDiffs(editor: Editor) { + return EDITOR_TO_PENDING_DIFFS.get(editor) + }, } diff --git a/packages/slate-react/src/plugin/with-react.ts b/packages/slate-react/src/plugin/with-react.ts index 2a757f3ea..a40f5d012 100644 --- a/packages/slate-react/src/plugin/with-react.ts +++ b/packages/slate-react/src/plugin/with-react.ts @@ -1,21 +1,31 @@ import ReactDOM from 'react-dom' -import { Editor, Node, Path, Operation, Transforms, Range } from 'slate' - -import { ReactEditor } from './react-editor' +import { Editor, Node, Operation, Path, Point, Range, Transforms } from 'slate' +import { + TextDiff, + transformPendingPoint, + transformPendingRange, + transformTextDiff, +} from '../utils/diff-text' +import { + getPlainText, + getSlateFragmentAttribute, + isDOMText, +} from '../utils/dom' import { Key } from '../utils/key' +import { findCurrentLineRange } from '../utils/lines' import { EDITOR_TO_KEY_TO_ELEMENT, EDITOR_TO_ON_CHANGE, - NODE_TO_KEY, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_USER_MARKS, EDITOR_TO_USER_SELECTION, + NODE_TO_KEY, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_PENDING_INSERTION_MARKS, } from '../utils/weak-maps' -import { - isDOMText, - getPlainText, - getSlateFragmentAttribute, -} from '../utils/dom' -import { findCurrentLineRange } from '../utils/lines' - +import { ReactEditor } from './react-editor' /** * `withReact` adds React and DOM specific behaviors to the editor. * @@ -27,12 +37,44 @@ import { findCurrentLineRange } from '../utils/lines' export const withReact = (editor: T) => { const e = editor as T & ReactEditor - const { apply, onChange, deleteBackward } = e + const { apply, onChange, deleteBackward, addMark, removeMark } = e // The WeakMap which maps a key to a specific HTMLElement must be scoped to the editor instance to // avoid collisions between editors in the DOM that share the same value. EDITOR_TO_KEY_TO_ELEMENT.set(e, new WeakMap()) + e.addMark = (key, value) => { + EDITOR_TO_SCHEDULE_FLUSH.get(e)?.() + + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(editor) + + addMark(key, value) + } + + e.removeMark = key => { + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(editor) + + removeMark(key) + } + e.deleteBackward = unit => { if (unit !== 'line') { return deleteBackward(unit) @@ -66,6 +108,31 @@ export const withReact = (editor: T) => { e.apply = (op: Operation) => { const matches: [Path, Key][] = [] + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) + if (pendingDiffs?.length) { + const transformed = pendingDiffs + .map(textDiff => transformTextDiff(textDiff, op)) + .filter(Boolean) as TextDiff[] + + EDITOR_TO_PENDING_DIFFS.set(editor, transformed) + } + + const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(editor) + if (pendingSelection) { + EDITOR_TO_PENDING_SELECTION.set( + editor, + transformPendingRange(editor, pendingSelection, op) + ) + } + const pendingAction = EDITOR_TO_PENDING_ACTION.get(editor) + if (pendingAction) { + const at = Point.isPoint(pendingAction?.at) + ? transformPendingPoint(editor, pendingAction.at, op) + : transformPendingRange(editor, pendingAction.at, op) + + EDITOR_TO_PENDING_ACTION.set(editor, at ? { ...pendingAction, at } : null) + } + switch (op.type) { case 'insert_text': case 'remove_text': diff --git a/packages/slate-react/src/utils/diff-text.ts b/packages/slate-react/src/utils/diff-text.ts new file mode 100644 index 000000000..a5dc92a42 --- /dev/null +++ b/packages/slate-react/src/utils/diff-text.ts @@ -0,0 +1,417 @@ +import { Editor, Node, Operation, Path, Point, Range, Text } from 'slate' +import { EDITOR_TO_PENDING_DIFFS } from './weak-maps' + +export type StringDiff = { + start: number + end: number + text: string +} + +export type TextDiff = { + id: number + path: Path + diff: StringDiff +} + +/** + * Check whether a text diff was applied in a way we can perform the pending action on / + * recover the pending selection. + */ +export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean { + const { path, diff } = textDiff + if (!Editor.hasPath(editor, path)) { + return false + } + + const node = Node.get(editor, path) + if (!Text.isText(node)) { + return false + } + + if (diff.start !== node.text.length || diff.text.length === 0) { + return ( + node.text.slice(diff.start, diff.start + diff.text.length) === diff.text + ) + } + + const nextPath = Path.next(path) + if (!Editor.hasPath(editor, nextPath)) { + return false + } + + const nextNode = Node.get(editor, nextPath) + return Text.isText(nextNode) && nextNode.text.startsWith(diff.text) +} + +function applyStringDiff(text: string, ...diffs: StringDiff[]) { + return diffs.reduce( + (text, diff) => + text.slice(0, diff.start) + diff.text + text.slice(diff.end), + text + ) +} + +function longestCommonPrefixLength(str: string, another: string) { + const length = Math.min(str.length, another.length) + + for (let i = 0; i < length; i++) { + if (str.charAt(i) !== another.charAt(i)) { + return i + } + } + + return length +} + +function longestCommonSuffixLength( + str: string, + another: string, + max: number +): number { + const length = Math.min(str.length, another.length, max) + + for (let i = 0; i < length; i++) { + if ( + str.charAt(str.length - i - 1) !== another.charAt(another.length - i - 1) + ) { + return i + } + } + + return length +} + +/** + * Remove redundant changes from the diff so that it spans the minimal possible range + */ +export function normalizeStringDiff(targetText: string, diff: StringDiff) { + const { start, end, text } = diff + const removedText = targetText.slice(start, end) + + const prefixLength = longestCommonPrefixLength(removedText, text) + const max = Math.min( + removedText.length - prefixLength, + text.length - prefixLength + ) + const suffixLength = longestCommonSuffixLength(removedText, text, max) + + const normalized: StringDiff = { + start: start + prefixLength, + end: end - suffixLength, + text: text.slice(prefixLength, text.length - suffixLength), + } + + if (normalized.start === normalized.end && normalized.text.length === 0) { + return null + } + + return normalized +} + +/** + * Return a string diff that is equivalent to applying b after a spanning the range of + * both changes + */ +export function mergeStringDiffs( + targetText: string, + a: StringDiff, + b: StringDiff +): StringDiff | null { + const start = Math.min(a.start, b.start) + const overlap = Math.max( + 0, + Math.min(a.start + a.text.length, b.end) - b.start + ) + + const applied = applyStringDiff(targetText, a, b) + const sliceEnd = Math.max( + b.start + b.text.length, + a.start + + a.text.length + + (a.start + a.text.length > b.start ? b.text.length : 0) - + overlap + ) + + const text = applied.slice(start, sliceEnd) + const end = Math.max(a.end, b.end - a.text.length + (a.end - a.start)) + return normalizeStringDiff(targetText, { start, end, text }) +} + +/** + * Get the slate range the text diff spans. + */ +export function targetRange(textDiff: TextDiff): Range { + const { path, diff } = textDiff + return { + anchor: { path, offset: diff.start }, + focus: { path, offset: diff.end }, + } +} + +/** + * Normalize a 'pending point' a.k.a a point based on the dom state before applying + * the pending diffs. Since the pending diffs might have been inserted with different + * marks we have to 'walk' the offset from the starting position to ensure we still + * have a valid point inside the document + */ +export function normalizePoint(editor: Editor, point: Point): Point | null { + let { path, offset } = point + if (!Editor.hasPath(editor, path)) { + return null + } + + let leaf = Node.get(editor, path) + if (!Text.isText(leaf)) { + return null + } + + const parentBlock = Editor.above(editor, { + match: n => Editor.isBlock(editor, n), + at: path, + }) + + if (!parentBlock) { + return null + } + + while (offset > leaf.text.length) { + const entry = Editor.next(editor, { at: path, match: Text.isText }) + if (!entry || !Path.isDescendant(entry[1], parentBlock[1])) { + return null + } + + offset -= leaf.text.length + leaf = entry[0] + path = entry[1] + } + + return { path, offset } +} + +/** + * Normalize a 'pending selection' to ensure it's valid in the current document state. + */ +export function normalizeRange(editor: Editor, range: Range): Range | null { + const anchor = normalizePoint(editor, range.anchor) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return { anchor, focus: anchor } + } + + const focus = normalizePoint(editor, range.focus) + if (!focus) { + return null + } + + return { anchor, focus } +} + +export function transformPendingPoint( + editor: Editor, + point: Point, + op: Operation +): Point | null { + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) + const textDiff = pendingDiffs?.find(({ path }) => + Path.equals(path, point.path) + ) + + if (!textDiff || point.offset <= textDiff.diff.start) { + return Point.transform(point, op, { affinity: 'backward' }) + } + + const { diff } = textDiff + // Point references location inside the diff => transform the point based on the location + // the diff will be applied to and add the offset inside the diff. + if (point.offset <= diff.start + diff.text.length) { + const anchor = { path: point.path, offset: diff.start } + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + + if (!transformed) { + return null + } + + return { + path: transformed.path, + offset: transformed.offset + point.offset - diff.start, + } + } + + // Point references location after the diff + const anchor = { + path: point.path, + offset: point.offset - diff.text.length + diff.end - diff.start, + } + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + if (!transformed) { + return null + } + + if ( + op.type === 'split_node' && + Path.equals(op.path, point.path) && + anchor.offset < op.position && + diff.start < op.position + ) { + return transformed + } + + return { + path: transformed.path, + offset: transformed.offset + diff.text.length - diff.end + diff.start, + } +} + +export function transformPendingRange( + editor: Editor, + range: Range, + op: Operation +): Range | null { + const anchor = transformPendingPoint(editor, range.anchor, op) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return { anchor, focus: anchor } + } + + const focus = transformPendingPoint(editor, range.focus, op) + if (!focus) { + return null + } + + return { anchor, focus } +} + +export function transformTextDiff( + textDiff: TextDiff, + op: Operation +): TextDiff | null { + const { path, diff, id } = textDiff + + switch (op.type) { + case 'insert_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset <= diff.start) { + return { + diff: { + start: op.text.length + diff.start, + end: op.text.length + diff.end, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end + op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'remove_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset + op.text.length <= diff.start) { + return { + diff: { + start: diff.start - op.text.length, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'split_node': { + if (!Path.equals(op.path, path) || op.position >= diff.end) { + return { + diff, + id, + path: Path.transform(path, op, { affinity: 'backward' })!, + } + } + + if (op.position > diff.start) { + return { + diff: { + start: diff.start, + end: Math.min(op.position, diff.end), + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start - op.position, + end: diff.end - op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op, { affinity: 'forward' })!, + } + } + case 'merge_node': { + if (!Path.equals(op.path, path)) { + return { + diff, + id, + path: Path.transform(path, op)!, + } + } + + return { + diff: { + start: diff.start + op.position, + end: diff.end + op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op)!, + } + } + } + + const newPath = Path.transform(path, op) + if (!newPath) { + return null + } + + return { + diff, + path: newPath, + id, + } +} diff --git a/packages/slate-react/src/utils/dom.ts b/packages/slate-react/src/utils/dom.ts index 368c6a905..48e178713 100644 --- a/packages/slate-react/src/utils/dom.ts +++ b/packages/slate-react/src/utils/dom.ts @@ -12,6 +12,7 @@ import DOMText = globalThis.Text import DOMRange = globalThis.Range import DOMSelection = globalThis.Selection import DOMStaticRange = globalThis.StaticRange +import { ReactEditor } from '../plugin/react-editor' export { DOMNode, @@ -264,3 +265,44 @@ export const getClipboardData = (dataTransfer: DataTransfer): DataTransfer => { } return dataTransfer } + +/** + * Check whether a mutation originates from a editable element inside the editor. + */ + +export const isTrackedMutation = ( + editor: ReactEditor, + mutation: MutationRecord, + batch: MutationRecord[] +): boolean => { + const { target } = mutation + if (isDOMElement(target) && target.matches('[contentEditable="false"]')) { + return false + } + + const { document } = ReactEditor.getWindow(editor) + if (document.contains(target)) { + return ReactEditor.hasDOMNode(editor, target, { editable: true }) + } + + const parentMutation = batch.find(({ addedNodes, removedNodes }) => { + for (const node of addedNodes) { + if (node === target || node.contains(target)) { + return true + } + } + + for (const node of removedNodes) { + if (node === target || node.contains(target)) { + return true + } + } + }) + + if (!parentMutation || parentMutation === mutation) { + return false + } + + // Target add/remove is tracked. Track the mutation if we track the parent mutation. + return isTrackedMutation(editor, parentMutation, batch) +} diff --git a/packages/slate-react/src/utils/lines.ts b/packages/slate-react/src/utils/lines.ts index 960d77a55..8831999c1 100644 --- a/packages/slate-react/src/utils/lines.ts +++ b/packages/slate-react/src/utils/lines.ts @@ -3,7 +3,7 @@ */ import { Range, Editor } from 'slate' -import { ReactEditor } from '..' +import { ReactEditor } from '../plugin/react-editor' const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => { const middle = (compareRect.top + compareRect.bottom) / 2 diff --git a/packages/slate-react/src/utils/weak-maps.ts b/packages/slate-react/src/utils/weak-maps.ts index 5c74d906e..8834d7778 100644 --- a/packages/slate-react/src/utils/weak-maps.ts +++ b/packages/slate-react/src/utils/weak-maps.ts @@ -1,6 +1,7 @@ -import { Ancestor, Editor, Node, RangeRef } from 'slate' +import { Ancestor, Editor, Node, Range, RangeRef, Text } from 'slate' +import { Action } from '../hooks/android-input-manager/android-input-manager' +import { TextDiff } from './diff-text' import { Key } from './key' -import { TextInsertion } from '../components/android/diff-text' /** * Two weak maps that allow us rebuild a path given a node. They are populated @@ -17,6 +18,10 @@ export const NODE_TO_PARENT: WeakMap = new WeakMap() export const EDITOR_TO_WINDOW: WeakMap = new WeakMap() export const EDITOR_TO_ELEMENT: WeakMap = new WeakMap() export const EDITOR_TO_PLACEHOLDER: WeakMap = new WeakMap() +export const EDITOR_TO_PLACEHOLDER_ELEMENT: WeakMap< + Editor, + HTMLElement +> = new WeakMap() export const ELEMENT_TO_NODE: WeakMap = new WeakMap() export const NODE_TO_ELEMENT: WeakMap = new WeakMap() export const NODE_TO_KEY: WeakMap = new WeakMap() @@ -34,17 +39,10 @@ export const IS_FOCUSED: WeakMap = new WeakMap() export const IS_DRAGGING: WeakMap = new WeakMap() export const IS_CLICKING: WeakMap = new WeakMap() export const IS_COMPOSING: WeakMap = new WeakMap() -export const IS_ON_COMPOSITION_END: WeakMap = new WeakMap() -export const EDITOR_TO_USER_SELECTION: WeakMap = new WeakMap() - -/** - * Weak maps for saving text on composition stage. - */ - -export const EDITOR_ON_COMPOSITION_TEXT: WeakMap< +export const EDITOR_TO_USER_SELECTION: WeakMap< Editor, - TextInsertion[] + RangeRef | null > = new WeakMap() /** @@ -53,10 +51,51 @@ export const EDITOR_ON_COMPOSITION_TEXT: WeakMap< export const EDITOR_TO_ON_CHANGE = new WeakMap void>() -export const NODE_TO_RESTORE_DOM = new WeakMap void>() +/** + * Weak maps for saving pending state on composition stage. + */ + +export const EDITOR_TO_SCHEDULE_FLUSH: WeakMap< + Editor, + () => void +> = new WeakMap() + +export const EDITOR_TO_PENDING_INSERTION_MARKS: WeakMap< + Editor, + Partial | null +> = new WeakMap() + +export const EDITOR_TO_USER_MARKS: WeakMap< + Editor, + Partial | null +> = new WeakMap() + +/** + * Android input handling specific weak-maps + */ + +export const EDITOR_TO_PENDING_DIFFS: WeakMap< + Editor, + TextDiff[] +> = new WeakMap() + +export const EDITOR_TO_PENDING_ACTION: WeakMap< + Editor, + Action | null +> = new WeakMap() + +export const EDITOR_TO_PENDING_SELECTION: WeakMap< + Editor, + Range | null +> = new WeakMap() + +export const EDITOR_TO_FORCE_RENDER: WeakMap void> = new WeakMap() /** * Symbols. */ export const PLACEHOLDER_SYMBOL = (Symbol('placeholder') as unknown) as string +export const MARK_PLACEHOLDER_SYMBOL = (Symbol( + 'mark-placeholder' +) as unknown) as string diff --git a/packages/slate-react/test/index.spec.tsx b/packages/slate-react/test/index.spec.tsx index 6ac7c43d0..0270c9b14 100644 --- a/packages/slate-react/test/index.spec.tsx +++ b/packages/slate-react/test/index.spec.tsx @@ -1,7 +1,7 @@ import React from 'react' import { createEditor, Element, Transforms } from 'slate' import { create, act, ReactTestRenderer } from 'react-test-renderer' -import { Slate, withReact, DefaultEditable } from '../src' +import { Slate, withReact, Editable } from '../src' const createNodeMock = () => ({ ownerDocument: global.document, @@ -21,7 +21,7 @@ describe('slate-react', () => { act(() => { el = create( {}}> - { React.useEffect(() => mounts(element), []) @@ -55,7 +55,7 @@ describe('slate-react', () => { act(() => { el = create( {}}> - { React.useEffect(() => mounts(element), []) diff --git a/packages/slate/src/interfaces/editor.ts b/packages/slate/src/interfaces/editor.ts index 5ce74c7f3..6bb87b4cb 100644 --- a/packages/slate/src/interfaces/editor.ts +++ b/packages/slate/src/interfaces/editor.ts @@ -601,11 +601,15 @@ export const Editor: EditorInterface = { */ isEditor(value: any): value is Editor { - if (!isPlainObject(value)) return false const cachedIsEditor = IS_EDITOR_CACHE.get(value) if (cachedIsEditor !== undefined) { return cachedIsEditor } + + if (!isPlainObject(value)) { + return false + } + const isEditor = typeof value.addMark === 'function' && typeof value.apply === 'function' && diff --git a/site/examples/markdown-shortcuts.tsx b/site/examples/markdown-shortcuts.tsx index 80e5f1672..9b5452a0d 100644 --- a/site/examples/markdown-shortcuts.tsx +++ b/site/examples/markdown-shortcuts.tsx @@ -1,15 +1,16 @@ import React, { useCallback, useMemo } from 'react' -import { Slate, Editable, withReact } from 'slate-react' import { - Editor, - Transforms, - Range, - Point, createEditor, - Element as SlateElement, Descendant, + Editor, + Element as SlateElement, + Node as SlateNode, + Point, + Range, + Transforms, } from 'slate' import { withHistory } from 'slate-history' +import { Editable, ReactEditor, Slate, withReact } from 'slate-react' import { BulletedListElement } from './custom-types' const SHORTCUTS = { @@ -31,9 +32,44 @@ const MarkdownShortcutsExample = () => { () => withShortcuts(withReact(withHistory(createEditor()))), [] ) + + const handleDOMBeforeInput = useCallback((e: InputEvent) => { + queueMicrotask(() => { + const pendingDiffs = ReactEditor.androidPendingDiffs(editor) + + const scheduleFlush = pendingDiffs?.some(({ diff, path }) => { + if (!diff.text.endsWith(' ')) { + return false + } + + const { text } = SlateNode.leaf(editor, path) + const beforeText = text.slice(0, diff.start) + diff.text.slice(0, -1) + if (!(beforeText in SHORTCUTS)) { + return + } + + const blockEntry = Editor.above(editor, { + at: path, + match: n => Editor.isBlock(editor, n), + }) + if (!blockEntry) { + return false + } + + const [, blockPath] = blockEntry + return Editor.isStart(editor, Editor.start(editor, path), blockPath) + }) + + if (scheduleFlush) { + ReactEditor.androidScheduleFlush(editor) + } + }) + }, []) + return ( { editor.insertText = text => { const { selection } = editor - if (text === ' ' && selection && Range.isCollapsed(selection)) { + if (text.endsWith(' ') && selection && Range.isCollapsed(selection)) { const { anchor } = selection const block = Editor.above(editor, { match: n => Editor.isBlock(editor, n), @@ -57,12 +93,16 @@ const withShortcuts = editor => { const path = block ? block[1] : [] const start = Editor.start(editor, path) const range = { anchor, focus: start } - const beforeText = Editor.string(editor, range) + const beforeText = Editor.string(editor, range) + text.slice(0, -1) const type = SHORTCUTS[beforeText] if (type) { Transforms.select(editor, range) - Transforms.delete(editor) + + if (!Range.isCollapsed(range)) { + Transforms.delete(editor) + } + const newProperties: Partial = { type, }