diff --git a/.changeset/mighty-zebras-relax.md b/.changeset/mighty-zebras-relax.md new file mode 100644 index 000000000..2a829ee48 --- /dev/null +++ b/.changeset/mighty-zebras-relax.md @@ -0,0 +1,5 @@ +--- +'slate-react': minor +--- + +Use native character insertion to fix browser/OS text features diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index ffd689955..d862b1853 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -47,6 +47,7 @@ import { PLACEHOLDER_SYMBOL, EDITOR_TO_WINDOW, } from '../utils/weak-maps' +import { asNative, flushNativeEvents } from '../utils/native' const Children = (props: Parameters[0]) => ( {useChildren(props)} @@ -267,7 +268,49 @@ export const Editable = (props: EditableProps) => { return } - event.preventDefault() + let native = false + if ( + type === 'insertText' && + selection && + Range.isCollapsed(selection) && + // Only use native character insertion for single characters a-z or space for now. + // Long-press events (hold a + press 4 = รค) to choose a special character otherwise + // causes duplicate inserts. + event.data && + event.data.length === 1 && + /[a-z ]/i.test(event.data) && + // Chrome seems to have issues correctly editing the start of nodes. + // When there is an inline element, e.g. a link, and you select + // right after it (the start of the next node). + selection.anchor.offset !== 0 + ) { + native = true + + // Skip native if there are marks, as + // `insertText` will insert a node, not just text. + if (editor.marks) { + native = false + } + + // and because of the selection moving in `insertText` (create-editor.ts). + const { anchor } = selection + const inline = Editor.above(editor, { + at: anchor, + match: n => Editor.isInline(editor, n), + mode: 'highest', + }) + if (inline) { + const [, inlinePath] = inline + + if (Editor.isEnd(editor, selection.anchor, inlinePath)) { + native = false + } + } + } + + if (!native) { + event.preventDefault() + } // COMPAT: For the deleting forward/backward input types we don't want // to change the selection because it is the range that will be deleted, @@ -379,7 +422,13 @@ export const Editable = (props: EditableProps) => { if (data instanceof window.DataTransfer) { ReactEditor.insertData(editor, data as DataTransfer) } else if (typeof data === 'string') { - Editor.insertText(editor, data) + // Only insertText operations use the native functionality, for now. + // Potentially expand to single character deletes, as well. + if (native) { + asNative(editor, () => Editor.insertText(editor, data)) + } else { + Editor.insertText(editor, data) + } } break @@ -551,6 +600,13 @@ export const Editable = (props: EditableProps) => { }, [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. + flushNativeEvents(editor) + }, [])} onBlur={useCallback( (event: React.FocusEvent) => { if ( diff --git a/packages/slate-react/src/components/leaf.tsx b/packages/slate-react/src/components/leaf.tsx index dcb6c4fa1..704fb2c73 100644 --- a/packages/slate-react/src/components/leaf.tsx +++ b/packages/slate-react/src/components/leaf.tsx @@ -4,9 +4,6 @@ import String from './string' import { PLACEHOLDER_SYMBOL } from '../utils/weak-maps' import { RenderLeafProps, RenderPlaceholderProps } from './editable' -// auto-incrementing key for String component, force it refresh to -// prevent inconsistent rendering by React with IME input -let keyForString = 0 /** * Individual leaves in a text node with unique formatting. */ @@ -48,13 +45,7 @@ const Leaf = (props: { }, [placeholderRef, leaf]) let children = ( - + ) if (leaf[PLACEHOLDER_SYMBOL]) { diff --git a/packages/slate-react/src/components/string.tsx b/packages/slate-react/src/components/string.tsx index 0a9228d95..7ecbd1e15 100644 --- a/packages/slate-react/src/components/string.tsx +++ b/packages/slate-react/src/components/string.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef } from 'react' import { Editor, Text, Path, Element, Node } from 'slate' import { ReactEditor, useSlateStatic } from '..' @@ -55,16 +55,32 @@ const String = (props: { /** * Leaf strings with text in them. */ +const TextString = React.memo( + (props: { text: string; isTrailing?: boolean }) => { + const { text, isTrailing = false } = props -const TextString = (props: { text: string; isTrailing?: boolean }) => { - const { text, isTrailing = false } = props - return ( - - {text} - {isTrailing ? '\n' : null} - - ) -} + const ref = useRef(null) + const forceUpdateFlag = useRef(false) + + if (ref.current && ref.current.textContent !== text) { + forceUpdateFlag.current = !forceUpdateFlag.current + } + + // This component may have skipped rendering due to native operations being + // applied. If an undo is performed React will see the old and new shadow DOM + // match and not apply an update. Forces each render to actually reconcile. + return ( + + {text} + {isTrailing ? '\n' : null} + + ) + } +) /** * Leaf strings without text, render as zero-width strings. diff --git a/packages/slate-react/src/plugin/with-react.ts b/packages/slate-react/src/plugin/with-react.ts index 09a7f35ee..bf30018ec 100644 --- a/packages/slate-react/src/plugin/with-react.ts +++ b/packages/slate-react/src/plugin/with-react.ts @@ -4,6 +4,11 @@ import { Editor, Node, Path, Operation, Transforms, Range } from 'slate' import { ReactEditor } from './react-editor' import { Key } from '../utils/key' import { EDITOR_TO_ON_CHANGE, NODE_TO_KEY } from '../utils/weak-maps' +import { + AS_NATIVE, + NATIVE_OPERATIONS, + flushNativeEvents, +} from '../utils/native' import { isDOMText, getPlainText } from '../utils/dom' import { findCurrentLineRange } from '../utils/lines' @@ -49,6 +54,31 @@ export const withReact = (editor: T) => { } e.apply = (op: Operation) => { + // if we're NOT an insert_text and there's a queue + // of native events, bail out and flush the queue. + // otherwise transforms as part of this cycle will + // be incorrect. + // + // This is needed as overriden operations (e.g. `insertText`) + // can call additional transforms, which will need accurate + // content, and will be called _before_ `onInput` is fired. + if (op.type !== 'insert_text') { + AS_NATIVE.set(editor, false) + flushNativeEvents(editor) + } + + // If we're in native mode, queue the operation + // and it will be applied later. + if (AS_NATIVE.get(editor)) { + const nativeOps = NATIVE_OPERATIONS.get(editor) + if (nativeOps) { + nativeOps.push(op) + } else { + NATIVE_OPERATIONS.set(editor, [op]) + } + return + } + const matches: [Path, Key][] = [] switch (op.type) { diff --git a/packages/slate-react/src/utils/native.ts b/packages/slate-react/src/utils/native.ts new file mode 100644 index 000000000..9411d61ea --- /dev/null +++ b/packages/slate-react/src/utils/native.ts @@ -0,0 +1,37 @@ +import { Editor, Operation } from 'slate' + +export const AS_NATIVE: WeakMap = new WeakMap() +export const NATIVE_OPERATIONS: WeakMap = new WeakMap() + +/** + * `asNative` queues operations as native, meaning native browser events will + * not have been prevented, and we need to flush the operations + * after the native events have propogated to the DOM. + * @param {Editor} editor - Editor on which the operations are being applied + * @param {callback} fn - Function containing .exec calls which will be queued as native + */ +export const asNative = (editor: Editor, fn: () => void) => { + AS_NATIVE.set(editor, true) + fn() + AS_NATIVE.set(editor, false) +} + +/** + * `flushNativeEvents` applies any queued native events. + * @param {Editor} editor - Editor on which the operations are being applied + */ +export const flushNativeEvents = (editor: Editor) => { + const nativeOps = NATIVE_OPERATIONS.get(editor) + + // Clear list _before_ applying, as we might flush + // events in each op, as well. + NATIVE_OPERATIONS.set(editor, []) + + if (nativeOps) { + Editor.withoutNormalizing(editor, () => { + nativeOps.forEach(op => { + editor.apply(op) + }) + }) + } +}