mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-13 10:44:02 +02:00
Use native character insertion to fix browser/OS text features (#3888)
* use native character insertion to fix browser/OS text features. (flickering spellcheck, autocorrect, text shortcuts, etc.) move some checks into previous if statement, remove commented out code move native behavior into `slate-react`, and remove any external interface dont use native editing if marks are set, as a new node will be inserted match -> above remove nativeOperationsQueue from editor bail out of native queueing and immediately flush events if non insert_text operation is being applied. * Convert TextString to a functional component * Batch normalization of native op application * Add changeset * only proceed as native event if single character non-special character or space, to limit potential bad side effects. * Revert "fix ime double input with mark" * Comment wording tweak Co-authored-by: Dylan Schiemann <dylan@dojotoolkit.org> * Comment wording tweak Co-authored-by: Dylan Schiemann <dylan@dojotoolkit.org> * Comment wording tweak Co-authored-by: Dylan Schiemann <dylan@dojotoolkit.org> * Comment wording tweak Co-authored-by: Dylan Schiemann <dylan@dojotoolkit.org> * Comment wording tweak Co-authored-by: Dylan Schiemann <dylan@dojotoolkit.org> Co-authored-by: Ludwig Pettersson <luddep@gmail.com> Co-authored-by: Dylan Schiemann <dylan@dojotoolkit.org>
This commit is contained in:
@@ -47,6 +47,7 @@ import {
|
||||
PLACEHOLDER_SYMBOL,
|
||||
EDITOR_TO_WINDOW,
|
||||
} from '../utils/weak-maps'
|
||||
import { asNative, flushNativeEvents } from '../utils/native'
|
||||
|
||||
const Children = (props: Parameters<typeof useChildren>[0]) => (
|
||||
<React.Fragment>{useChildren(props)}</React.Fragment>
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
if (
|
||||
|
@@ -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 = (
|
||||
<String
|
||||
key={keyForString++}
|
||||
isLast={isLast}
|
||||
leaf={leaf}
|
||||
parent={parent}
|
||||
text={text}
|
||||
/>
|
||||
<String isLast={isLast} leaf={leaf} parent={parent} text={text} />
|
||||
)
|
||||
|
||||
if (leaf[PLACEHOLDER_SYMBOL]) {
|
||||
|
@@ -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 (
|
||||
<span data-slate-string>
|
||||
{text}
|
||||
{isTrailing ? '\n' : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const ref = useRef<HTMLSpanElement>(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 (
|
||||
<span
|
||||
data-slate-string
|
||||
ref={ref}
|
||||
key={forceUpdateFlag.current ? 'A' : 'B'}
|
||||
>
|
||||
{text}
|
||||
{isTrailing ? '\n' : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Leaf strings without text, render as zero-width strings.
|
||||
|
@@ -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 = <T extends Editor>(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) {
|
||||
|
37
packages/slate-react/src/utils/native.ts
Normal file
37
packages/slate-react/src/utils/native.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Editor, Operation } from 'slate'
|
||||
|
||||
export const AS_NATIVE: WeakMap<Editor, boolean> = new WeakMap()
|
||||
export const NATIVE_OPERATIONS: WeakMap<Editor, Operation[]> = 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user