Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use native character insertion to fix browser/OS text features #3888

Merged
merged 11 commits into from Aug 11, 2021
5 changes: 5 additions & 0 deletions .changeset/mighty-zebras-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate-react': minor
---

Use native character insertion to fix browser/OS text features
60 changes: 58 additions & 2 deletions packages/slate-react/src/components/editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
PLACEHOLDER_SYMBOL,
EDITOR_TO_WINDOW,
} from '../utils/weak-maps'
import { asNative, flushNativeEvents } from '../utils/native'

/**
* `RenderElementProps` are passed to the `renderElement` handler.
Expand Down Expand Up @@ -268,7 +269,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,
Expand Down Expand Up @@ -380,7 +423,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
Expand Down Expand Up @@ -552,6 +601,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 (
Expand Down
11 changes: 1 addition & 10 deletions packages/slate-react/src/components/leaf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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]) {
Expand Down
36 changes: 26 additions & 10 deletions packages/slate-react/src/components/string.tsx
Original file line number Diff line number Diff line change
@@ -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 '..'
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions packages/slate-react/src/plugin/with-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions packages/slate-react/src/utils/native.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
}
}