Skip to content

Commit

Permalink
Use native character insertion to fix browser/OS text features (#3888)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Aug 11, 2021
1 parent 55ff8f0 commit 25afbd4
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 22 deletions.
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 @@ -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>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
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)
})
})
}
}

0 comments on commit 25afbd4

Please sign in to comment.