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,
}