Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-plums-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate-react': patch
---

Keep the DOM and Slate value in sync when an insertText override rejects native text input, including insertions at decoration boundaries.
76 changes: 73 additions & 3 deletions packages/slate-react/src/components/editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ import { useFlushDeferredSelectorsOnRender } from '../hooks/use-slate-selector'

type DeferredOperation = () => void

const isTextDOMNode = (node: unknown): node is DOMText =>
isDOMNode(node) && node.nodeType === 3

const isSameCaretPosition = (editor: Editor, a: Range, b: Range) => {
if (!Range.isCollapsed(a) || !Range.isCollapsed(b)) {
return false
Expand Down Expand Up @@ -219,6 +222,14 @@ export const Editable = forwardRef(
const [isComposing, setIsComposing] = useState(false)
const ref = useRef<HTMLDivElement | null>(null)
const deferredOperations = useRef<DeferredOperation[]>([])
const lastNativeInsertion = useRef<{
point: Point
text: string
domNode: DOMText
domOffset: number
domText: string
char: string
} | null>(null)
const [placeholderHeight, setPlaceholderHeight] = useState<
number | undefined
>()
Expand Down Expand Up @@ -672,6 +683,7 @@ export const Editable = forwardRef(
}

let native = false
let nativePoint: [DOMText, number] | null = null
if (
type === 'insertText' &&
selection &&
Expand All @@ -695,9 +707,10 @@ export const Editable = forwardRef(
native = false
}

const domAnchor = getSelection(
const domSelection = getSelection(
ReactEditor.findDocumentOrShadowRoot(editor)
)?.anchorNode
)
const domAnchor = domSelection?.anchorNode

if (!isInSlateText(domAnchor)) {
native = false
Expand Down Expand Up @@ -749,6 +762,13 @@ export const Editable = forwardRef(
native = false
}
}

if (native && isTextDOMNode(node)) {
nativePoint =
domSelection?.isCollapsed && isTextDOMNode(domAnchor)
? [domAnchor, domSelection.anchorOffset]
: [node, offset]
}
}
}
// COMPAT: For the deleting forward/backward input types we don't want
Expand All @@ -759,7 +779,7 @@ export const Editable = forwardRef(
(!type.startsWith('delete') || type.startsWith('deleteBy')) &&
!IS_NODE_MAP_DIRTY.get(editor)
) {
const [targetRange] = (event as any).getTargetRanges()
const [targetRange] = event.getTargetRanges()

if (targetRange) {
// Unresolvable ranges would throw out of the handler (#3556); suppress and fall back to synthetic handling.
Expand All @@ -785,6 +805,13 @@ export const Editable = forwardRef(
EDITOR_TO_USER_SELECTION.set(editor, selectionRef)
}
}

if (native && isTextDOMNode(targetRange.startContainer)) {
nativePoint = [
targetRange.startContainer,
targetRange.startOffset,
]
}
}
}

Expand All @@ -795,6 +822,7 @@ export const Editable = forwardRef(
}

if (!native) {
lastNativeInsertion.current = null
event.preventDefault()
}

Expand Down Expand Up @@ -901,6 +929,19 @@ export const Editable = forwardRef(
// Only insertText operations use the native functionality, for now.
// Potentially expand to single character deletes, as well.
if (native) {
lastNativeInsertion.current = null
if (nativePoint && selection) {
const [node] = Editor.leaf(editor, selection.anchor)
const [domNode, domOffset] = nativePoint
lastNativeInsertion.current = {
point: selection.anchor,
text: node.text,
domNode,
domOffset,
domText: domNode.data,
char: data,
}
}
deferredOperations.current.push(() =>
Editor.insertText(editor, data)
)
Expand Down Expand Up @@ -1239,6 +1280,35 @@ export const Editable = forwardRef(
}
deferredOperations.current = []

const nativeInsertion = lastNativeInsertion.current
lastNativeInsertion.current = null

if (nativeInsertion) {
const { point, text, domNode, domOffset, domText, char } =
nativeInsertion
const node = Node.getIf(editor, point.path)
if (
Text.isText(node) &&
node.text === text &&
domNode.isConnected &&
domNode.data ===
domText.slice(0, domOffset) +
char +
domText.slice(domOffset)
) {
domNode.deleteData(domOffset, char.length)
if (
editor.selection &&
Range.isCollapsed(editor.selection) &&
Point.equals(editor.selection.anchor, point)
) {
getSelection(
ReactEditor.findDocumentOrShadowRoot(editor)
)?.collapse(domNode, domOffset)
}
}
}

// COMPAT: Since `beforeinput` doesn't fully `preventDefault`,
// there's a chance that content might be placed in the browser's undo stack.
// This means undo can be triggered even when the div is not focused,
Expand Down
128 changes: 128 additions & 0 deletions packages/slate-react/test/native-insert-text.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React from 'react'
import { act, fireEvent, render } from '@testing-library/react'
import { createEditor, Node, NodeEntry, Range, Transforms } from 'slate'
import { IS_NODE_MAP_DIRTY } from 'slate-dom'
import { Editable, ReactEditor, Slate, withReact } from '../src'

jest.mock('slate-dom', () => ({
...jest.requireActual('slate-dom'),
HAS_BEFORE_INPUT_SUPPORT: true,
}))

Object.defineProperty(HTMLElement.prototype, 'isContentEditable', {
get() {
return (
this.closest('[contenteditable]')?.getAttribute('contenteditable') ===
'true'
)
},
})

const decorate = ([node, path]: NodeEntry): Array<
Range & { highlight: boolean }
> =>
Node.isText(node)
? [
{
anchor: { path, offset: 0 },
focus: { path, offset: 3 },
highlight: true,
},
]
: []

const setup = async (decorated: boolean, offset: number) => {
let beforeInput: EventListener | undefined
const addEventListener = HTMLElement.prototype.addEventListener
const listenerSpy = jest
.spyOn(HTMLElement.prototype, 'addEventListener')
.mockImplementation(function (this: HTMLElement, type, listener, options) {
if (type === 'beforeinput' && typeof listener === 'function') {
beforeInput = listener
}
return addEventListener.call(this, type, listener, options)
})
const editor = withReact(createEditor())
const insertText = editor.insertText
editor.insertText = (text, options) => {
if (!/[A-Z]/.test(text)) {
insertText(text, options)
}
}

render(
<Slate editor={editor} initialValue={[{ children: [{ text: 'abcdef' }] }]}>
<Editable decorate={decorated ? decorate : undefined} />
</Slate>
)
listenerSpy.mockRestore()

await act(async () => {
Transforms.select(editor, { path: [0, 0], offset })
})

const editable = ReactEditor.toDOMNode(editor, editor)
const nativeInput = async (leaf: number, domOffset: number, data: string) => {
const node = editable.querySelectorAll('[data-slate-string]')[leaf]
.firstChild!
if (!(node instanceof window.Text)) {
throw new Error('Expected a DOM text node')
}
window.getSelection()!.collapse(node, domOffset)
const range = document.createRange()
range.setStart(node, domOffset)
range.collapse(true)
const preventDefault = jest.fn()

await act(async () => {
beforeInput!.call(editable, {
isTrusted: true,
target: editable,
inputType: 'insertText',
data,
getTargetRanges: () => [range],
preventDefault,
} as unknown as InputEvent)

if (!preventDefault.mock.calls.length) {
node.insertData(domOffset, data)
window.getSelection()!.collapse(node, domOffset + data.length)
}
fireEvent.input(editable, { inputType: 'insertText', data })
})
}

return { editor, editable, nativeInput }
}

afterEach(() => {
jest.restoreAllMocks()
})

test('removes a rejected native insertion from an undecorated text node', async () => {
const { editor, editable, nativeInput } = await setup(false, 6)
await nativeInput(0, 6, 'X')

expect(Node.string(editor)).toBe('abcdef')
expect(editable.textContent).toBe('abcdef')
})

test('removes a rejected insertion from the actual leaf at a decoration boundary', async () => {
const { editor, editable, nativeInput } = await setup(true, 3)
await nativeInput(1, 0, 'X')

expect(Node.string(editor)).toBe('abcdef')
expect(editable.textContent).toBe('abcdef')
await nativeInput(1, 0, 'g')
expect(Node.string(editor)).toBe('abcgdef')
expect(editable.textContent).toBe('abcgdef')
})

test('does not resolve a native insertion point while the node map is dirty', async () => {
const { editor, nativeInput } = await setup(false, 6)
IS_NODE_MAP_DIRTY.set(editor, true)
const toDOMPoint = jest.spyOn(ReactEditor, 'toDOMPoint')
await nativeInput(0, 6, 'X')

expect(toDOMPoint).not.toHaveBeenCalled()
})
Loading
Loading