diff --git a/.changeset/major-hairs-share.md b/.changeset/major-hairs-share.md new file mode 100644 index 000000000..54b854e28 --- /dev/null +++ b/.changeset/major-hairs-share.md @@ -0,0 +1,13 @@ +--- +"@portabletext/editor": minor +--- + +feat: add unified input handling across desktop and mobile + +Previously, Android used a separate input pipeline that assumed all browser +input events were non-cancelable, requiring expensive DOM reconciliation for +every keystroke. Most Android input events are in faccancelable - only IME +composition is not. The new hybrid input manager intercepts events directly +where possible and falls back to a ProseMirror-inspired DOM parse-and-diff path +for composition. This fixes autocorrect on Android and gives behavior authors a +single code path that works on all platforms. diff --git a/packages/editor/src/dom/change-detector.test.ts b/packages/editor/src/dom/change-detector.test.ts new file mode 100644 index 000000000..f1c369c23 --- /dev/null +++ b/packages/editor/src/dom/change-detector.test.ts @@ -0,0 +1,596 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import {describe, expect, test} from 'vitest' +import {detectChange} from './change-detector' +import type {BlockTextSnapshot} from './dom-text-reader' + +function createTextBlock( + key: string, + text: string, + options?: {style?: string; listItem?: string; level?: number}, +): PortableTextBlock { + return { + _key: key, + _type: 'block', + children: [ + { + _key: `${key}-span`, + _type: 'span' as const, + text, + marks: [], + }, + ], + style: options?.style ?? 'normal', + ...(options?.listItem ? {listItem: options.listItem} : {}), + ...(options?.level !== undefined ? {level: options.level} : {}), + } +} + +function createMultiSpanTextBlock( + key: string, + spans: Array<{key: string; text: string; marks?: string[]}>, +): PortableTextBlock { + return { + _key: key, + _type: 'block', + children: spans.map((span) => ({ + _key: span.key, + _type: 'span' as const, + text: span.text, + marks: span.marks ?? [], + })), + style: 'normal', + } +} + +function createObjectBlock(key: string, type: string): PortableTextBlock { + return { + _key: key, + _type: type, + } +} + +function textSnapshot(key: string, text: string): BlockTextSnapshot { + return {_key: key, text, isTextBlock: true} +} + +function objectSnapshot(key: string): BlockTextSnapshot { + return {_key: key, text: '', isTextBlock: false} +} + +describe('detectChange', () => { + describe('noop', () => { + test('returns noop for two empty arrays', () => { + expect(detectChange([], [])).toEqual({type: 'noop'}) + }) + + test('returns noop when text content is identical', () => { + const oldBlocks = [createTextBlock('block1', 'Hello world')] + const newBlocks = [textSnapshot('block1', 'Hello world')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'noop', + }) + }) + + test('returns noop for multiple identical blocks', () => { + const oldBlocks = [ + createTextBlock('block1', 'First'), + createTextBlock('block2', 'Second'), + createTextBlock('block3', 'Third'), + ] + const newBlocks = [ + textSnapshot('block1', 'First'), + textSnapshot('block2', 'Second'), + textSnapshot('block3', 'Third'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'noop', + }) + }) + }) + + describe('text.insert', () => { + test('detects single character insertion at end', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [textSnapshot('block1', 'Hello!')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 5, + text: '!', + }) + }) + + test('detects single character insertion at beginning', () => { + const oldBlocks = [createTextBlock('block1', 'ello')] + const newBlocks = [textSnapshot('block1', 'Hello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 0, + text: 'H', + }) + }) + + test('detects multi-character insertion in middle', () => { + const oldBlocks = [createTextBlock('block1', 'Helo')] + const newBlocks = [textSnapshot('block1', 'Hello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 3, + text: 'l', + }) + }) + + test('detects word insertion', () => { + const oldBlocks = [createTextBlock('block1', 'Hello world')] + const newBlocks = [textSnapshot('block1', 'Hello beautiful world')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 6, + text: 'beautiful ', + }) + }) + + test('detects insertion into empty block', () => { + const oldBlocks = [createTextBlock('block1', '')] + const newBlocks = [textSnapshot('block1', 'Hello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 0, + text: 'Hello', + }) + }) + }) + + describe('text.delete', () => { + test('detects single character deletion at end', () => { + const oldBlocks = [createTextBlock('block1', 'Hello!')] + const newBlocks = [textSnapshot('block1', 'Hello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.delete', + blockKey: 'block1', + spanKey: 'block1-span', + from: 5, + to: 6, + }) + }) + + test('detects single character deletion at beginning', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [textSnapshot('block1', 'ello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.delete', + blockKey: 'block1', + spanKey: 'block1-span', + from: 0, + to: 1, + }) + }) + + test('detects multi-character deletion in middle', () => { + const oldBlocks = [createTextBlock('block1', 'Hello world')] + const newBlocks = [textSnapshot('block1', 'Held')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.delete', + blockKey: 'block1', + spanKey: 'block1-span', + from: 3, + to: 10, + }) + }) + + test('detects deletion to empty', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [textSnapshot('block1', '')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.delete', + blockKey: 'block1', + spanKey: 'block1-span', + from: 0, + to: 5, + }) + }) + + test('detects word deletion', () => { + const oldBlocks = [createTextBlock('block1', 'Hello beautiful world')] + const newBlocks = [textSnapshot('block1', 'Hello world')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.delete', + blockKey: 'block1', + spanKey: 'block1-span', + from: 6, + to: 16, + }) + }) + }) + + describe('text.replace', () => { + test('detects single character replacement', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [textSnapshot('block1', 'Hallo')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.replace', + blockKey: 'block1', + spanKey: 'block1-span', + from: 1, + to: 2, + text: 'a', + }) + }) + + test('detects word replacement via autocorrect (minimal diff)', () => { + // "teh" → "the": the diff algorithm finds the minimal change. + // Forward scan: 't' matches → diffStart = 1 + // Backward scan: both end with " quick" → oldEnd = 3, newEnd = 3 + // So the minimal diff is replacing "eh" with "he" at positions 1-3. + const oldBlocks = [createTextBlock('block1', 'teh quick')] + const newBlocks = [textSnapshot('block1', 'the quick')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.replace', + blockKey: 'block1', + spanKey: 'block1-span', + from: 1, + to: 3, + text: 'he', + }) + }) + + test('detects full word replacement with different length', () => { + const oldBlocks = [createTextBlock('block1', 'Hello world')] + const newBlocks = [textSnapshot('block1', 'Hello universe')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.replace', + blockKey: 'block1', + spanKey: 'block1-span', + from: 6, + to: 11, + text: 'universe', + }) + }) + + test('detects IME composition replacement', () => { + // Simulates Japanese IME: composing "か" then confirming "書" + const oldBlocks = [createTextBlock('block1', 'テストか')] + const newBlocks = [textSnapshot('block1', 'テスト書')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.replace', + blockKey: 'block1', + spanKey: 'block1-span', + from: 3, + to: 4, + text: '書', + }) + }) + + test('detects complete word swap', () => { + // When the entire content changes, from and to span the whole string + const oldBlocks = [createTextBlock('block1', 'cat')] + const newBlocks = [textSnapshot('block1', 'dog')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.replace', + blockKey: 'block1', + spanKey: 'block1-span', + from: 0, + to: 3, + text: 'dog', + }) + }) + }) + + describe('block.split', () => { + test('detects split in the middle of text', () => { + const oldBlocks = [createTextBlock('block1', 'Hello world')] + const newBlocks = [ + textSnapshot('block1', 'Hello '), + textSnapshot('block2', 'world'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.split', + originalBlockKey: 'block1', + newBlockKey: 'block2', + splitOffset: 6, + }) + }) + + test('detects split at the beginning of text', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [ + textSnapshot('block1', ''), + textSnapshot('block2', 'Hello'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.split', + originalBlockKey: 'block1', + newBlockKey: 'block2', + splitOffset: 0, + }) + }) + + test('detects split at the end of text', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [ + textSnapshot('block1', 'Hello'), + textSnapshot('block2', ''), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.split', + originalBlockKey: 'block1', + newBlockKey: 'block2', + splitOffset: 5, + }) + }) + + test('detects split with multiple existing blocks', () => { + const oldBlocks = [ + createTextBlock('block1', 'First'), + createTextBlock('block2', 'Hello world'), + createTextBlock('block3', 'Third'), + ] + const newBlocks = [ + textSnapshot('block1', 'First'), + textSnapshot('block2', 'Hello '), + textSnapshot('block4', 'world'), + textSnapshot('block3', 'Third'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.split', + originalBlockKey: 'block2', + newBlockKey: 'block4', + splitOffset: 6, + }) + }) + + test('detects split of empty block (Enter on empty line)', () => { + const oldBlocks = [createTextBlock('block1', '')] + const newBlocks = [textSnapshot('block1', ''), textSnapshot('block2', '')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.split', + originalBlockKey: 'block1', + newBlockKey: 'block2', + splitOffset: 0, + }) + }) + }) + + describe('block.merge', () => { + test('detects merge of two text blocks (Backspace)', () => { + const oldBlocks = [ + createTextBlock('block1', 'Hello '), + createTextBlock('block2', 'world'), + ] + const newBlocks = [textSnapshot('block1', 'Hello world')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.merge', + survivingBlockKey: 'block1', + removedBlockKey: 'block2', + joinOffset: 6, + }) + }) + + test('detects merge when second block is empty', () => { + const oldBlocks = [ + createTextBlock('block1', 'Hello'), + createTextBlock('block2', ''), + ] + const newBlocks = [textSnapshot('block1', 'Hello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.merge', + survivingBlockKey: 'block1', + removedBlockKey: 'block2', + joinOffset: 5, + }) + }) + + test('detects merge when first block is empty', () => { + const oldBlocks = [ + createTextBlock('block1', ''), + createTextBlock('block2', 'Hello'), + ] + const newBlocks = [textSnapshot('block2', 'Hello')] + // block1 is removed, block2 survives. The removed block's text ("") + // was prepended, so joinOffset is 0. + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.merge', + survivingBlockKey: 'block2', + removedBlockKey: 'block1', + joinOffset: 0, + }) + }) + + test('detects merge with multiple blocks present', () => { + const oldBlocks = [ + createTextBlock('block1', 'First'), + createTextBlock('block2', 'Hello '), + createTextBlock('block3', 'world'), + createTextBlock('block4', 'Last'), + ] + const newBlocks = [ + textSnapshot('block1', 'First'), + textSnapshot('block2', 'Hello world'), + textSnapshot('block4', 'Last'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.merge', + survivingBlockKey: 'block2', + removedBlockKey: 'block3', + joinOffset: 6, + }) + }) + }) + + describe('block.insert', () => { + test('detects block object insertion after text block', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [ + textSnapshot('block1', 'Hello'), + objectSnapshot('block2'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.insert', + blockKey: 'block2', + index: 1, + }) + }) + + test('detects block insertion at beginning', () => { + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [ + objectSnapshot('block2'), + textSnapshot('block1', 'Hello'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.insert', + blockKey: 'block2', + index: 0, + }) + }) + + test('detects text block insertion that does not match split pattern', () => { + // A new text block is inserted but its text doesn't match the + // suffix of the preceding block — not a split + const oldBlocks = [createTextBlock('block1', 'Hello')] + const newBlocks = [ + textSnapshot('block1', 'Hello'), + textSnapshot('block2', 'Something completely different'), + ] + // This matches the split pattern: "Hello" === "Hello" + ""? No, + // "Hello" !== "Hello" + "Something completely different" + // So it falls through to block.insert + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.insert', + blockKey: 'block2', + index: 1, + }) + }) + }) + + describe('block.delete', () => { + test('detects block object deletion', () => { + const oldBlocks = [ + createTextBlock('block1', 'Hello'), + createObjectBlock('block2', 'image'), + ] + const newBlocks = [textSnapshot('block1', 'Hello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.delete', + blockKey: 'block2', + index: 1, + }) + }) + + test('detects block object deletion at beginning', () => { + const oldBlocks = [ + createObjectBlock('block1', 'image'), + createTextBlock('block2', 'Hello'), + ] + const newBlocks = [textSnapshot('block2', 'Hello')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'block.delete', + blockKey: 'block1', + index: 0, + }) + }) + }) + + describe('multi-span blocks', () => { + test('detects text insertion across spans', () => { + const oldBlocks = [ + createMultiSpanTextBlock('block1', [ + {key: 'span1', text: 'Hello '}, + {key: 'span2', text: 'world', marks: ['strong']}, + ]), + ] + const newBlocks = [textSnapshot('block1', 'Hello beautiful world')] + // The flattened text goes from "Hello world" to "Hello beautiful world". + // The insertion offset (6) falls on the span1/span2 boundary in the old + // block, so `findSpanKeyAtOffset` returns the next span (span2). + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'span2', + offset: 6, + text: 'beautiful ', + }) + }) + + test('detects text deletion across spans', () => { + const oldBlocks = [ + createMultiSpanTextBlock('block1', [ + {key: 'span1', text: 'Hello '}, + {key: 'span2', text: 'beautiful '}, + {key: 'span3', text: 'world'}, + ]), + ] + const newBlocks = [textSnapshot('block1', 'Hello world')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.delete', + blockKey: 'block1', + spanKey: 'span2', + from: 6, + to: 16, + }) + }) + }) + + describe('edge cases', () => { + test('handles block objects (no children) without crashing', () => { + const oldBlocks = [createObjectBlock('block1', 'image')] + const newBlocks = [objectSnapshot('block1')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'noop', + }) + }) + + test('handles mixed text and object blocks', () => { + const oldBlocks = [ + createTextBlock('block1', 'Hello'), + createObjectBlock('block2', 'image'), + createTextBlock('block3', 'World'), + ] + const newBlocks = [ + textSnapshot('block1', 'Hello!'), + objectSnapshot('block2'), + textSnapshot('block3', 'World'), + ] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 5, + text: '!', + }) + }) + + test('handles Unicode text correctly', () => { + const oldBlocks = [createTextBlock('block1', '日本語テスト')] + const newBlocks = [textSnapshot('block1', '日本語のテスト')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 3, + text: 'の', + }) + }) + + test('handles emoji correctly', () => { + const oldBlocks = [createTextBlock('block1', 'Hello 🌍')] + const newBlocks = [textSnapshot('block1', 'Hello 🌍!')] + expect(detectChange(oldBlocks, newBlocks)).toEqual({ + type: 'text.insert', + blockKey: 'block1', + spanKey: 'block1-span', + offset: 8, + text: '!', + }) + }) + }) +}) diff --git a/packages/editor/src/dom/change-detector.ts b/packages/editor/src/dom/change-detector.ts new file mode 100644 index 000000000..2674cf4aa --- /dev/null +++ b/packages/editor/src/dom/change-detector.ts @@ -0,0 +1,606 @@ +import type { + PortableTextBlock, + PortableTextSpan, + PortableTextTextBlock, +} from '@portabletext/schema' +import {DIFF_DELETE, DIFF_INSERT, makeDiff} from '@sanity/diff-match-patch' +import type {BlockTextSnapshot} from './dom-text-reader' + +export type TextInsertChange = { + type: 'text.insert' + blockKey: string + spanKey: string + offset: number + text: string +} + +export type TextDeleteChange = { + type: 'text.delete' + blockKey: string + spanKey: string + from: number + to: number +} + +export type TextReplaceChange = { + type: 'text.replace' + blockKey: string + spanKey: string + from: number + to: number + text: string +} + +export type BlockSplitChange = { + type: 'block.split' + originalBlockKey: string + newBlockKey: string + splitOffset: number +} + +export type BlockMergeChange = { + type: 'block.merge' + survivingBlockKey: string + removedBlockKey: string + joinOffset: number +} + +export type BlockInsertChange = { + type: 'block.insert' + blockKey: string + index: number +} + +export type BlockDeleteChange = { + type: 'block.delete' + blockKey: string + index: number +} + +export type NoChange = { + type: 'noop' +} + +export type PortableTextChange = + | TextInsertChange + | TextDeleteChange + | TextReplaceChange + | BlockSplitChange + | BlockMergeChange + | BlockInsertChange + | BlockDeleteChange + | NoChange + +/** + * Compare two PT block arrays and detect what changed. + * + * Algorithm: + * 1. Align blocks by `_key` to find insertions, deletions, and matches + * 2. Check for structural changes (split, merge) first + * 3. Fall back to text-level diffing for matched blocks + * 4. Handle pure block insertions/deletions as a last resort + */ +export function detectChange( + oldBlocks: PortableTextBlock[], + newBlocks: BlockTextSnapshot[], + cursorOffset?: number, + spanType: string = 'span', +): PortableTextChange { + if (oldBlocks.length === 0 && newBlocks.length === 0) { + return {type: 'noop'} + } + + const alignment = alignBlocks(oldBlocks, newBlocks) + + // Structural changes take priority — they involve both block count + // changes AND text changes, so we report the structural intent. + const splitChange = detectBlockSplit( + alignment, + oldBlocks, + newBlocks, + spanType, + ) + if (splitChange) { + return splitChange + } + + const mergeChange = detectBlockMerge(alignment, oldBlocks, spanType) + if (mergeChange) { + return mergeChange + } + + // Pure block insertions + if (alignment.insertedKeys.length > 0 && alignment.deletedKeys.length === 0) { + const firstInsertedKey = alignment.insertedKeys[0] + if (firstInsertedKey === undefined) { + return {type: 'noop'} + } + const insertIndex = alignment.newBlockIndexByKey.get(firstInsertedKey) + + if (insertIndex !== undefined) { + return { + type: 'block.insert', + blockKey: firstInsertedKey, + index: insertIndex, + } + } + } + + // Pure block deletions + if (alignment.deletedKeys.length > 0 && alignment.insertedKeys.length === 0) { + const firstDeletedKey = alignment.deletedKeys[0] + if (firstDeletedKey === undefined) { + return {type: 'noop'} + } + const deleteIndex = alignment.oldBlockIndexByKey.get(firstDeletedKey) + + if (deleteIndex !== undefined) { + return { + type: 'block.delete', + blockKey: firstDeletedKey, + index: deleteIndex, + } + } + } + + // Text-level changes within matched blocks + const textChanges: PortableTextChange[] = [] + + for (const matchedKey of alignment.matchedKeys) { + const oldBlock = alignment.oldBlocksByKey.get(matchedKey) + const newSnapshot = alignment.newSnapshotsByKey.get(matchedKey) + + if (!oldBlock || !newSnapshot) { + continue + } + + const textChange = detectTextChange( + matchedKey, + oldBlock, + newSnapshot, + spanType, + ) + if (textChange) { + textChanges.push(textChange) + } + } + + if (textChanges.length === 1) { + const singleChange = textChanges[0] + if (singleChange) { + return singleChange + } + } + + if (textChanges.length > 1) { + // Multiple blocks changed simultaneously — use `cursorOffset` to + // pick the most relevant one if available. + // + // Note: `cursorOffset` is span-local (selection.anchor.offset) but + // the disambiguation below treats it as document-global by accumulating + // a running offset across blocks. This is technically incorrect but + // only matters when multiple blocks change simultaneously on the slow + // path, which is extremely rare in practice. + if (cursorOffset !== undefined) { + let runningOffset = 0 + for (const snapshot of newBlocks) { + const blockEnd = runningOffset + snapshot.text.length + + if (cursorOffset >= runningOffset && cursorOffset <= blockEnd) { + const cursorBlockChange = textChanges.find( + (change) => + 'blockKey' in change && change.blockKey === snapshot._key, + ) + if (cursorBlockChange) { + return cursorBlockChange + } + } + + // +1 for the implicit block separator + runningOffset = blockEnd + 1 + } + } + + const firstChange = textChanges[0] + if (firstChange) { + return firstChange + } + } + + return {type: 'noop'} +} + +function detectBlockSplit( + alignment: BlockAlignment, + _oldBlocks: PortableTextBlock[], + newBlocks: BlockTextSnapshot[], + spanType: string, +): BlockSplitChange | null { + const {insertedKeys, deletedKeys} = alignment + + if (insertedKeys.length !== 1 || deletedKeys.length !== 0) { + return null + } + + const insertedKey = insertedKeys[0] + if (!insertedKey) { + return null + } + const insertedSnapshot = alignment.newSnapshotsByKey.get(insertedKey) + if (!insertedSnapshot) { + return null + } + + // Block objects (e.g. images) aren't splits + if (!insertedSnapshot.isTextBlock) { + return null + } + + const insertedIndex = alignment.newBlockIndexByKey.get(insertedKey) + if (insertedIndex === undefined || insertedIndex === 0) { + return null + } + + const precedingSnapshot = newBlocks[insertedIndex - 1] + if (!precedingSnapshot) { + return null + } + + const originalKey = precedingSnapshot._key + const oldOriginalBlock = alignment.oldBlocksByKey.get(originalKey) + if (!oldOriginalBlock) { + return null + } + + const oldText = getBlockText(oldOriginalBlock, spanType) + + if (oldText === precedingSnapshot.text + insertedSnapshot.text) { + return { + type: 'block.split', + originalBlockKey: originalKey, + newBlockKey: insertedKey, + splitOffset: precedingSnapshot.text.length, + } + } + + return null +} + +function detectBlockMerge( + alignment: BlockAlignment, + oldBlocks: PortableTextBlock[], + spanType: string, +): BlockMergeChange | null { + const {insertedKeys, deletedKeys} = alignment + + if (deletedKeys.length !== 1 || insertedKeys.length !== 0) { + return null + } + + const removedKey = deletedKeys[0] + if (!removedKey) { + return null + } + const removedBlock = alignment.oldBlocksByKey.get(removedKey) + if (!removedBlock) { + return null + } + + // Block objects (e.g. images) aren't merges + if (!hasChildren(removedBlock)) { + return null + } + + const removedOldIndex = alignment.oldBlockIndexByKey.get(removedKey) + if (removedOldIndex === undefined) { + return null + } + + const removedText = getBlockText(removedBlock, spanType) + + // Check preceding block absorbed the removed block's content (Backspace) + if (removedOldIndex > 0) { + const precedingBlock = oldBlocks[removedOldIndex - 1] + if (!precedingBlock) { + return null + } + const precedingKey = precedingBlock._key + const newPrecedingSnapshot = alignment.newSnapshotsByKey.get(precedingKey) + + if (newPrecedingSnapshot) { + const oldPrecedingText = getBlockText(precedingBlock, spanType) + + if (newPrecedingSnapshot.text === oldPrecedingText + removedText) { + return { + type: 'block.merge', + survivingBlockKey: precedingKey, + removedBlockKey: removedKey, + joinOffset: oldPrecedingText.length, + } + } + } + } + + // Check following block absorbed the removed block's content (Delete) + if (removedOldIndex < oldBlocks.length - 1) { + const followingBlock = oldBlocks[removedOldIndex + 1] + if (!followingBlock) { + return null + } + const followingKey = followingBlock._key + const newFollowingSnapshot = alignment.newSnapshotsByKey.get(followingKey) + + if (newFollowingSnapshot) { + const oldFollowingText = getBlockText(followingBlock, spanType) + + if (newFollowingSnapshot.text === removedText + oldFollowingText) { + return { + type: 'block.merge', + survivingBlockKey: followingKey, + removedBlockKey: removedKey, + joinOffset: removedText.length, + } + } + } + } + + return null +} + +function detectTextChange( + blockKey: string, + oldBlock: PortableTextBlock, + newSnapshot: BlockTextSnapshot, + spanType: string, +): PortableTextChange | null { + const oldText = getBlockText(oldBlock, spanType) + const newText = newSnapshot.text + + const diff = findTextDiff(oldText, newText) + if (!diff) { + return null + } + + const deletedLength = diff.oldEnd - diff.diffStart + const insertedText = newText.slice(diff.diffStart, diff.newEnd) + + // All three cases resolve the span key from the old block. The insertion + // offset exists in the old text too (it's where the change starts), so the + // old block works for `text.insert` as well as delete and replace. + const spanKey = findSpanKeyAtOffset(oldBlock, diff.diffStart, spanType) + + if (deletedLength === 0 && insertedText.length > 0) { + return { + type: 'text.insert', + blockKey, + spanKey, + offset: diff.diffStart, + text: insertedText, + } + } + + if (deletedLength > 0 && insertedText.length === 0) { + return { + type: 'text.delete', + blockKey, + spanKey, + from: diff.diffStart, + to: diff.oldEnd, + } + } + + return { + type: 'text.replace', + blockKey, + spanKey, + from: diff.diffStart, + to: diff.oldEnd, + text: insertedText, + } +} + +type BlockAlignment = { + oldKeySet: Set + newKeySet: Set + insertedKeys: string[] + deletedKeys: string[] + matchedKeys: string[] + oldBlocksByKey: Map + newSnapshotsByKey: Map + newBlockIndexByKey: Map + oldBlockIndexByKey: Map +} + +/** + * Aligns old and new block lists by `_key`. Keys are unique among siblings + * (not globally), so this works for a flat block list. For container support, + * the alignment would need to account for blocks at different nesting levels + * potentially sharing keys. + * + * Assumes a single user action per flush (one split, one merge, or one + * text change). Simultaneous structural changes across multiple blocks + * may produce a `noop` result. + */ +function alignBlocks( + oldBlocks: PortableTextBlock[], + newBlocks: BlockTextSnapshot[], +): BlockAlignment { + const oldKeySet = new Set() + const newKeySet = new Set() + const oldBlocksByKey = new Map() + const newSnapshotsByKey = new Map() + const newBlockIndexByKey = new Map() + const oldBlockIndexByKey = new Map() + + for (let blockIndex = 0; blockIndex < oldBlocks.length; blockIndex++) { + const block = oldBlocks[blockIndex] + if (!block) { + continue + } + oldKeySet.add(block._key) + oldBlocksByKey.set(block._key, block) + oldBlockIndexByKey.set(block._key, blockIndex) + } + + for (let blockIndex = 0; blockIndex < newBlocks.length; blockIndex++) { + const snapshot = newBlocks[blockIndex] + if (!snapshot) { + continue + } + newKeySet.add(snapshot._key) + newSnapshotsByKey.set(snapshot._key, snapshot) + newBlockIndexByKey.set(snapshot._key, blockIndex) + } + + const insertedKeys: string[] = [] + const deletedKeys: string[] = [] + const matchedKeys: string[] = [] + + for (const key of newKeySet) { + if (!oldKeySet.has(key)) { + insertedKeys.push(key) + } + } + + for (const key of oldKeySet) { + if (!newKeySet.has(key)) { + deletedKeys.push(key) + } + } + + for (const key of oldKeySet) { + if (newKeySet.has(key)) { + matchedKeys.push(key) + } + } + + return { + oldKeySet, + newKeySet, + insertedKeys, + deletedKeys, + matchedKeys, + oldBlocksByKey, + newSnapshotsByKey, + newBlockIndexByKey, + oldBlockIndexByKey, + } +} + +/** + * Find the `_key` of the span that contains the given character offset + * in the block's concatenated text. + * + * On a span boundary, returns the next span. At the end of text, returns + * the last span. + */ +function findSpanKeyAtOffset( + block: PortableTextBlock, + offset: number, + spanType: string, +): string { + if (!hasChildren(block)) { + return '' + } + + const spans = block.children.filter((child): child is PortableTextSpan => + isPortableTextSpan(child, spanType), + ) + if (spans.length === 0) { + return '' + } + + let runningOffset = 0 + for (const span of spans) { + const spanEnd = runningOffset + span.text.length + if (offset < spanEnd) { + return span._key + } + runningOffset = spanEnd + } + + const lastSpan = spans[spans.length - 1] + return lastSpan ? lastSpan._key : '' +} + +type TextDiffRange = { + diffStart: number + oldEnd: number + newEnd: number +} + +function findTextDiff(oldText: string, newText: string): TextDiffRange | null { + if (oldText === newText) { + return null + } + + const diffs = makeDiff(oldText, newText) + + let oldOffset = 0 + let newOffset = 0 + let firstChangeOldOffset: number | undefined + let maxOldEnd = 0 + let maxNewEnd = 0 + + for (const [operation, text] of diffs) { + if (operation === DIFF_DELETE) { + if (firstChangeOldOffset === undefined) { + firstChangeOldOffset = oldOffset + } + maxOldEnd = oldOffset + text.length + maxNewEnd = Math.max(maxNewEnd, newOffset) + oldOffset += text.length + } else if (operation === DIFF_INSERT) { + if (firstChangeOldOffset === undefined) { + firstChangeOldOffset = oldOffset + } + maxOldEnd = Math.max(maxOldEnd, oldOffset) + maxNewEnd = newOffset + text.length + newOffset += text.length + } else { + oldOffset += text.length + newOffset += text.length + } + } + + if (firstChangeOldOffset === undefined) { + return null + } + + return { + diffStart: firstChangeOldOffset, + oldEnd: maxOldEnd, + newEnd: maxNewEnd, + } +} + +function hasChildren(block: PortableTextBlock): block is PortableTextTextBlock { + return 'children' in block && Array.isArray(block.children) +} + +function isPortableTextSpan( + child: unknown, + spanType: string, +): child is PortableTextSpan { + if (typeof child !== 'object' || child === null) { + return false + } + return ( + '_type' in child && + child._type === spanType && + 'text' in child && + typeof child.text === 'string' + ) +} + +function getBlockText(block: PortableTextBlock, spanType: string): string { + if (!hasChildren(block)) { + return '' + } + return block.children + .filter((child): child is PortableTextSpan => + isPortableTextSpan(child, spanType), + ) + .map((span) => span.text) + .join('') +} diff --git a/packages/editor/src/dom/change-to-behavior-event.test.ts b/packages/editor/src/dom/change-to-behavior-event.test.ts new file mode 100644 index 000000000..29bbbb9fc --- /dev/null +++ b/packages/editor/src/dom/change-to-behavior-event.test.ts @@ -0,0 +1,287 @@ +import {describe, expect, test} from 'vitest' +import {portableTextChangeToBehaviorEvent} from './change-to-behavior-event' + +describe('portableTextChangeToBehaviorEvent', () => { + describe('text.insert', () => { + test('maps to insert.text event with selectionBefore', () => { + const result = portableTextChangeToBehaviorEvent({ + type: 'text.insert', + blockKey: 'b1', + spanKey: 's1', + offset: 5, + text: 'Hello', + }) + + expect(result).toEqual({ + events: [{type: 'insert.text', text: 'Hello'}], + selectionBefore: { + blockKey: 'b1', + offset: 5, + }, + }) + }) + + test('uses block-global offset for selectionBefore', () => { + // offset here is block-global (e.g. offset 12 in a multi-span block) + const result = portableTextChangeToBehaviorEvent({ + type: 'text.insert', + blockKey: 'b1', + spanKey: 's2', + offset: 12, + text: 'x', + }) + + expect(result.selectionBefore).toEqual({ + blockKey: 'b1', + offset: 12, + }) + }) + }) + + describe('text.delete', () => { + test('single-span: maps to delete event with block-level paths', () => { + // Deleting "llo" from "Hello" (from=2, to=5) in a single span + const result = portableTextChangeToBehaviorEvent({ + type: 'text.delete', + blockKey: 'b1', + spanKey: 's1', + from: 2, + to: 5, + }) + + expect(result.events).toEqual([ + { + type: 'delete', + direction: 'backward', + at: { + anchor: { + path: [{_key: 'b1'}], + offset: 2, + }, + focus: { + path: [{_key: 'b1'}], + offset: 5, + }, + }, + }, + ]) + }) + + test('single-span: selectionBefore uses change.to for backward deletion', () => { + const result = portableTextChangeToBehaviorEvent( + { + type: 'text.delete', + blockKey: 'b1', + spanKey: 's1', + from: 2, + to: 5, + }, + // cursorOffset=5 means cursor is at the end of the deleted range → backward + 5, + ) + + expect(result.selectionBefore).toEqual({ + blockKey: 'b1', + offset: 5, + }) + }) + + test('single-span: selectionBefore uses change.from for forward deletion', () => { + const result = portableTextChangeToBehaviorEvent( + { + type: 'text.delete', + blockKey: 'b1', + spanKey: 's1', + from: 2, + to: 5, + }, + // cursorOffset=2 means cursor is at the start of the deleted range → forward + 2, + ) + + expect(result.selectionBefore).toEqual({ + blockKey: 'b1', + offset: 2, + }) + }) + + test('multi-span: uses block-level paths so offsets crossing span boundaries work', () => { + // Block: ["Hello " (s1, len=6), "beautiful " (s2, len=10), "world" (s3, len=5)] + // Deleting "lo beautiful wo" → from=3, to=19, spanKey=s1 + // + // BUG (before fix): paths pointed to s1 with offsets 3 and 19, + // but s1 only has length 6. toSlateRange would clamp offset 19 to 6, + // only deleting "lo " instead of the full cross-span range. + // + // FIX: paths use block-level [{_key: 'b1'}] so toSlateRange calls + // blockOffsetToSpanSelectionPoint to resolve the correct spans. + const result = portableTextChangeToBehaviorEvent({ + type: 'text.delete', + blockKey: 'b1', + spanKey: 's1', + from: 3, + to: 19, + }) + + expect(result.events).toEqual([ + { + type: 'delete', + direction: 'backward', + at: { + anchor: { + path: [{_key: 'b1'}], + offset: 3, + }, + focus: { + path: [{_key: 'b1'}], + offset: 19, + }, + }, + }, + ]) + }) + + test('infers backward deletion when cursorOffset is undefined', () => { + const result = portableTextChangeToBehaviorEvent({ + type: 'text.delete', + blockKey: 'b1', + spanKey: 's1', + from: 3, + to: 5, + }) + + expect(result.events[0]).toMatchObject({ + type: 'delete', + direction: 'backward', + }) + }) + + test('infers forward deletion when cursorOffset <= from', () => { + const result = portableTextChangeToBehaviorEvent( + { + type: 'text.delete', + blockKey: 'b1', + spanKey: 's1', + from: 3, + to: 5, + }, + 3, + ) + + expect(result.events[0]).toMatchObject({ + type: 'delete', + direction: 'forward', + }) + }) + + test('infers backward deletion when cursorOffset > from', () => { + const result = portableTextChangeToBehaviorEvent( + { + type: 'text.delete', + blockKey: 'b1', + spanKey: 's1', + from: 3, + to: 5, + }, + 5, + ) + + expect(result.events[0]).toMatchObject({ + type: 'delete', + direction: 'backward', + }) + }) + }) + + describe('text.replace', () => { + test('single-span: maps to delete + insert.text events with block-level paths', () => { + // Replacing "world" (from=6, to=11) with "universe" in a single span + const result = portableTextChangeToBehaviorEvent({ + type: 'text.replace', + blockKey: 'b1', + spanKey: 's1', + from: 6, + to: 11, + text: 'universe', + }) + + expect(result.events).toEqual([ + { + type: 'delete', + direction: 'backward', + at: { + anchor: { + path: [{_key: 'b1'}], + offset: 6, + }, + focus: { + path: [{_key: 'b1'}], + offset: 11, + }, + }, + }, + { + type: 'insert.text', + text: 'universe', + }, + ]) + }) + + test('single-span: selectionBefore uses change.to', () => { + const result = portableTextChangeToBehaviorEvent({ + type: 'text.replace', + blockKey: 'b1', + spanKey: 's1', + from: 6, + to: 11, + text: 'universe', + }) + + expect(result.selectionBefore).toEqual({ + blockKey: 'b1', + offset: 11, + }) + }) + + test('multi-span: uses block-level paths so offsets crossing span boundaries work', () => { + // Block: ["Hello " (s1, len=6), "beautiful " (s2, len=10), "world" (s3, len=5)] + // Replacing "beautiful world" (from=6, to=21) with "earth" + // + // BUG (before fix): paths pointed to s2 with offsets 6 and 21, + // but s2 only has length 10. toSlateRange would clamp, causing + // incorrect replacement. + // + // FIX: paths use block-level [{_key: 'b1'}] so toSlateRange resolves + // the correct spans via blockOffsetToSpanSelectionPoint. + const result = portableTextChangeToBehaviorEvent({ + type: 'text.replace', + blockKey: 'b1', + spanKey: 's2', + from: 6, + to: 21, + text: 'earth', + }) + + expect(result.events).toEqual([ + { + type: 'delete', + direction: 'backward', + at: { + anchor: { + path: [{_key: 'b1'}], + offset: 6, + }, + focus: { + path: [{_key: 'b1'}], + offset: 21, + }, + }, + }, + { + type: 'insert.text', + text: 'earth', + }, + ]) + }) + }) +}) diff --git a/packages/editor/src/dom/change-to-behavior-event.ts b/packages/editor/src/dom/change-to-behavior-event.ts new file mode 100644 index 000000000..1ca84b4c9 --- /dev/null +++ b/packages/editor/src/dom/change-to-behavior-event.ts @@ -0,0 +1,144 @@ +import type {BehaviorEvent} from '../behaviors' +import type {EditorSelection} from '../types/editor' +import type {PortableTextChange} from './change-detector' + +export type PortableTextChangeMappingResult = { + events: Array + selectionBefore?: { + blockKey: string + offset: number + } +} + +export function portableTextChangeToBehaviorEvent( + change: PortableTextChange, + cursorOffset?: number, +): PortableTextChangeMappingResult { + switch (change.type) { + case 'text.insert': { + return { + events: [{type: 'insert.text', text: change.text}], + selectionBefore: { + blockKey: change.blockKey, + offset: change.offset, + }, + } + } + + case 'text.delete': { + const deletionDirection = inferDeletionDirection(change, cursorOffset) + + const deleteSelection: NonNullable = { + anchor: { + path: [{_key: change.blockKey}], + offset: change.from, + }, + focus: { + path: [{_key: change.blockKey}], + offset: change.to, + }, + } + + return { + events: [ + { + type: 'delete', + direction: deletionDirection, + at: deleteSelection, + }, + ], + selectionBefore: { + blockKey: change.blockKey, + offset: deletionDirection === 'forward' ? change.from : change.to, + }, + } + } + + case 'text.replace': { + const deleteSelection: NonNullable = { + anchor: { + path: [{_key: change.blockKey}], + offset: change.from, + }, + focus: { + path: [{_key: change.blockKey}], + offset: change.to, + }, + } + + return { + events: [ + { + type: 'delete', + direction: 'backward', + at: deleteSelection, + }, + { + type: 'insert.text', + text: change.text, + }, + ], + selectionBefore: { + blockKey: change.blockKey, + offset: change.to, + }, + } + } + + // TODO(https://github.com/portabletext/editor/pull/2327): once non-patch + // compliant Slate operations are removed, `block.split` and `block.merge` + // need a new mapping strategy — they can't go through `insert.break` / + // `delete.backward` which rely on those operations internally. + case 'block.split': { + return { + events: [{type: 'insert.break'}], + selectionBefore: { + blockKey: change.originalBlockKey, + offset: change.splitOffset, + }, + } + } + + case 'block.merge': { + return { + events: [{type: 'delete.backward', unit: 'character'}], + selectionBefore: { + blockKey: change.survivingBlockKey, + offset: change.joinOffset, + }, + } + } + + case 'block.insert': { + return { + events: [{type: 'insert.break'}], + } + } + + case 'block.delete': { + return { + events: [{type: 'delete.block', at: [{_key: change.blockKey}]}], + } + } + + case 'noop': + return {events: []} + } +} + +function inferDeletionDirection( + change: {from: number; to: number}, + cursorOffset?: number, +): 'backward' | 'forward' { + if (cursorOffset === undefined) { + // Backspace is the overwhelmingly common case, especially on mobile + // where the Delete key doesn't exist. + return 'backward' + } + + if (cursorOffset <= change.from) { + return 'forward' + } + + return 'backward' +} diff --git a/packages/editor/src/dom/dom-text-reader.ts b/packages/editor/src/dom/dom-text-reader.ts new file mode 100644 index 000000000..045ac5eed --- /dev/null +++ b/packages/editor/src/dom/dom-text-reader.ts @@ -0,0 +1,80 @@ +export type BlockTextSnapshot = { + _key: string + text: string + isTextBlock: boolean +} + +/** + * Reads text content from the editor DOM for change detection. + * + * This is intentionally minimal and only extracts block keys and their + * concatenated text content. Everything else (styles, marks, list items) + * lives in the model and doesn't need DOM parsing. + */ +export function readBlockTexts(rootElement: HTMLElement): BlockTextSnapshot[] { + const snapshots: BlockTextSnapshot[] = [] + + // Iterates direct children only and assumes a flat block structure. + // For container support, this would need to walk the DOM recursively. + for (const child of rootElement.children) { + if (!(child instanceof HTMLElement)) { + continue + } + + const key = child.getAttribute('data-block-key') + if (!key) { + continue + } + + const blockType = child.getAttribute('data-block-type') + if (blockType === 'object') { + snapshots.push({_key: key, text: '', isTextBlock: false}) + continue + } + + snapshots.push({_key: key, text: readBlockText(child), isTextBlock: true}) + } + + return snapshots +} + +function readBlockText(blockElement: HTMLElement): string { + const leaves = blockElement.querySelectorAll('[data-slate-leaf]') + let text = '' + + for (const leaf of leaves) { + if (!(leaf instanceof HTMLElement)) { + continue + } + text += extractLeafText(leaf) + } + + return text +} + +function extractLeafText(leafElement: HTMLElement): string { + const stringElement = leafElement.querySelector('[data-slate-string]') + if (stringElement) { + const text = stringElement.textContent ?? '' + // Rendering appends a trailing '\n' spacer so the browser doesn't collapse + // trailing newlines. We strip that extra newline here. + if (text.endsWith('\n\n')) { + return text.slice(0, -1) + } + return text + } + + // During IME, the browser sometimes types into a zero-width element + // instead of a regular string element. + const zeroWidthElement = leafElement.querySelector('[data-slate-zero-width]') + if (zeroWidthElement) { + const rawText = (zeroWidthElement.textContent ?? '').replace(/\uFEFF/g, '') + if (rawText.length > 0) { + return rawText + } + return '' + } + + const rawText = leafElement.textContent ?? '' + return rawText.replace(/\uFEFF/g, '') +} diff --git a/packages/editor/src/dom/input-manager.ts b/packages/editor/src/dom/input-manager.ts new file mode 100644 index 000000000..f41dba942 --- /dev/null +++ b/packages/editor/src/dom/input-manager.ts @@ -0,0 +1,670 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import type {BehaviorEvent} from '../behaviors/behavior.types.event' +import type {EditorActor} from '../editor/editor-machine' +import {Path, Range, Transforms} from '../slate' +import {getSelection, isTrackedMutation} from '../slate-dom' +import {ReactEditor} from '../slate-react/plugin/react-editor' +import type {DebouncedFunc} from '../slate-react/utils/debounce' +import type {PortableTextSlateEditor} from '../types/slate-editor' +import {detectChange} from './change-detector' +import {portableTextChangeToBehaviorEvent} from './change-to-behavior-event' +import type {BlockTextSnapshot} from './dom-text-reader' +import {readBlockTexts} from './dom-text-reader' + +// Some IMEs fire compositionEnd before the final insertText +const COMPOSITION_RESOLVE_DELAY = 25 + +const FLUSH_DELAY = 200 + +const DELETE_INPUT_TYPE_MAP: Record< + string, + | {type: 'delete'; direction: 'forward'} + | {type: 'delete.forward'; unit: 'character' | 'word' | 'line' | 'block'} + | {type: 'delete.backward'; unit: 'character' | 'word' | 'line' | 'block'} +> = { + deleteByComposition: {type: 'delete', direction: 'forward'}, + deleteByCut: {type: 'delete', direction: 'forward'}, + deleteByDrag: {type: 'delete', direction: 'forward'}, + deleteContent: {type: 'delete.forward', unit: 'character'}, + deleteContentForward: {type: 'delete.forward', unit: 'character'}, + deleteContentBackward: {type: 'delete.backward', unit: 'character'}, + deleteHardLineBackward: {type: 'delete.backward', unit: 'block'}, + deleteSoftLineBackward: {type: 'delete.backward', unit: 'line'}, + deleteHardLineForward: {type: 'delete.forward', unit: 'block'}, + deleteSoftLineForward: {type: 'delete.forward', unit: 'line'}, + deleteWordBackward: {type: 'delete.backward', unit: 'word'}, + deleteWordForward: {type: 'delete.forward', unit: 'word'}, +} + +export type InputManagerOptions = { + editor: PortableTextSlateEditor + editorActor: EditorActor + editorRef: React.RefObject + scheduleOnDOMSelectionChange: DebouncedFunc<() => void> + onDOMSelectionChange: DebouncedFunc<() => void> +} + +/** + * Translates browser input events into Behavior Events. + * + * Two strategies depending on whether the event is cancelable: + * + * **Fast path** — desktop typing, delete, paste, Enter: + * The `beforeinput` event is cancelable, so we call `preventDefault()` to + * stop the browser from touching the DOM and dispatch the corresponding + * Behavior Event (`insert.text`, `delete.backward`, etc.) immediately. + * + * **Slow path** — Android IME, composition, mobile spellcheck: + * The `beforeinput` event is not cancelable, so the browser will mutate + * the DOM. We snapshot the current blocks before the mutation, let the + * browser do its thing, then `flush()`: + * 1. Read block text from the mutated DOM (just keys + text content) + * 2. Diff against the snapshot to detect what changed + * 3. Map the detected change to a Behavior Event and dispatch it + */ +export class InputManager { + private editor: PortableTextSlateEditor + private editorActor: EditorActor + private editorRef: React.RefObject + private scheduleOnDOMSelectionChange: DebouncedFunc<() => void> + private onDOMSelectionChange: DebouncedFunc<() => void> + + private flushing: 'action' | boolean = false + private compositionEndTimeoutId: ReturnType | null = null + private flushTimeoutId: ReturnType | null = null + private actionTimeoutId: ReturnType | null = null + + private pendingAction = false + private isComposing = false + private isSlowPath = false + private lastKnownBlocks: PortableTextBlock[] = [] + private compositionEndSnapshots: BlockTextSnapshot[] | null = null + private compositionStartSelection: Range | null = null + + drainPendingMutations: (() => void) | null = null + + constructor(options: InputManagerOptions) { + this.editor = options.editor + this.editorActor = options.editorActor + this.editorRef = options.editorRef + this.scheduleOnDOMSelectionChange = options.scheduleOnDOMSelectionChange + this.onDOMSelectionChange = options.onDOMSelectionChange + } + + hasPendingAction = (): boolean => this.pendingAction + + hasPendingChanges = (): boolean => this.pendingAction + + isFlushing = (): boolean | 'action' => this.flushing + + flush = (): void => { + if (this.flushTimeoutId) { + clearTimeout(this.flushTimeoutId) + this.flushTimeoutId = null + } + + if (this.actionTimeoutId) { + clearTimeout(this.actionTimeoutId) + this.actionTimeoutId = null + } + + this.drainPendingMutations?.() + + if (!this.pendingAction) { + return + } + + if (!this.flushing) { + this.flushing = 'action' + try { + this.pendingAction = false + + const editorElement = this.editorRef.current + if (!editorElement) { + this.isSlowPath = false + return + } + + const oldBlocks = this.lastKnownBlocks + + // Use snapshots captured at compositionEnd if available (they were + // read before RestoreDOM reverted the DOM). + let newSnapshots: BlockTextSnapshot[] + if (this.compositionEndSnapshots) { + newSnapshots = this.compositionEndSnapshots + this.compositionEndSnapshots = null + } else { + newSnapshots = readBlockTexts(editorElement) + } + + const cursorOffset = this.getCurrentCursorOffset() + const change = detectChange( + oldBlocks, + newSnapshots, + cursorOffset, + this.editorActor.getSnapshot().context.schema.span.name, + ) + + const mapping = portableTextChangeToBehaviorEvent(change, cursorOffset) + + if (mapping.selectionBefore) { + this.applySelectionHint(mapping.selectionBefore) + } + + for (const behaviorEvent of mapping.events) { + this.editorActor.send({ + type: 'behavior event', + behaviorEvent, + editor: this.editor, + }) + } + + this.lastKnownBlocks = this.snapshotBlocks() + this.isSlowPath = false + + this.scheduleOnDOMSelectionChange.flush() + this.onDOMSelectionChange.flush() + } finally { + setTimeout(() => { + this.flushing = false + }) + } + } + } + + scheduleFlush = (): void => { + if (!this.actionTimeoutId) { + this.actionTimeoutId = setTimeout(this.flush) + } + } + + handleDOMBeforeInput = (event: InputEvent): void => { + if (this.flushTimeoutId) { + clearTimeout(this.flushTimeoutId) + this.flushTimeoutId = null + } + + // Flush pending selection changes so Slate's selection is current + // (IMEs and extensions like Grammarly set DOM selection before beforeinput) + this.scheduleOnDOMSelectionChange.flush() + this.onDOMSelectionChange.flush() + + const inputType = event.inputType + + if ( + this.isComposing && + (inputType === 'insertCompositionText' || + inputType === 'deleteCompositionText') + ) { + return + } + + // `insertText` arriving during composition (after compositionEnd but before + // the timeout clears `isComposing`) is the composition's final commitment. + // Handle via fast path so cross-boundary selections work correctly. + if (this.isComposing && inputType === 'insertText' && event.cancelable) { + this.isComposing = false + this.editor.composing = false + this.compositionEndSnapshots = null + + if (this.compositionEndTimeoutId) { + clearTimeout(this.compositionEndTimeoutId) + this.compositionEndTimeoutId = null + } + + // Restore the selection from compositionStart. The browser may have + // collapsed a cross-boundary selection into one span during composition. + const savedSelection = this.compositionStartSelection + this.compositionStartSelection = null + + if (savedSelection) { + try { + if ( + !this.editor.selection || + !Range.equals(this.editor.selection, savedSelection) + ) { + Transforms.select(this.editor, savedSelection) + } + } catch { + // Fall back to getTargetRanges() below + } + } + + if (!savedSelection && !this.editor.isNodeMapDirty) { + const [nativeTargetRange] = event.getTargetRanges() + if (nativeTargetRange) { + try { + const targetRange = ReactEditor.toSlateRange( + this.editor, + nativeTargetRange, + {exactMatch: false, suppressThrow: true}, + ) + if ( + targetRange && + (!this.editor.selection || + !Range.equals(this.editor.selection, targetRange)) + ) { + Transforms.select(this.editor, targetRange) + } + } catch { + // Proceed with current selection + } + } + } + + event.preventDefault() + const data = event.data + this.send({ + type: 'insert.text', + text: typeof data === 'string' ? data : '', + }) + return + } + + this.lastKnownBlocks = this.snapshotBlocks() + + // `insertReplacementText` (autocorrect/spellcheck): when cancelable + // (desktop), handle immediately. On Android (not cancelable), fall + // through to the slow path. + if ( + inputType === 'insertReplacementText' && + event.cancelable && + !this.editor.isNodeMapDirty + ) { + const data = event.data + const text = typeof data === 'string' ? data : '' + if (text) { + const [nativeTargetRange] = event.getTargetRanges() + if (nativeTargetRange) { + const targetRange = ReactEditor.toSlateRange( + this.editor, + nativeTargetRange, + { + exactMatch: false, + suppressThrow: true, + }, + ) + if (targetRange) { + Transforms.select(this.editor, targetRange) + } + } + + event.preventDefault() + this.send({type: 'insert.text', text}) + return + } + } + + if (this.tryFastPath(event)) { + return + } + + // Slow path: let the browser mutate the DOM, then parse-and-diff + this.pendingAction = true + this.isSlowPath = true + + this.actionTimeoutId = setTimeout(this.flush, FLUSH_DELAY) + } + + handleCompositionStart = (_event: CompositionEvent): void => { + this.isComposing = true + this.editor.composing = true + + // Save the selection for cross-boundary composition handling. + // The browser may collapse the selection into one span during composition, + // but we need the original to handle cross-boundary deletion correctly. + this.compositionStartSelection = this.editor.selection + ? { + anchor: {...this.editor.selection.anchor}, + focus: {...this.editor.selection.focus}, + } + : null + + if (this.compositionEndTimeoutId) { + clearTimeout(this.compositionEndTimeoutId) + this.compositionEndTimeoutId = null + } + + this.lastKnownBlocks = this.snapshotBlocks() + } + + handleCompositionEnd = (event: CompositionEvent): void => { + const compositionData = typeof event.data === 'string' ? event.data : null + + if (this.compositionEndTimeoutId) { + clearTimeout(this.compositionEndTimeoutId) + } + + const savedSelection = this.compositionStartSelection + const isCrossBoundary = + savedSelection !== null && + compositionData !== null && + !Range.isCollapsed(savedSelection) && + isCrossBoundarySelection(savedSelection) + + if (!isCrossBoundary) { + // Same-span composition: send `insert.text` synchronously so callers + // that read the editor value immediately see the update. This is + // critical for `userEvent.fill()` in Firefox. + // + // We use `compositionData` rather than parsing the DOM because the + // DOM structure may have been destroyed by the composition. + // + // Two composing flags: + // - `isComposing` (internal) controls event routing in `tryFastPath` + // - `editor.composing` controls RestoreDOM and onDOMSelectionChange + this.isComposing = false + this.editor.composing = false + this.compositionStartSelection = null + this.compositionEndSnapshots = null + + if (compositionData) { + this.send({type: 'insert.text', text: compositionData}) + } + + this.lastKnownBlocks = this.snapshotBlocks() + this.isSlowPath = false + } else { + // Cross-boundary composition: defer so we can restore the original + // selection. Capture DOM state now before RestoreDOM reverts it. + const editorElement = this.editorRef.current + if (editorElement) { + this.compositionEndSnapshots = readBlockTexts(editorElement) + } + + this.compositionEndTimeoutId = setTimeout(() => { + this.isComposing = false + this.editor.composing = false + this.compositionStartSelection = null + + try { + Transforms.select(this.editor, savedSelection) + this.send({type: 'insert.text', text: compositionData}) + this.lastKnownBlocks = this.snapshotBlocks() + this.isSlowPath = false + this.compositionEndSnapshots = null + return + } catch { + // Selection invalid — fall through to parse-and-diff + } + + this.pendingAction = true + this.flush() + }, COMPOSITION_RESOLVE_DELAY) + } + } + + handleInput = (): void => { + if (this.pendingAction && !this.isComposing) { + this.flush() + } + } + + handleKeyDown = (_event: KeyboardEvent): void => { + // SwiftKey closes the keyboard when typing next to a non-contenteditable + // element. Temporarily hiding the placeholder works around this. + if (!this.pendingAction) { + const placeholderElement = this.editor.domPlaceholderElement + if (placeholderElement) { + placeholderElement.style.display = 'none' + setTimeout(() => { + placeholderElement.style.removeProperty('display') + }) + } + } + } + + handleUserSelect = (range: Range | null): void => { + this.editor.pendingSelection = range + + if (this.flushTimeoutId) { + clearTimeout(this.flushTimeoutId) + this.flushTimeoutId = null + } + + if (!range) { + return + } + + if (this.pendingAction) { + this.flushTimeoutId = setTimeout(this.flush, FLUSH_DELAY) + } + } + + handleDomMutations = (mutations: MutationRecord[]): void => { + // On the slow path, force re-render to restore DOM state for unexpected + // mutations. On the fast path we called preventDefault() so there + // should be no unexpected mutations — forceRender() there would + // interfere with React renders (e.g. range decoration updates). + if (!this.pendingAction && this.isSlowPath) { + if ( + mutations.some((mutation) => + isTrackedMutation(this.editor, mutation, mutations), + ) + ) { + this.editor.forceRender?.() + } + } + } + + private snapshotBlocks(): PortableTextBlock[] { + return [...(this.editor.children as Array)] + } + + private getCurrentCursorOffset(): number | undefined { + const {selection} = this.editor + if (selection && Range.isCollapsed(selection)) { + return selection.anchor.offset + } + return undefined + } + + private applySelectionHint(hint: {blockKey: string; offset: number}): void { + const blockIndex = this.editor.blockIndexMap.get(hint.blockKey) + if (blockIndex === undefined) { + return + } + + const block = (this.editor.children as Array)[blockIndex] + if (!block || !('children' in block) || !Array.isArray(block.children)) { + return + } + + let remainingOffset = hint.offset + let childIndex = 0 + for (let i = 0; i < block.children.length; i++) { + const child = block.children[i] + if (!child || typeof child !== 'object') { + continue + } + const text = 'text' in child ? String(child.text) : '' + if (remainingOffset <= text.length) { + childIndex = i + break + } + remainingOffset -= text.length + childIndex = i + } + + const path = [blockIndex, childIndex] + try { + const point = {path, offset: remainingOffset} + const range = {anchor: point, focus: point} + if ( + !this.editor.selection || + !Range.equals(this.editor.selection, range) + ) { + Transforms.select(this.editor, range) + } + } catch { + // Invalid path — silently ignore + } + } + + private send(behaviorEvent: BehaviorEvent): void { + this.editorActor.send({ + type: 'behavior event', + behaviorEvent, + editor: this.editor, + }) + } + + private tryFastPath(event: InputEvent): boolean { + const inputType = event.inputType + + // Structural events always use the fast path, even during composition. + // On slower environments (CI), Firefox may fire compositionstart before + // insertParagraph, but the block split must go through the behavior system. + if (inputType === 'insertParagraph' && event.cancelable) { + event.preventDefault() + this.send({type: 'insert.break'}) + return true + } + + if (inputType === 'insertLineBreak' && event.cancelable) { + event.preventDefault() + this.send({type: 'insert.soft break'}) + return true + } + + if (!event.cancelable || this.isComposing) { + return false + } + const {selection} = this.editor + + // Handled by `handleNativeHistoryEvents` in editable.tsx + if (inputType === 'historyUndo' || inputType === 'historyRedo') { + event.preventDefault() + return true + } + + if ( + inputType === 'insertCompositionText' || + inputType === 'deleteCompositionText' + ) { + return false + } + + // With expanded selection, all delete variants collapse to a simple + // directional delete — the unit is irrelevant. + if ( + selection && + Range.isExpanded(selection) && + inputType.startsWith('delete') + ) { + const direction = inputType.endsWith('Backward') ? 'backward' : 'forward' + event.preventDefault() + this.send({type: 'delete', direction}) + return true + } + + // DataTransfer becomes inaccessible after the event handler returns + const data = + (event as InputEvent & {dataTransfer?: DataTransfer}).dataTransfer ?? + event.data ?? + undefined + + if ( + inputType === 'insertFromPaste' || + inputType === 'insertFromDrop' || + inputType === 'insertFromYank' || + inputType === 'insertFromComposition' + ) { + event.preventDefault() + + // Safari fires insertFromComposition before compositionEnd + if (inputType === 'insertFromComposition') { + this.isComposing = false + this.editor.composing = false + } + + if (isDataTransfer(data)) { + this.send({type: 'input.*', originEvent: {dataTransfer: data}}) + } else { + this.send({ + type: 'insert.text', + text: typeof data === 'string' ? data : '', + }) + } + return true + } + + if (inputType === 'deleteEntireSoftLine') { + event.preventDefault() + this.send({type: 'delete.backward', unit: 'line'}) + this.send({type: 'delete.forward', unit: 'line'}) + return true + } + + const deleteEvent = DELETE_INPUT_TYPE_MAP[inputType] + if (deleteEvent) { + // When Slate's selection is collapsed but the DOM selection is expanded + // (e.g. after Ctrl+A where selectionchange hasn't been processed yet), + // sync Slate's selection with the browser's target range. + // We check the DOM selection (not just the target range) to avoid + // catching grapheme cluster deletion — where the target range is + // expanded but the DOM selection is collapsed at the cursor. + if ( + (inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward') && + selection && + Range.isCollapsed(selection) && + !this.editor.isNodeMapDirty + ) { + try { + const root = ReactEditor.findDocumentOrShadowRoot(this.editor) + const domSelection = getSelection(root) + if (domSelection && !domSelection.isCollapsed) { + const [nativeTargetRange] = event.getTargetRanges() + if (nativeTargetRange) { + const targetRange = ReactEditor.toSlateRange( + this.editor, + nativeTargetRange, + {exactMatch: false, suppressThrow: true}, + ) + if (targetRange && Range.isExpanded(targetRange)) { + event.preventDefault() + Transforms.select(this.editor, targetRange) + const direction = inputType.endsWith('Backward') + ? 'backward' + : 'forward' + this.send({type: 'delete', direction}) + return true + } + } + } + } catch { + // Proceed with normal delete handling + } + } + event.preventDefault() + this.send(deleteEvent) + return true + } + + if (inputType === 'insertText') { + if (isDataTransfer(data)) { + event.preventDefault() + this.send({type: 'input.*', originEvent: {dataTransfer: data}}) + return true + } + + event.preventDefault() + this.send({ + type: 'insert.text', + text: typeof data === 'string' ? data : '', + }) + return true + } + + return false + } +} + +function isCrossBoundarySelection(selection: Range): boolean { + return !Path.equals(selection.anchor.path, selection.focus.path) +} + +function isDataTransfer(value: any): value is DataTransfer { + return value?.constructor?.name === 'DataTransfer' +} diff --git a/packages/editor/src/dom/use-input-manager.ts b/packages/editor/src/dom/use-input-manager.ts new file mode 100644 index 000000000..87cb9de4b --- /dev/null +++ b/packages/editor/src/dom/use-input-manager.ts @@ -0,0 +1,61 @@ +import {useState, type RefObject} from 'react' +import type {EditorActor} from '../editor/editor-machine' +import {useIsMounted} from '../slate-react/hooks/use-is-mounted' +import {useMutationObserver} from '../slate-react/hooks/use-mutation-observer' +import {useSlateStatic} from '../slate-react/hooks/use-slate-static' +import {InputManager, type InputManagerOptions} from './input-manager' + +export type UseInputManagerOptions = { + editorActor: EditorActor + node: RefObject +} & Omit< + InputManagerOptions, + 'editor' | 'editorActor' | 'onUserInput' | 'receivedUserInput' +> + +const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { + subtree: true, + childList: true, + characterData: true, +} + +export function useInputManager(options: UseInputManagerOptions): InputManager { + const {editorActor, node, ...restOptions} = options + + const editor = useSlateStatic() + const isMounted = useIsMounted() + + const [inputManager] = useState( + () => + new InputManager({ + editor, + editorActor, + ...restOptions, + }), + ) + + const observer = useMutationObserver( + node, + inputManager.handleDomMutations, + MUTATION_OBSERVER_CONFIG, + ) + + // Flush pending MutationObserver records synchronously. The observer + // callback is a microtask that may not have fired when `flush()` runs. + // eslint-disable-next-line react-hooks/immutability + inputManager.drainPendingMutations = () => { + const records = observer.takeRecords() + if (records.length > 0) { + inputManager.handleDomMutations(records) + } + } + + // eslint-disable-next-line react-hooks/immutability + editor.scheduleFlush = inputManager.scheduleFlush + + if (isMounted) { + inputManager.flush() + } + + return inputManager +} diff --git a/packages/editor/src/slate-react/components/editable.tsx b/packages/editor/src/slate-react/components/editable.tsx index 9f53812aa..f617c6da7 100644 --- a/packages/editor/src/slate-react/components/editable.tsx +++ b/packages/editor/src/slate-react/components/editable.tsx @@ -10,6 +10,8 @@ import React, { type JSX, } from 'react' import scrollIntoView from 'scroll-into-view-if-needed' +import type {InputManager} from '../../dom/input-manager' +import {useInputManager} from '../../dom/use-input-manager' import type {EditorActor} from '../../editor/editor-machine' import { Editor, @@ -34,11 +36,7 @@ import { IS_ANDROID, IS_CHROME, IS_FIREFOX, - IS_FIREFOX_LEGACY, - IS_IOS, - IS_UC_MOBILE, IS_WEBKIT, - IS_WECHATBROWSER, isDOMElement, isDOMNode, isPlainTextOnlyPaste, @@ -47,10 +45,7 @@ import { TRIPLE_CLICK, type DOMElement, type DOMRange, - type DOMText, } from '../../slate-dom' -import type {AndroidInputManager} from '../hooks/android-input-manager/android-input-manager' -import {useAndroidInputManager} from '../hooks/android-input-manager/use-android-input-manager' import useChildren from '../hooks/use-children' import {ComposingContext} from '../hooks/use-composing' import {DecorateContext, useDecorateContext} from '../hooks/use-decorations' @@ -64,8 +59,6 @@ import {debounce, throttle} from '../utils/debounce' import getDirection from '../utils/direction' import {RestoreDOM} from './restore-dom/restore-dom' -type DeferredOperation = () => void - const Children = (props: Parameters[0]) => ( {useChildren(props)} ) @@ -186,7 +179,6 @@ export const Editable = forwardRef( // Rerender editor when composition status changed const [isComposing, setIsComposing] = useState(false) const ref = useRef(null) - const deferredOperations = useRef([]) const [placeholderHeight, setPlaceholderHeight] = useState< number | undefined >() @@ -225,14 +217,12 @@ export const Editable = forwardRef( }, [autoFocus]) /** - * The AndroidInputManager object has a cyclical dependency on onDOMSelectionChange + * The `InputManager` has a cyclical dependency on `onDOMSelectionChange` * * It is defined as a reference to simplify hook dependencies and clarify that * it needs to be initialized. */ - const androidInputManagerRef = useRef< - AndroidInputManager | null | undefined - >(undefined) + const inputManagerRef = useRef(null) // Listen on the native `selectionchange` event to be able to update any time // the selection changes. This is required because React's `onSelect` is leaky @@ -265,10 +255,10 @@ export const Editable = forwardRef( return } - const androidInputManager = androidInputManagerRef.current + const inputManager = inputManagerRef.current if ( - (IS_ANDROID || !ReactEditor.isComposing(editor)) && - (!state.isUpdatingSelection || androidInputManager?.isFlushing()) && + (!ReactEditor.isComposing(editor) || inputManager?.isFlushing()) && + (!state.isUpdatingSelection || inputManager?.isFlushing()) && !state.isDraggingInternally ) { const root = ReactEditor.findDocumentOrShadowRoot(editor) @@ -304,14 +294,14 @@ export const Editable = forwardRef( if (range) { if ( !ReactEditor.isComposing(editor) && - !androidInputManager?.hasPendingChanges() && - !androidInputManager?.isFlushing() + !inputManager?.hasPendingChanges() && + !inputManager?.isFlushing() ) { // Suppress browser selection normalization that would // overwrite a block object selection. Transforms.select(editor, range) } else { - androidInputManager?.handleUserSelect(range) + inputManager?.handleUserSelect(range) } } } @@ -330,9 +320,10 @@ export const Editable = forwardRef( [onDOMSelectionChange], ) - androidInputManagerRef.current = useAndroidInputManager({ + inputManagerRef.current = useInputManager({ editorActor, node: ref as React.RefObject, + editorRef: ref as React.RefObject, onDOMSelectionChange, scheduleOnDOMSelectionChange, }) @@ -358,7 +349,7 @@ export const Editable = forwardRef( if ( !domSelection || !ReactEditor.isFocused(editor) || - androidInputManagerRef.current?.hasPendingAction() + inputManagerRef.current?.hasPendingAction() ) { return } @@ -457,7 +448,10 @@ export const Editable = forwardRef( } if (newDomRange) { - if (ReactEditor.isComposing(editor) && !IS_ANDROID) { + if ( + ReactEditor.isComposing(editor) && + !inputManagerRef.current?.isFlushing() + ) { domSelection.collapseToEnd() } else if (Range.isBackward(selection!)) { domSelection.setBaseAndExtent( @@ -487,10 +481,9 @@ export const Editable = forwardRef( setDomSelection() } - const ensureSelection = - androidInputManagerRef.current?.isFlushing() === 'action' + const ensureSelection = inputManagerRef.current?.isFlushing() === 'action' - if (!IS_ANDROID || !ensureSelection) { + if (!ensureSelection) { setTimeout(() => { state.isUpdatingSelection = false }) @@ -573,357 +566,14 @@ export const Editable = forwardRef( ReactEditor.hasEditableTarget(editor, event.target) && !isDOMEventHandled(event, propsOnDOMBeforeInput) ) { - // COMPAT: BeforeInput events aren't cancelable on android, so we have to handle them differently using the android input manager. - if (androidInputManagerRef.current) { - return androidInputManagerRef.current.handleDOMBeforeInput(event) - } - - // Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before - // triggering a `beforeinput` expecting the change to be applied to the immediately before - // set selection. - scheduleOnDOMSelectionChange.flush() - onDOMSelectionChange.flush() - - const {selection} = editor - const {inputType: type} = event - const data = (event as any).dataTransfer || event.data || undefined - - const isCompositionChange = - type === 'insertCompositionText' || type === 'deleteCompositionText' - - // COMPAT: use composition change events as a hint to where we should insert - // composition text if we aren't composing to work around https://github.com/ianstormtaylor/slate/issues/5038 - if (isCompositionChange && ReactEditor.isComposing(editor)) { - return - } - - 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 has issues correctly editing the start of nodes: https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 - // 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 - } - - // If the NODE_MAP is dirty, we can't trust the selection anchor (eg ReactEditor.toDOMPoint) - if (!editor.isNodeMapDirty) { - // Chrome also has issues correctly editing the end of anchor elements: https://bugs.chromium.org/p/chromium/issues/detail?id=1259100 - // Therefore we don't allow native events to insert text at the end of anchor nodes. - const {anchor} = selection - - const [node, offset] = ReactEditor.toDOMPoint(editor, anchor) - const anchorNode = node.parentElement?.closest('a') - - const window = ReactEditor.getWindow(editor) - - if ( - native && - anchorNode && - ReactEditor.hasDOMNode(editor, anchorNode) - ) { - // Find the last text node inside the anchor. - const lastText = window?.document - .createTreeWalker(anchorNode, NodeFilter.SHOW_TEXT) - .lastChild() as DOMText | null - - if ( - lastText === node && - lastText.textContent?.length === offset - ) { - native = false - } - } - - // Chrome has issues with the presence of tab characters inside elements with whiteSpace = 'pre' - // causing abnormal insert behavior: https://bugs.chromium.org/p/chromium/issues/detail?id=1219139 - if ( - native && - node.parentElement && - window?.getComputedStyle(node.parentElement)?.whiteSpace === - 'pre' - ) { - const block = Editor.above(editor, { - at: anchor.path, - match: (n) => - Element.isElement(n, editor.schema) && - Editor.isBlock(editor, n), - }) - - if ( - block && - Node.string(block[0], editor.schema).includes('\t') - ) { - native = false - } - } - } - } - // 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, - // and those commands determine that for themselves. - // If the NODE_MAP is dirty, we can't trust the selection anchor (eg ReactEditor.toDOMPoint via ReactEditor.toSlateRange) - if ( - (!type.startsWith('delete') || type.startsWith('deleteBy')) && - !editor.isNodeMapDirty - ) { - const [targetRange] = (event as any).getTargetRanges() - - if (targetRange) { - const range = ReactEditor.toSlateRange(editor, targetRange, { - exactMatch: false, - suppressThrow: false, - }) - - if (!selection || !Range.equals(selection, range)) { - native = false - - const selectionRef = - !isCompositionChange && - editor.selection && - Editor.rangeRef(editor, editor.selection) - - Transforms.select(editor, range) - - if (selectionRef) { - editor.userSelection = selectionRef - } - } - } - } - - // Composition change types occur while a user is composing text and can't be - // cancelled. Let them through and wait for the composition to end. - if (isCompositionChange) { - return - } - - if (!native) { - event.preventDefault() - } - - // COMPAT: If the selection is expanded, even if the command seems like - // a delete forward/backward command it should delete the selection. - if ( - selection && - Range.isExpanded(selection) && - type.startsWith('delete') - ) { - const direction = type.endsWith('Backward') ? 'backward' : 'forward' - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction}, - editor, - }) - return - } - - switch (type) { - case 'deleteByComposition': - case 'deleteByCut': - case 'deleteByDrag': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction: 'forward'}, - editor, - }) - break - } - - case 'deleteContent': - case 'deleteContentForward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'character'}, - editor, - }) - break - } - - case 'deleteContentBackward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'character'}, - editor, - }) - break - } - - case 'deleteEntireSoftLine': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'line'}, - editor, - }) - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'line'}, - editor, - }) - break - } - - case 'deleteHardLineBackward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'block'}, - editor, - }) - break - } - - case 'deleteSoftLineBackward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'line'}, - editor, - }) - break - } - - case 'deleteHardLineForward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'block'}, - editor, - }) - break - } - - case 'deleteSoftLineForward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'line'}, - editor, - }) - break - } - - case 'deleteWordBackward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'word'}, - editor, - }) - break - } - - case 'deleteWordForward': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'word'}, - editor, - }) - break - } - - case 'insertLineBreak': - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.soft break'}, - editor, - }) - break - - case 'insertParagraph': { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.break'}, - editor, - }) - break - } - - case 'insertFromComposition': - case 'insertFromDrop': - case 'insertFromPaste': - case 'insertFromYank': - case 'insertReplacementText': - case 'insertText': { - if (type === 'insertFromComposition') { - // COMPAT: in Safari, `compositionend` is dispatched after the - // `beforeinput` for "insertFromComposition". But if we wait for it - // then we will abort because we're still composing and the selection - // won't be updated properly. - // https://www.w3.org/TR/input-events-2/ - if (ReactEditor.isComposing(editor)) { - setIsComposing(false) - editor.composing = false - } - } - - // use a weak comparison instead of 'instanceof' to allow - // programmatic access of paste events coming from external windows - // like cypress where cy.window does not work realibly - if (data?.constructor.name === 'DataTransfer') { - editorActor.send({ - type: 'behavior event', - behaviorEvent: { - type: 'input.*', - originEvent: {dataTransfer: data}, - }, - editor, - }) - } else if (typeof data === 'string') { - // Only insertText operations use the native functionality, for now. - // Potentially expand to single character deletes, as well. - if (native) { - deferredOperations.current.push(() => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.text', text: data}, - editor, - }), - ) - } else { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.text', text: data}, - editor, - }) - } - } - - break - } - } - - // Restore the actual user section if nothing manually set it. - const toRestore = editor.userSelection?.unref() - editor.userSelection = null - - if ( - toRestore && - (!editor.selection || !Range.equals(editor.selection, toRestore)) - ) { - Transforms.select(editor, toRestore) - } + // Delegate all beforeinput handling to the input manager. + // The manager handles both the fast path (preventDefault + direct + // behavior event on desktop) and the slow path (parse-and-diff + // fallback for composition, spellcheck, Android IME, etc.) + inputManagerRef.current?.handleDOMBeforeInput(event) } }, - [ - editor, - editorActor, - onDOMSelectionChange, - onUserInput, - propsOnDOMBeforeInput, - readOnly, - scheduleOnDOMSelectionChange, - ], + [editor, onUserInput, propsOnDOMBeforeInput, readOnly], ) const callbackRef = useCallback( @@ -1179,33 +829,9 @@ export const Editable = forwardRef( return } - if (androidInputManagerRef.current) { - androidInputManagerRef.current.handleInput() - return - } - - // 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. - for (const op of deferredOperations.current) { - op() - } - deferredOperations.current = [] - - // 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, - // and it only triggers the input event for the node. (2024/10/09) - if (!ReactEditor.isFocused(editor)) { - handleNativeHistoryEvents( - editor, - editorActor, - event.nativeEvent as InputEvent, - ) - } + inputManagerRef.current?.handleInput() }, - [attributes.onInput, editor, editorActor], + [attributes.onInput], )} onBlur={useCallback( (event: React.FocusEvent) => { @@ -1363,56 +989,16 @@ export const Editable = forwardRef( }) } - androidInputManagerRef.current?.handleCompositionEnd( - event, + inputManagerRef.current?.handleCompositionEnd( + event.nativeEvent, ) - if ( - isEventHandled(event, attributes.onCompositionEnd) || - IS_ANDROID - ) { + if (isEventHandled(event, attributes.onCompositionEnd)) { return } - - // COMPAT: In Chrome, `beforeinput` events for compositions - // aren't correct and never fire the "insertFromComposition" - // type that we need. So instead, insert whenever a composition - // ends since it will already have been committed to the DOM. - if ( - !IS_WEBKIT && - !IS_FIREFOX_LEGACY && - !IS_IOS && - !IS_WECHATBROWSER && - !IS_UC_MOBILE && - event.data - ) { - const placeholderMarks = editor.pendingInsertionMarks - editor.pendingInsertionMarks = null - - // Ensure we insert text with the marks the user was actually seeing - if (placeholderMarks !== undefined) { - editor.userMarks = editor.marks - editor.marks = placeholderMarks as typeof editor.marks - } - - editorActor.send({ - type: 'behavior event', - behaviorEvent: { - type: 'insert.text', - text: event.data, - }, - editor, - }) - - const userMarks = editor.userMarks - editor.userMarks = null - if (userMarks !== undefined) { - editor.marks = userMarks as typeof editor.marks - } - } } }, - [attributes.onCompositionEnd, editor, editorActor], + [attributes.onCompositionEnd, editor], )} onCompositionUpdate={useCallback( (event: React.CompositionEvent) => { @@ -1435,31 +1021,20 @@ export const Editable = forwardRef( return } if (ReactEditor.hasSelectableTarget(editor, event.target)) { - androidInputManagerRef.current?.handleCompositionStart( - event, + inputManagerRef.current?.handleCompositionStart( + event.nativeEvent, ) if ( - isEventHandled(event, attributes.onCompositionStart) || - IS_ANDROID + isEventHandled(event, attributes.onCompositionStart) ) { return } setIsComposing(true) - - const {selection} = editor - if (selection && Range.isExpanded(selection)) { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction: 'forward'}, - editor, - }) - return - } } }, - [attributes.onCompositionStart, editor, editorActor], + [attributes.onCompositionStart, editor], )} onCopy={useCallback( (event: React.ClipboardEvent) => { @@ -1656,7 +1231,7 @@ export const Editable = forwardRef( !readOnly && ReactEditor.hasEditableTarget(editor, event.target) ) { - androidInputManagerRef.current?.handleKeyDown(event) + inputManagerRef.current?.handleKeyDown(event.nativeEvent) const {nativeEvent} = event diff --git a/packages/editor/src/slate-react/components/restore-dom/restore-dom.tsx b/packages/editor/src/slate-react/components/restore-dom/restore-dom.tsx index d1f23deff..34d766deb 100644 --- a/packages/editor/src/slate-react/components/restore-dom/restore-dom.tsx +++ b/packages/editor/src/slate-react/components/restore-dom/restore-dom.tsx @@ -5,7 +5,6 @@ import { type ReactNode, type RefObject, } from 'react' -import {IS_ANDROID} from '../../../slate-dom' import {EditorContext} from '../../hooks/use-slate-static' import { createRestoreDomManager, @@ -79,6 +78,4 @@ class RestoreDOMComponent extends Component { } } -export const RestoreDOM: ComponentType = IS_ANDROID - ? RestoreDOMComponent - : ({children}) => <>{children} +export const RestoreDOM: ComponentType = RestoreDOMComponent diff --git a/packages/editor/src/slate-react/hooks/android-input-manager/android-input-manager.ts b/packages/editor/src/slate-react/hooks/android-input-manager/android-input-manager.ts deleted file mode 100644 index 688e5c4af..000000000 --- a/packages/editor/src/slate-react/hooks/android-input-manager/android-input-manager.ts +++ /dev/null @@ -1,951 +0,0 @@ -import type {EditorActor} from '../../../editor/editor-machine' -import { - Editor, - Node, - Path, - Point, - Range, - Text, - Transforms, -} from '../../../slate' -import { - applyStringDiff, - isDOMSelection, - isTrackedMutation, - mergeStringDiffs, - normalizePoint, - normalizeRange, - normalizeStringDiff, - targetRange, - verifyDiffState, - type StringDiff, - type TextDiff, -} from '../../../slate-dom' -import {ReactEditor} from '../../plugin/react-editor' -import type {DebouncedFunc} from '../../utils/debounce' - -export type Action = {at?: Point | Range; run: () => void} - -// https://github.com/facebook/draft-js/blob/main/src/component/handlers/composition/DraftEditorCompositionHandler.js#L41 -// When using keyboard English association function, conpositionEnd triggered too fast, resulting in after `insertText` still maintain association state. -const RESOLVE_DELAY = 25 - -// Time with no user interaction before the current user action is considered as done. -const FLUSH_DELAY = 200 - -// Replace with `const debug = console.log` to debug -const debug = (..._: unknown[]) => {} - -// Type guard to check if a value is a DataTransfer -const isDataTransfer = (value: any): value is DataTransfer => - value?.constructor.name === 'DataTransfer' - -export type CreateAndroidInputManagerOptions = { - editor: Editor - editorActor: EditorActor - - scheduleOnDOMSelectionChange: DebouncedFunc<() => void> - onDOMSelectionChange: DebouncedFunc<() => void> -} - -export type AndroidInputManager = { - flush: () => void - scheduleFlush: () => void - - hasPendingDiffs: () => boolean - hasPendingAction: () => boolean - hasPendingChanges: () => boolean - isFlushing: () => boolean | 'action' - - handleUserSelect: (range: Range | null) => void - handleCompositionEnd: (event: React.CompositionEvent) => void - handleCompositionStart: ( - event: React.CompositionEvent, - ) => void - handleDOMBeforeInput: (event: InputEvent) => void - handleKeyDown: (event: React.KeyboardEvent) => void - - handleDomMutations: (mutations: MutationRecord[]) => void - handleInput: () => void -} - -export function createAndroidInputManager({ - editor, - editorActor, - scheduleOnDOMSelectionChange, - onDOMSelectionChange, -}: CreateAndroidInputManagerOptions): AndroidInputManager { - let flushing: 'action' | boolean = false - let compositionEndTimeoutId: ReturnType | null = null - let flushTimeoutId: ReturnType | null = null - let actionTimeoutId: ReturnType | null = null - - let idCounter = 0 - let insertPositionHint: StringDiff | null | false = false - - const applyPendingSelection = () => { - const pendingSelection = editor.pendingSelection - editor.pendingSelection = null - - if (pendingSelection) { - const {selection} = editor - const normalized = normalizeRange(editor, pendingSelection) - - debug('apply pending selection', pendingSelection, normalized) - - if (normalized && (!selection || !Range.equals(normalized, selection))) { - Transforms.select(editor, normalized) - } - } - } - - const performAction = () => { - const action = editor.pendingAction - editor.pendingAction = null - if (!action) { - return - } - - if (action.at) { - const target = Point.isPoint(action.at) - ? normalizePoint(editor, action.at) - : normalizeRange(editor, action.at) - - if (!target) { - return - } - - const targetRange = Editor.range(editor, target) - if (!editor.selection || !Range.equals(editor.selection, targetRange)) { - Transforms.select(editor, target) - } - } - - action.run() - } - - const flush = () => { - if (flushTimeoutId) { - clearTimeout(flushTimeoutId) - flushTimeoutId = null - } - - if (actionTimeoutId) { - clearTimeout(actionTimeoutId) - actionTimeoutId = null - } - - if (!hasPendingDiffs() && !hasPendingAction()) { - applyPendingSelection() - return - } - - if (!flushing) { - flushing = true - // biome-ignore lint/suspicious/noAssignInExpressions: Slate upstream pattern - setTimeout(() => (flushing = false)) - } - - if (hasPendingAction()) { - flushing = 'action' - } - - const selectionRef = - editor.selection && - Editor.rangeRef(editor, editor.selection, {affinity: 'forward'}) - editor.userMarks = editor.marks - - debug('flush', editor.pendingAction, editor.pendingDiffs) - - let scheduleSelectionChange = hasPendingDiffs() - - let diff: TextDiff | undefined - // biome-ignore lint/suspicious/noAssignInExpressions: Slate upstream pattern - while ((diff = editor.pendingDiffs?.[0])) { - const pendingMarks = editor.pendingInsertionMarks - - if (pendingMarks !== undefined) { - editor.pendingInsertionMarks = null - editor.marks = pendingMarks as typeof editor.marks - } - - if (pendingMarks && insertPositionHint === false) { - insertPositionHint = null - debug('insert after mark placeholder') - } - - const range = targetRange(diff) - if (!editor.selection || !Range.equals(editor.selection, range)) { - Transforms.select(editor, range) - } - - if (diff.diff.text) { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.text', text: diff.diff.text}, - editor, - }) - } else { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction: 'forward'}, - editor, - }) - } - - // Remove diff only after we have applied it to account for it when transforming - // pending ranges. - // biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Slate upstream pattern — diffs guaranteed to exist in loop - editor.pendingDiffs = editor.pendingDiffs?.filter( - ({id}) => id !== diff!.id, - )! - - if (!verifyDiffState(editor, diff)) { - debug('invalid diff state') - scheduleSelectionChange = false - editor.pendingAction = null - editor.userMarks = null - flushing = 'action' - - // Ensure we don't restore the pending user (dom) selection - // since the document and dom state do not match. - editor.pendingSelection = null - scheduleOnDOMSelectionChange.cancel() - onDOMSelectionChange.cancel() - selectionRef?.unref() - } - } - - const selection = selectionRef?.unref() - if ( - selection && - !editor.pendingSelection && - (!editor.selection || !Range.equals(selection, editor.selection)) - ) { - Transforms.select(editor, selection) - } - - if (hasPendingAction()) { - performAction() - return - } - - // COMPAT: The selectionChange event is fired after the action is performed, - // so we have to manually schedule it to ensure we don't 'throw away' the selection - // while rendering if we have pending changes. - if (scheduleSelectionChange) { - debug('scheduleOnDOMSelectionChange pending changes') - scheduleOnDOMSelectionChange() - } - - scheduleOnDOMSelectionChange.flush() - onDOMSelectionChange.flush() - - applyPendingSelection() - - const userMarks = editor.userMarks - editor.userMarks = null - if (userMarks !== undefined) { - editor.marks = userMarks as typeof editor.marks - editor.onChange() - } - } - - const handleCompositionEnd = ( - _event: React.CompositionEvent, - ) => { - if (compositionEndTimeoutId) { - clearTimeout(compositionEndTimeoutId) - } - - compositionEndTimeoutId = setTimeout(() => { - editor.composing = false - flush() - }, RESOLVE_DELAY) - } - - const handleCompositionStart = ( - _event: React.CompositionEvent, - ) => { - debug('composition start') - - editor.composing = true - - if (compositionEndTimeoutId) { - clearTimeout(compositionEndTimeoutId) - compositionEndTimeoutId = null - } - } - - const updatePlaceholderVisibility = (forceHide = false) => { - const placeholderElement = editor.domPlaceholderElement - if (!placeholderElement) { - return - } - - if (hasPendingDiffs() || forceHide) { - placeholderElement.style.display = 'none' - return - } - - placeholderElement.style.removeProperty('display') - } - - const storeDiff = (path: Path, diff: StringDiff) => { - debug('storeDiff', path, diff) - - const pendingDiffs = editor.pendingDiffs - - const target = Node.leaf(editor, path, editor.schema) - - if (!Text.isText(target, editor.schema)) { - return - } - - const idx = pendingDiffs.findIndex((change) => - Path.equals(change.path, path), - ) - if (idx < 0) { - const normalized = normalizeStringDiff(target.text, diff) - if (normalized) { - pendingDiffs.push({path, diff, id: idCounter++}) - } - - updatePlaceholderVisibility() - return - } - - const merged = mergeStringDiffs(target.text, pendingDiffs[idx]!.diff, diff) - if (!merged) { - pendingDiffs.splice(idx, 1) - updatePlaceholderVisibility() - return - } - - pendingDiffs[idx] = { - ...pendingDiffs[idx]!, - diff: merged, - } - } - - const scheduleAction = ( - run: () => void, - {at}: {at?: Point | Range} = {}, - ): void => { - insertPositionHint = false - debug('scheduleAction', {at, run}) - - editor.pendingSelection = null - scheduleOnDOMSelectionChange.cancel() - onDOMSelectionChange.cancel() - - if (hasPendingAction()) { - flush() - } - - editor.pendingAction = {at, run} - - // COMPAT: When deleting before a non-contenteditable element chrome only fires a beforeinput, - // (no input) and doesn't perform any dom mutations. Without a flush timeout we would never flush - // in this case and thus never actually perform the action. - actionTimeoutId = setTimeout(flush) - } - - const handleDOMBeforeInput = (event: InputEvent): void => { - if (flushTimeoutId) { - clearTimeout(flushTimeoutId) - flushTimeoutId = null - } - - if (editor.isNodeMapDirty) { - return - } - - const {inputType: type} = event - let targetRange: Range | null = null - const data: DataTransfer | string | undefined = - (event as any).dataTransfer || event.data || undefined - - if ( - insertPositionHint !== false && - type !== 'insertText' && - type !== 'insertCompositionText' - ) { - insertPositionHint = false - } - - let [nativeTargetRange] = (event as any).getTargetRanges() - if (nativeTargetRange) { - targetRange = ReactEditor.toSlateRange(editor, nativeTargetRange, { - exactMatch: false, - suppressThrow: true, - }) - } - - // COMPAT: SelectionChange event is fired after the action is performed, so we - // have to manually get the selection here to ensure it's up-to-date. - const window = ReactEditor.getWindow(editor) - const domSelection = window.getSelection() - if (!targetRange && domSelection) { - nativeTargetRange = domSelection - targetRange = ReactEditor.toSlateRange(editor, domSelection, { - exactMatch: false, - suppressThrow: true, - }) - } - - targetRange = targetRange ?? editor.selection - if (!targetRange) { - return - } - - // By default, the input manager tries to store text diffs so that we can - // defer flushing them at a later point in time. We don't want to flush - // for every input event as this can be expensive. However, there are some - // scenarios where we cannot safely store the text diff and must instead - // schedule an action to let Slate normalize the editor state. - let canStoreDiff = true - - if (type.startsWith('delete')) { - const direction = type.endsWith('Backward') ? 'backward' : 'forward' - let [start, end] = Range.edges(targetRange) - let [leaf, path] = Editor.leaf(editor, start.path) - - if (!Text.isText(leaf, editor.schema)) { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction}, - editor, - }), - {at: targetRange}, - ) - } - - if (Range.isExpanded(targetRange)) { - if (leaf.text.length === start.offset && end.offset === 0) { - const next = Editor.next(editor, { - at: start.path, - match: (n) => Text.isText(n, editor.schema), - }) - if (next && Path.equals(next[1], end.path)) { - // when deleting a linebreak, targetRange will span across the break (ie start in the node before and end in the node after) - // if the node before is empty, this will look like a hanging range and get unhung later--which will take the break we want to remove out of the range - // so to avoid this we collapse the target range to default to single character deletion - if (direction === 'backward') { - targetRange = {anchor: end, focus: end} - start = end - ;[leaf, path] = next - } else { - targetRange = {anchor: start, focus: start} - end = start - } - } - } - } - - const diff = { - text: '', - start: start.offset, - end: end.offset, - } - const pendingDiffs = editor.pendingDiffs - const relevantPendingDiffs = pendingDiffs?.find((change) => - Path.equals(change.path, path), - ) - const diffs = relevantPendingDiffs - ? [relevantPendingDiffs.diff, diff] - : [diff] - const text = applyStringDiff(leaf.text, ...diffs) - - if (text.length === 0) { - // Text leaf will be removed, so we need to schedule an - // action to remove it so that Slate can normalize instead - // of storing as a diff - canStoreDiff = false - } - - if (Range.isExpanded(targetRange)) { - if ( - canStoreDiff && - Path.equals(targetRange.anchor.path, targetRange.focus.path) - ) { - const point = {path: targetRange.anchor.path, offset: start.offset} - const range = Editor.range(editor, point, point) - handleUserSelect(range) - - return storeDiff(targetRange.anchor.path, { - text: '', - end: end.offset, - start: start.offset, - }) - } - - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction}, - editor, - }), - {at: targetRange}, - ) - } - } - - switch (type) { - case 'deleteByComposition': - case 'deleteByCut': - case 'deleteByDrag': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction: 'forward'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteContent': - case 'deleteContentForward': { - const {anchor} = targetRange - if (canStoreDiff && Range.isCollapsed(targetRange)) { - const targetNode = Node.leaf(editor, anchor.path, editor.schema) - - if ( - Text.isText(targetNode, editor.schema) && - anchor.offset < targetNode.text.length - ) { - return storeDiff(anchor.path, { - text: '', - start: anchor.offset, - end: anchor.offset + 1, - }) - } - } - - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'character'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteContentBackward': { - const {anchor} = targetRange - - // If we have a mismatch between the native and slate selection being collapsed - // we are most likely deleting a zero-width placeholder and thus should perform it - // as an action to ensure correct behavior (mostly happens with mark placeholders) - const nativeCollapsed = isDOMSelection(nativeTargetRange) - ? nativeTargetRange.isCollapsed - : !!nativeTargetRange?.collapsed - - if ( - canStoreDiff && - nativeCollapsed && - Range.isCollapsed(targetRange) && - anchor.offset > 0 - ) { - return storeDiff(anchor.path, { - text: '', - start: anchor.offset - 1, - end: anchor.offset, - }) - } - - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'character'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteEntireSoftLine': { - return scheduleAction( - () => { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'line'}, - editor, - }) - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'line'}, - editor, - }) - }, - {at: targetRange}, - ) - } - - case 'deleteHardLineBackward': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'block'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteSoftLineBackward': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'line'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteHardLineForward': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'block'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteSoftLineForward': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'line'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteWordBackward': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.backward', unit: 'word'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'deleteWordForward': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete.forward', unit: 'word'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'insertLineBreak': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.soft break'}, - editor, - }), - {at: targetRange}, - ) - } - - case 'insertParagraph': { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.break'}, - editor, - }), - {at: targetRange}, - ) - } - case 'insertCompositionText': - case 'deleteCompositionText': - case 'insertFromComposition': - case 'insertFromDrop': - case 'insertFromPaste': - case 'insertFromYank': - case 'insertReplacementText': - case 'insertText': { - if (isDataTransfer(data)) { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: { - type: 'input.*', - originEvent: {dataTransfer: data}, - }, - editor, - }), - {at: targetRange}, - ) - } - - let text = data ?? '' - - // COMPAT: If we are writing inside a placeholder, the ime inserts the text inside - // the placeholder itself and thus includes the zero-width space inside edit events. - if (editor.pendingInsertionMarks) { - text = text.replace('\uFEFF', '') - } - - // Pastes from the Android clipboard will generate `insertText` events. - // If the copied text contains any newlines, Android will append an - // extra newline to the end of the copied text. - if (type === 'insertText' && /.*\n.*\n$/.test(text)) { - text = text.slice(0, -1) - } - - // If the text includes a newline, split it at newlines and paste each component - // string, with soft breaks in between each. - if (text.includes('\n')) { - return scheduleAction( - () => { - const parts = text.split('\n') - parts.forEach((line, i) => { - if (line) { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.text', text: line}, - editor, - }) - } - if (i !== parts.length - 1) { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.soft break'}, - editor, - }) - } - }) - }, - { - at: targetRange, - }, - ) - } - - if (Path.equals(targetRange.anchor.path, targetRange.focus.path)) { - const [start, end] = Range.edges(targetRange) - - const diff = { - start: start.offset, - end: end.offset, - text, - } - - // COMPAT: Swiftkey has a weird bug where the target range of the 2nd word - // inserted after a mark placeholder is inserted with an anchor offset off by 1. - // So writing 'some text' will result in 'some ttext'. Luckily all 'normal' insert - // text events are fired with the correct target ranges, only the final 'insertComposition' - // isn't, so we can adjust the target range start offset if we are confident this is the - // swiftkey insert causing the issue. - if (text && insertPositionHint && type === 'insertCompositionText') { - const hintPosition = - insertPositionHint.start + insertPositionHint.text.search(/\S|$/) - const diffPosition = diff.start + diff.text.search(/\S|$/) - - if ( - diffPosition === hintPosition + 1 && - diff.end === - insertPositionHint.start + insertPositionHint.text.length - ) { - debug('adjusting swiftKey insert position using hint') - diff.start -= 1 - insertPositionHint = null - scheduleFlush() - } else { - insertPositionHint = false - } - } else if (type === 'insertText') { - if (insertPositionHint === null) { - insertPositionHint = diff - } else if ( - insertPositionHint && - Range.isCollapsed(targetRange) && - insertPositionHint.end + insertPositionHint.text.length === - start.offset - ) { - insertPositionHint = { - ...insertPositionHint, - text: insertPositionHint.text + text, - } - } else { - insertPositionHint = false - } - } else { - insertPositionHint = false - } - - if (canStoreDiff) { - const currentSelection = editor.selection - storeDiff(start.path, diff) - - if (currentSelection) { - const newPoint = { - path: start.path, - offset: start.offset + text.length, - } - - scheduleAction( - () => { - Transforms.select(editor, { - anchor: newPoint, - focus: newPoint, - }) - }, - {at: newPoint}, - ) - } - return - } - } - - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'insert.text', text}, - editor, - }), - {at: targetRange}, - ) - } - } - } - - const hasPendingAction = () => { - return !!editor.pendingAction - } - - const hasPendingDiffs = () => { - return !!editor.pendingDiffs?.length - } - - const hasPendingChanges = () => { - return hasPendingAction() || hasPendingDiffs() - } - - const isFlushing = () => { - return flushing - } - - const handleUserSelect = (range: Range | null) => { - editor.pendingSelection = range - - if (flushTimeoutId) { - clearTimeout(flushTimeoutId) - flushTimeoutId = null - } - - const {selection} = editor - if (!range) { - return - } - - const pathChanged = - !selection || !Path.equals(selection.anchor.path, range.anchor.path) - const parentPathChanged = - !selection || - !Path.equals( - selection.anchor.path.slice(0, -1), - range.anchor.path.slice(0, -1), - ) - - if ((pathChanged && insertPositionHint) || parentPathChanged) { - insertPositionHint = false - } - - if (pathChanged || hasPendingDiffs()) { - flushTimeoutId = setTimeout(flush, FLUSH_DELAY) - } - } - - const handleInput = () => { - if (hasPendingAction() || !hasPendingDiffs()) { - debug('flush input') - flush() - } - } - - const handleKeyDown = (_: React.KeyboardEvent) => { - // COMPAT: Swiftkey closes the keyboard when typing inside a empty node - // directly next to a non-contenteditable element (= the placeholder). - // The only event fired soon enough for us to allow hiding the placeholder - // without swiftkey picking it up is the keydown event, so we have to hide it - // here. See https://github.com/ianstormtaylor/slate/pull/4988#issuecomment-1201050535 - if (!hasPendingDiffs()) { - updatePlaceholderVisibility(true) - setTimeout(updatePlaceholderVisibility) - } - } - - const scheduleFlush = () => { - if (!hasPendingAction()) { - actionTimeoutId = setTimeout(flush) - } - } - - const handleDomMutations = (mutations: MutationRecord[]) => { - if (hasPendingDiffs() || hasPendingAction()) { - return - } - - if ( - mutations.some((mutation) => - isTrackedMutation(editor, mutation, mutations), - ) - ) { - // Cause a re-render to restore the dom state if we encounter tracked mutations without - // a corresponding pending action. - editor.forceRender?.() - } - } - - return { - flush, - scheduleFlush, - - hasPendingDiffs, - hasPendingAction, - hasPendingChanges, - - isFlushing, - - handleUserSelect, - handleCompositionEnd, - handleCompositionStart, - handleDOMBeforeInput, - handleKeyDown, - - handleDomMutations, - handleInput, - } -} diff --git a/packages/editor/src/slate-react/hooks/android-input-manager/use-android-input-manager.ts b/packages/editor/src/slate-react/hooks/android-input-manager/use-android-input-manager.ts deleted file mode 100644 index 097241165..000000000 --- a/packages/editor/src/slate-react/hooks/android-input-manager/use-android-input-manager.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {useState, type RefObject} from 'react' -import type {EditorActor} from '../../../editor/editor-machine' -import {IS_ANDROID} from '../../../slate-dom' -import {useIsMounted} from '../use-is-mounted' -import {useMutationObserver} from '../use-mutation-observer' -import {useSlateStatic} from '../use-slate-static' -import { - createAndroidInputManager, - type CreateAndroidInputManagerOptions, -} from './android-input-manager' - -type UseAndroidInputManagerOptions = { - editorActor: EditorActor - node: RefObject -} & Omit< - CreateAndroidInputManagerOptions, - 'editor' | 'editorActor' | 'onUserInput' | 'receivedUserInput' -> - -const MUTATION_OBSERVER_CONFIG: MutationObserverInit = { - subtree: true, - childList: true, - characterData: true, -} - -export const useAndroidInputManager = !IS_ANDROID - ? () => null - : ({editorActor, node, ...options}: UseAndroidInputManagerOptions) => { - if (!IS_ANDROID) { - return null - } - - // biome-ignore lint/correctness/useHookAtTopLevel: Slate's platform-conditional hook pattern (Android-only) - const editor = useSlateStatic() - // biome-ignore lint/correctness/useHookAtTopLevel: Slate's platform-conditional hook pattern (Android-only) - const isMounted = useIsMounted() - - // biome-ignore lint/correctness/useHookAtTopLevel: Slate's platform-conditional hook pattern (Android-only) - const [inputManager] = useState(() => - createAndroidInputManager({ - editor, - editorActor, - ...options, - }), - ) - - // biome-ignore lint/correctness/useHookAtTopLevel: Slate's platform-conditional hook pattern (Android-only) - useMutationObserver( - node, - inputManager.handleDomMutations, - MUTATION_OBSERVER_CONFIG, - ) - editor.scheduleFlush = inputManager.scheduleFlush - if (isMounted) { - inputManager.flush() - } - - return inputManager - } diff --git a/packages/editor/src/slate-react/hooks/use-mutation-observer.ts b/packages/editor/src/slate-react/hooks/use-mutation-observer.ts index 34ebadf43..72d16e33a 100644 --- a/packages/editor/src/slate-react/hooks/use-mutation-observer.ts +++ b/packages/editor/src/slate-react/hooks/use-mutation-observer.ts @@ -5,7 +5,7 @@ export function useMutationObserver( node: RefObject, callback: MutationCallback, options: MutationObserverInit, -) { +): MutationObserver { const [mutationObserver] = useState(() => new MutationObserver(callback)) useIsomorphicLayoutEffect(() => { @@ -22,4 +22,6 @@ export function useMutationObserver( mutationObserver.observe(node.current, options) return () => mutationObserver.disconnect() }, [mutationObserver, node, options]) + + return mutationObserver } diff --git a/packages/editor/tests/composition.test.ts b/packages/editor/tests/composition.test.ts index fe21ae64f..1d55eb711 100644 --- a/packages/editor/tests/composition.test.ts +++ b/packages/editor/tests/composition.test.ts @@ -1879,4 +1879,83 @@ describe.skipIf(!isChromium)('Composition (IME)', () => { }) }) }) + + describe('Cross-block composition', () => { + test('composition replacing a cross-block selection merges blocks', async () => { + const keyGenerator = createTestKeyGenerator() + const block0Key = keyGenerator() + const span0Key = keyGenerator() + const block1Key = keyGenerator() + const span1Key = keyGenerator() + + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({}), + initialValue: [ + { + _key: block0Key, + _type: 'block', + children: [{_key: span0Key, _type: 'span', text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _key: block1Key, + _type: 'block', + children: [{_key: span1Key, _type: 'span', text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ], + }) + + await userEvent.click(locator) + + const selection = { + anchor: { + path: [{_key: block0Key}, 'children', {_key: span0Key}], + offset: 1, + }, + focus: { + path: [{_key: block1Key}, 'children', {_key: span1Key}], + offset: 2, + }, + backward: false, + } + editor.send({ + type: 'select', + at: selection, + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual(selection) + }) + + enableCompositionKeyEvents() + + const session = cdp() + + // Compose 'x' via IME to replace the cross-block selection + await session.send('Input.imeSetComposition', { + text: 'x', + selectionStart: 1, + selectionEnd: 1, + }) + await delay() + await session.send('Input.insertText', {text: 'x'}) + + // Assert: blocks should be merged into one with 'fxr' + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _key: block0Key, + _type: 'block', + children: [{_key: span0Key, _type: 'span', text: 'fxr', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + }) + }) })