From e1fb188c8e7d80a7ae5a6d4e99537c237ca0f0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Mon, 9 Mar 2026 11:47:47 +0100 Subject: [PATCH 1/2] test: composition across blocks --- packages/editor/tests/composition.test.ts | 79 +++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/editor/tests/composition.test.ts b/packages/editor/tests/composition.test.ts index fe21ae64f3..1d55eb7116 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', + }, + ]) + }) + }) + }) }) From 5da96fb6babfc50a180d743b567ac6ef625be38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Mon, 2 Mar 2026 17:49:02 +0000 Subject: [PATCH 2/2] 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. --- .changeset/major-hairs-share.md | 19 + .../editor/src/dom/change-detector.test.ts | 596 +++++++++++ packages/editor/src/dom/change-detector.ts | 606 +++++++++++ .../src/dom/change-to-behavior-event.test.ts | 317 ++++++ .../src/dom/change-to-behavior-event.ts | 144 +++ packages/editor/src/dom/dom-text-reader.ts | 80 ++ packages/editor/src/dom/input-manager.ts | 714 +++++++++++++ packages/editor/src/dom/use-input-manager.ts | 61 ++ .../editor/src/engine/dom/utils/diff-text.ts | 216 ---- packages/editor/src/engine/dom/utils/dom.ts | 13 +- .../src/engine/dom/utils/environment.ts | 22 - .../src/engine/path/is-descendant-path.ts | 6 - .../src/engine/react/components/editable.tsx | 500 +-------- .../components/restore-dom/restore-dom.tsx | 5 +- .../android-input-manager.ts | 946 ------------------ .../use-android-input-manager.ts | 59 -- .../react/hooks/use-mutation-observer.ts | 4 +- .../tests/decorator-toggle-no-ghost.test.tsx | 32 + 18 files changed, 2608 insertions(+), 1732 deletions(-) create mode 100644 .changeset/major-hairs-share.md create mode 100644 packages/editor/src/dom/change-detector.test.ts create mode 100644 packages/editor/src/dom/change-detector.ts create mode 100644 packages/editor/src/dom/change-to-behavior-event.test.ts create mode 100644 packages/editor/src/dom/change-to-behavior-event.ts create mode 100644 packages/editor/src/dom/dom-text-reader.ts create mode 100644 packages/editor/src/dom/input-manager.ts create mode 100644 packages/editor/src/dom/use-input-manager.ts delete mode 100644 packages/editor/src/engine/path/is-descendant-path.ts delete mode 100644 packages/editor/src/engine/react/hooks/android-input-manager/android-input-manager.ts delete mode 100644 packages/editor/src/engine/react/hooks/android-input-manager/use-android-input-manager.ts create mode 100644 packages/editor/tests/decorator-toggle-no-ghost.test.tsx diff --git a/.changeset/major-hairs-share.md b/.changeset/major-hairs-share.md new file mode 100644 index 0000000000..1449848bab --- /dev/null +++ b/.changeset/major-hairs-share.md @@ -0,0 +1,19 @@ +--- +"@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 fact cancelable; 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. + +It also removes a class of desktop DOM corruption: because cancelable input no +longer mutates the DOM ahead of the model, typing immediately after toggling a +decorator (for example bold) at a collapsed cursor no longer leaves a stray +"ghost" copy of the character in the editor. The rendered text now always +matches the document. 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 0000000000..f1c369c237 --- /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 0000000000..2674cf4aac --- /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 0000000000..b48c3f18e9 --- /dev/null +++ b/packages/editor/src/dom/change-to-behavior-event.test.ts @@ -0,0 +1,317 @@ +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]).toEqual({ + type: 'delete', + direction: 'backward', + at: { + anchor: { + path: [{_key: 'b1'}], + offset: 3, + }, + focus: { + path: [{_key: 'b1'}], + offset: 5, + }, + }, + }) + }) + + 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]).toEqual({ + type: 'delete', + direction: 'forward', + at: { + anchor: { + path: [{_key: 'b1'}], + offset: 3, + }, + focus: { + path: [{_key: 'b1'}], + offset: 5, + }, + }, + }) + }) + + 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]).toEqual({ + type: 'delete', + direction: 'backward', + at: { + anchor: { + path: [{_key: 'b1'}], + offset: 3, + }, + focus: { + path: [{_key: 'b1'}], + offset: 5, + }, + }, + }) + }) + }) + + 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 0000000000..1ca84b4c9c --- /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 0000000000..045ac5eedf --- /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 0000000000..cf704ea184 --- /dev/null +++ b/packages/editor/src/dom/input-manager.ts @@ -0,0 +1,714 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import type {BehaviorEvent} from '../behaviors/behavior.types.event' +import type {EditorActor} from '../editor/editor-machine' +import {DOMEditor} from '../engine/dom/plugin/dom-editor' +import {getSelection, isTrackedMutation} from '../engine/dom/utils/dom' +import type {Editor} from '../engine/interfaces/editor' +import type {Range} from '../engine/interfaces/range' +import {pathEquals} from '../engine/path/path-equals' +import {isCollapsedRange} from '../engine/range/is-collapsed-range' +import {isExpandedRange} from '../engine/range/is-expanded-range' +import {rangeEquals} from '../engine/range/range-equals' +import type {DebouncedFunc} from '../engine/react/utils/debounce' +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: Editor + 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: Editor + 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.snapshot.context.selection || + !rangeEquals(this.editor.snapshot.context.selection, savedSelection) + ) { + this.editor.select(savedSelection) + } + } catch { + // Fall back to getTargetRanges() below + } + } + + if (!savedSelection && !this.editor.isNodeMapDirty) { + const [nativeTargetRange] = event.getTargetRanges() + if (nativeTargetRange) { + try { + const targetRange = DOMEditor.toEditorSelection( + this.editor, + nativeTargetRange, + {exactMatch: false, suppressThrow: true}, + ) + if ( + targetRange && + (!this.editor.snapshot.context.selection || + !rangeEquals( + this.editor.snapshot.context.selection, + targetRange, + )) + ) { + this.editor.select(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 = DOMEditor.toEditorSelection( + this.editor, + nativeTargetRange, + { + exactMatch: false, + suppressThrow: true, + }, + ) + if (targetRange) { + this.editor.select(targetRange) + } + } else { + this.selectDOMSelectionFallback() + } + + 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.snapshot.context.selection + ? { + anchor: {...this.editor.snapshot.context.selection.anchor}, + focus: {...this.editor.snapshot.context.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 && + !isCollapsedRange(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 { + this.editor.select(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; the original workaround hid the placeholder element here. The + // engine no longer exposes `editor.domPlaceholderElement` (the placeholder + // is rendered outside the engine), so the workaround has no element to + // target and this is a no-op until the placeholder element is re-exposed. + } + + 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.snapshot.context.value ?? [])] + } + + private getCurrentCursorOffset(): number | undefined { + const {selection} = this.editor.snapshot.context + if (selection && isCollapsedRange(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.snapshot.context.value ?? [])[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.snapshot.context.selection || + !rangeEquals(this.editor.snapshot.context.selection, range) + ) { + this.editor.select(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.snapshot.context + + // 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 && + isExpandedRange(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 && + isCollapsedRange(selection) && + !this.editor.isNodeMapDirty + ) { + try { + const root = DOMEditor.findDocumentOrShadowRoot(this.editor) + const domSelection = getSelection(root) + if (domSelection && !domSelection.isCollapsed) { + const [nativeTargetRange] = event.getTargetRanges() + if (nativeTargetRange) { + const targetRange = DOMEditor.toEditorSelection( + this.editor, + nativeTargetRange, + {exactMatch: false, suppressThrow: true}, + ) + if (targetRange && isExpandedRange(targetRange)) { + event.preventDefault() + this.editor.select(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 + } + + const [nativeTargetRange] = event.getTargetRanges() + if (!nativeTargetRange) { + this.selectDOMSelectionFallback() + } + + event.preventDefault() + this.send({ + type: 'insert.text', + text: typeof data === 'string' ? data : '', + }) + return true + } + + return false + } + + // Synthetic `beforeinput` events dispatched by browser extensions like + // Grammarly carry no target ranges; they set the DOM selection on the range + // to operate on instead. The `onDOMSelectionChange` flush in + // `handleDOMBeforeInput` normally syncs the editor selection to that DOM + // selection, but inside an iframe it can't: accepting an extension + // suggestion moves focus off the editable, so the flush bails on its focus + // check and the editor selection stays stale. Fall back to the DOM selection + // so the input lands on the targeted range, not the stale cursor. + private selectDOMSelectionFallback(): void { + if (this.editor.isNodeMapDirty) { + return + } + + const domSelection = getSelection( + DOMEditor.findDocumentOrShadowRoot(this.editor), + ) + if (!domSelection || domSelection.rangeCount === 0) { + return + } + + const targetRange = DOMEditor.toEditorSelection(this.editor, domSelection, { + exactMatch: false, + // The DOM selection fallback is not guaranteed to be a valid editor + // range, so don't throw on it. + suppressThrow: true, + }) + + if ( + targetRange && + (!this.editor.snapshot.context.selection || + !rangeEquals(this.editor.snapshot.context.selection, targetRange)) + ) { + this.editor.select(targetRange) + } + } +} + +function isCrossBoundarySelection(selection: Range): boolean { + return !pathEquals(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 0000000000..51e9351c93 --- /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 {useEngineStatic} from '../engine/react/hooks/use-engine-static' +import {useIsMounted} from '../engine/react/hooks/use-is-mounted' +import {useMutationObserver} from '../engine/react/hooks/use-mutation-observer' +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 = useEngineStatic() + 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/engine/dom/utils/diff-text.ts b/packages/editor/src/engine/dom/utils/diff-text.ts index c92ed948c4..1499d9b9ba 100644 --- a/packages/editor/src/engine/dom/utils/diff-text.ts +++ b/packages/editor/src/engine/dom/utils/diff-text.ts @@ -1,16 +1,8 @@ -import {isSpan, isTextBlock} from '@portabletext/schema' -import {getNodes} from '../../../traversal/get-nodes' -import {getParent} from '../../../traversal/get-parent' -import {getSibling} from '../../../traversal/get-sibling' -import {getSpan} from '../../../traversal/get-span' -import {hasNode} from '../../../traversal/has-node' -import {after} from '../../editor/after' import type {Editor} from '../../interfaces/editor' import type {EngineOperation} from '../../interfaces/operation' import type {Path} from '../../interfaces/path' import type {Point} from '../../interfaces/point' import type {Range} from '../../interfaces/range' -import {isDescendantPath} from '../../path/is-descendant-path' import {pathEquals} from '../../path/path-equals' import {transformPath} from '../../path/transform-path' import {transformPoint} from '../../point/transform-point' @@ -28,214 +20,6 @@ export type TextDiff = { diff: StringDiff } -/** - * Check whether a text diff was applied in a way we can perform the pending action on / - * recover the pending selection. - */ -export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean { - const {path, diff} = textDiff - if (!hasNode(editor.snapshot, path)) { - return false - } - - const nodeEntry = getSpan(editor.snapshot, path) - if (!nodeEntry) { - return false - } - const node = nodeEntry.node - - if (diff.start !== node.text.length || diff.text.length === 0) { - return ( - node.text.slice(diff.start, diff.start + diff.text.length) === diff.text - ) - } - - const nextSibling = getSibling(editor.snapshot, path, {direction: 'next'}) - if (!nextSibling) { - return false - } - - const nextNodeEntry = getSpan(editor.snapshot, nextSibling.path) - return !!nextNodeEntry && nextNodeEntry.node.text.startsWith(diff.text) -} - -export function applyStringDiff(text: string, ...diffs: StringDiff[]) { - return diffs.reduce( - (text, diff) => - text.slice(0, diff.start) + diff.text + text.slice(diff.end), - text, - ) -} - -function longestCommonPrefixLength(str: string, another: string) { - const length = Math.min(str.length, another.length) - - for (let i = 0; i < length; i++) { - if (str.charAt(i) !== another.charAt(i)) { - return i - } - } - - return length -} - -function longestCommonSuffixLength( - str: string, - another: string, - max: number, -): number { - const length = Math.min(str.length, another.length, max) - - for (let i = 0; i < length; i++) { - if ( - str.charAt(str.length - i - 1) !== another.charAt(another.length - i - 1) - ) { - return i - } - } - - return length -} - -/** - * Remove redundant changes from the diff so that it spans the minimal possible range - */ -export function normalizeStringDiff(targetText: string, diff: StringDiff) { - const {start, end, text} = diff - const removedText = targetText.slice(start, end) - - const prefixLength = longestCommonPrefixLength(removedText, text) - const max = Math.min( - removedText.length - prefixLength, - text.length - prefixLength, - ) - const suffixLength = longestCommonSuffixLength(removedText, text, max) - - const normalized: StringDiff = { - start: start + prefixLength, - end: end - suffixLength, - text: text.slice(prefixLength, text.length - suffixLength), - } - - if (normalized.start === normalized.end && normalized.text.length === 0) { - return null - } - - return normalized -} - -/** - * Return a string diff that is equivalent to applying b after a spanning the range of - * both changes - */ -export function mergeStringDiffs( - targetText: string, - a: StringDiff, - b: StringDiff, -): StringDiff | null { - const start = Math.min(a.start, b.start) - const overlap = Math.max( - 0, - Math.min(a.start + a.text.length, b.end) - b.start, - ) - - const applied = applyStringDiff(targetText, a, b) - const sliceEnd = Math.max( - b.start + b.text.length, - a.start + - a.text.length + - (a.start + a.text.length > b.start ? b.text.length : 0) - - overlap, - ) - - const text = applied.slice(start, sliceEnd) - const end = Math.max(a.end, b.end - a.text.length + (a.end - a.start)) - return normalizeStringDiff(targetText, {start, end, text}) -} - -/** - * Get the range the text diff spans. - */ -export function targetRange(textDiff: TextDiff): Range { - const {path, diff} = textDiff - return { - anchor: {path, offset: diff.start}, - focus: {path, offset: diff.end}, - } -} - -/** - * Normalize a 'pending point' a.k.a a point based on the dom state before applying - * the pending diffs. Since the pending diffs might have been inserted with different - * marks we have to 'walk' the offset from the starting position to ensure we still - * have a valid point inside the document - */ -export function normalizePoint(editor: Editor, point: Point): Point | null { - let {path, offset} = point - if (!hasNode(editor.snapshot, path)) { - return null - } - - const leafEntry = getSpan(editor.snapshot, path) - if (!leafEntry) { - return null - } - let leaf = leafEntry.node - - const parentBlock = getParent(editor.snapshot, path, { - match: (node) => - isTextBlock({schema: editor.snapshot.context.schema}, node), - }) - - if (!parentBlock) { - return null - } - - while (offset > leaf.text.length) { - const afterPoint = after(editor, path) - const [nextEntry] = afterPoint - ? getNodes(editor.snapshot, { - from: afterPoint.path, - match: (n) => isSpan({schema: editor.snapshot.context.schema}, n), - }) - : [] - if ( - !nextEntry || - !isSpan({schema: editor.snapshot.context.schema}, nextEntry.node) || - !isDescendantPath(nextEntry.path, parentBlock.path) - ) { - return null - } - - offset -= leaf.text.length - leaf = nextEntry.node - path = nextEntry.path - } - - return {path, offset} -} - -/** - * Normalize a 'pending selection' to ensure it's valid in the current document state. - */ -export function normalizeRange(editor: Editor, range: Range): Range | null { - const anchor = normalizePoint(editor, range.anchor) - if (!anchor) { - return null - } - - if (isCollapsedRange(range)) { - return {anchor, focus: anchor} - } - - const focus = normalizePoint(editor, range.focus) - if (!focus) { - return null - } - - return {anchor, focus} -} - export function transformPendingPoint( editor: Editor, point: Point, diff --git a/packages/editor/src/engine/dom/utils/dom.ts b/packages/editor/src/engine/dom/utils/dom.ts index 1ffa522085..3576fa5d94 100644 --- a/packages/editor/src/engine/dom/utils/dom.ts +++ b/packages/editor/src/engine/dom/utils/dom.ts @@ -11,22 +11,11 @@ import {DOMEditor} from '../plugin/dom-editor' type DOMNode = globalThis.Node type DOMComment = globalThis.Comment type DOMElement = globalThis.Element -// DOMText is used as a value (instanceof) in dom-editor.ts, so we need both -// the type alias and a const reference to the global constructor. -type DOMText = globalThis.Text -const DOMText = globalThis.Text type DOMRange = globalThis.Range type DOMSelection = globalThis.Selection type DOMStaticRange = globalThis.StaticRange -export { - type DOMNode, - type DOMElement, - DOMText, - type DOMRange, - type DOMSelection, - type DOMStaticRange, -} +export type {DOMNode, DOMElement, DOMRange, DOMSelection, DOMStaticRange} declare global { interface Window { diff --git a/packages/editor/src/engine/dom/utils/environment.ts b/packages/editor/src/engine/dom/utils/environment.ts index e183325323..a4ea5ada17 100644 --- a/packages/editor/src/engine/dom/utils/environment.ts +++ b/packages/editor/src/engine/dom/utils/environment.ts @@ -1,8 +1,3 @@ -export const IS_IOS = - typeof navigator !== 'undefined' && - typeof window !== 'undefined' && - /iPad|iPhone|iPod/.test(navigator.userAgent) && - !(window as any).MSStream export const IS_ANDROID = typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent) @@ -33,23 +28,6 @@ const IS_ANDROID_CHROME_LEGACY = typeof navigator !== 'undefined' && /Chrome?\/(?:[0-5]?\d)(?:\.)/i.test(navigator.userAgent) -// Firefox did not support `beforeInput` until `v87`. -export const IS_FIREFOX_LEGACY = - typeof navigator !== 'undefined' && - /^(?!.*Seamonkey)(?=.*Firefox\/(?:[0-7][0-9]|[0-8][0-6])(?:\.)).*/i.test( - navigator.userAgent, - ) - -// UC mobile browser -export const IS_UC_MOBILE = - typeof navigator !== 'undefined' && /.*UCBrowser/.test(navigator.userAgent) - -// Wechat browser (not including mac wechat) -export const IS_WECHATBROWSER = - typeof navigator !== 'undefined' && - /.*Wechat/.test(navigator.userAgent) && - !/.*MacWechat/.test(navigator.userAgent) && // avoid lookbehind (buggy in safari < 16.4) - (!IS_CHROME || IS_CHROME_LEGACY) // wechat and low chrome is real wechat // Check if DOM is available as React does internally. // https://github.com/facebook/react/blob/master/packages/shared/ExecutionEnvironment.js export const CAN_USE_DOM = !!( diff --git a/packages/editor/src/engine/path/is-descendant-path.ts b/packages/editor/src/engine/path/is-descendant-path.ts deleted file mode 100644 index 8a9b6588bc..0000000000 --- a/packages/editor/src/engine/path/is-descendant-path.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {Path} from '../interfaces/path' -import {isAncestorPath} from './is-ancestor-path' - -export function isDescendantPath(path: Path, another: Path): boolean { - return isAncestorPath(another, path) -} diff --git a/packages/editor/src/engine/react/components/editable.tsx b/packages/editor/src/engine/react/components/editable.tsx index 438dcb4e78..1af6b842f3 100644 --- a/packages/editor/src/engine/react/components/editable.tsx +++ b/packages/editor/src/engine/react/components/editable.tsx @@ -17,6 +17,8 @@ import React, { import scrollIntoView from 'scroll-into-view-if-needed' import {getDomNode} from '../../../dom-traversal/get-dom-node' import {getDomNodePath} from '../../../dom-traversal/get-dom-node-path' +import type {InputManager} from '../../../dom/input-manager' +import {useInputManager} from '../../../dom/use-input-manager' import type {EditorActor} from '../../../editor/editor-machine' import {getAncestor} from '../../../traversal/get-ancestor' import {getNode} from '../../../traversal/get-node' @@ -39,24 +41,17 @@ import { isPlainTextOnlyPaste, type DOMElement, type DOMRange, - type DOMText, } from '../../dom/utils/dom' import { CAN_USE_DOM, HAS_BEFORE_INPUT_SUPPORT, - IS_ANDROID, IS_CHROME, IS_FIREFOX, - IS_FIREFOX_LEGACY, - IS_IOS, - IS_UC_MOBILE, IS_WEBKIT, - IS_WECHATBROWSER, } from '../../dom/utils/environment' import Hotkeys from '../../dom/utils/hotkeys' import {end as editorEnd} from '../../editor/end' import {range as editorRange} from '../../editor/range' -import {rangeRef} from '../../editor/range-ref' import {start as editorStart} from '../../editor/start' import type {Editor} from '../../interfaces/editor' import type {NodeEntry} from '../../interfaces/node' @@ -68,8 +63,6 @@ import {isBackwardRange} from '../../range/is-backward-range' import {isCollapsedRange} from '../../range/is-collapsed-range' import {isExpandedRange} from '../../range/is-expanded-range' import {rangeEquals} from '../../range/range-equals' -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 {DecorateContext, useDecorateContext} from '../hooks/use-decorations' import {useEngine} from '../hooks/use-engine' @@ -81,8 +74,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]) => { const children = useChildren(props) return {children} @@ -183,7 +174,6 @@ export const Editable = forwardRef( // Rerender editor when composition status changed const ref = useRef(null) - const deferredOperations = useRef([]) const processing = useRef(false) const {onUserInput, receivedUserInput} = useTrackUserInput() @@ -217,14 +207,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 @@ -262,10 +250,10 @@ export const Editable = forwardRef( return } - const androidInputManager = androidInputManagerRef.current + const inputManager = inputManagerRef.current if ( - (IS_ANDROID || !editor.composing) && - (!state.isUpdatingSelection || androidInputManager?.isFlushing()) + (!editor.composing || inputManager?.isFlushing()) && + (!state.isUpdatingSelection || inputManager?.isFlushing()) ) { const root = DOMEditor.findDocumentOrShadowRoot(editor) const {activeElement} = root @@ -306,14 +294,14 @@ export const Editable = forwardRef( if (range) { if ( !editor.composing && - !androidInputManager?.hasPendingChanges() && - !androidInputManager?.isFlushing() + !inputManager?.hasPendingChanges() && + !inputManager?.isFlushing() ) { // Suppress browser selection normalization that would // overwrite a block object selection. editor.select(range) } else { - androidInputManager?.handleUserSelect(range) + inputManager?.handleUserSelect(range) } } } @@ -332,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, }) @@ -356,7 +345,7 @@ export const Editable = forwardRef( if ( !domSelection || !editor.focused || - androidInputManagerRef.current?.hasPendingAction() + inputManagerRef.current?.hasPendingAction() ) { return } @@ -461,7 +450,7 @@ export const Editable = forwardRef( } if (newDomRange) { - if (editor.composing && !IS_ANDROID) { + if (editor.composing && !inputManagerRef.current?.isFlushing()) { domSelection.collapseToEnd() } else if (isBackwardRange(selection!, editor.snapshot.context)) { domSelection.setBaseAndExtent( @@ -491,10 +480,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 }) @@ -591,370 +579,14 @@ export const Editable = forwardRef( DOMEditor.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.snapshot.context - 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/engine/issues/5038 - if (isCompositionChange && editor.composing) { - return - } - - let native = false - if ( - type === 'insertText' && - selection && - isCollapsedRange(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 - - // If the NODE_MAP is dirty, we can't trust the selection anchor (eg DOMEditor.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] = DOMEditor.toDOMPoint(editor, anchor) - const anchorNode = node.parentElement?.closest('a') - - const window = DOMEditor.getWindow(editor) - - if ( - native && - anchorNode && - DOMEditor.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 = getParent(editor.snapshot, anchor.path, { - match: (node) => - isTextBlock({schema: editor.snapshot.context.schema}, node), - }) - - if (block) { - const blockText = getText(editor.snapshot, block.path) - - if (blockText?.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 DOMEditor.toDOMPoint via DOMEditor.toEditorSelection) - if ( - (!type.startsWith('delete') || type.startsWith('deleteBy')) && - !editor.isNodeMapDirty - ) { - const [targetRange] = (event as any).getTargetRanges() - - // The target ranges tell us where the input should be applied. - // Synthetic `beforeinput` events dispatched by browser extensions - // like Grammarly carry no target ranges; they set the DOM selection - // to the affected range instead. The selection-change flush above - // normally syncs the editor selection to that DOM selection, but - // inside an iframe it can't: accepting an extension suggestion - // moves focus off the editable, so the flush bails on its focus - // check and the editor selection stays stale. Fall back to the DOM - // selection so the input isn't applied at the wrong place. - const domSelection = getSelection( - DOMEditor.findDocumentOrShadowRoot(editor), - ) - const inputTarget = - targetRange ?? - (domSelection && domSelection.rangeCount > 0 - ? domSelection - : undefined) - - if (inputTarget) { - const range = DOMEditor.toEditorSelection(editor, inputTarget, { - exactMatch: false, - // The DOM selection fallback is not guaranteed to be a valid - // editor range, so don't throw on it. - suppressThrow: !targetRange, - }) - - if (range && (!selection || !rangeEquals(selection, range))) { - native = false - - const selectionRef = - !isCompositionChange && - editor.snapshot.context.selection && - rangeRef(editor, editor.snapshot.context.selection) - - editor.select(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 && - isExpandedRange(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 (editor.composing) { - 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.snapshot.context.selection || - !rangeEquals(editor.snapshot.context.selection, toRestore)) - ) { - editor.select(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, editorActor, onUserInput, propsOnDOMBeforeInput, readOnly], ) const callbackRef = useCallback( @@ -1112,37 +744,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 ( - !editor.focused && - isDOMNode(event.target) && - DOMEditor.hasEditableTarget(editor, event.target) - ) { - handleNativeHistoryEvents( - editor, - editorActor, - event.nativeEvent as InputEvent, - ) - } + inputManagerRef.current?.handleInput() }, - [attributes.onInput, editor, editorActor], + [attributes.onInput], )} onBlur={useCallback( (event: React.FocusEvent) => { @@ -1338,39 +942,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 - ) { - editorActor.send({ - type: 'behavior event', - behaviorEvent: { - type: 'insert.text', - text: event.data, - }, - editor, - }) - } } }, - [attributes.onCompositionEnd, editor, editorActor], + [attributes.onCompositionEnd, editor], )} onCompositionUpdate={useCallback( (event: React.CompositionEvent) => { @@ -1392,29 +973,16 @@ export const Editable = forwardRef( return } if (DOMEditor.hasSelectableTarget(editor, event.target)) { - androidInputManagerRef.current?.handleCompositionStart( - event, + inputManagerRef.current?.handleCompositionStart( + event.nativeEvent, ) - if ( - isEventHandled(event, attributes.onCompositionStart) || - IS_ANDROID - ) { - return - } - - const {selection} = editor.snapshot.context - if (selection && isExpandedRange(selection)) { - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction: 'forward'}, - editor, - }) + if (isEventHandled(event, attributes.onCompositionStart)) { return } } }, - [attributes.onCompositionStart, editor, editorActor], + [attributes.onCompositionStart, editor], )} onCopy={attributes.onCopy} onCut={attributes.onCut} @@ -1458,7 +1026,7 @@ export const Editable = forwardRef( !readOnly && DOMEditor.hasEditableTarget(editor, event.target) ) { - androidInputManagerRef.current?.handleKeyDown(event) + inputManagerRef.current?.handleKeyDown(event.nativeEvent) const {nativeEvent} = event diff --git a/packages/editor/src/engine/react/components/restore-dom/restore-dom.tsx b/packages/editor/src/engine/react/components/restore-dom/restore-dom.tsx index 9b14f960f9..3a563aede3 100644 --- a/packages/editor/src/engine/react/components/restore-dom/restore-dom.tsx +++ b/packages/editor/src/engine/react/components/restore-dom/restore-dom.tsx @@ -5,7 +5,6 @@ import { type ReactNode, type RefObject, } from 'react' -import {IS_ANDROID} from '../../../dom/utils/environment' import {EditorContext} from '../../hooks/use-engine-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/engine/react/hooks/android-input-manager/android-input-manager.ts b/packages/editor/src/engine/react/hooks/android-input-manager/android-input-manager.ts deleted file mode 100644 index f2e5d8f7c6..0000000000 --- a/packages/editor/src/engine/react/hooks/android-input-manager/android-input-manager.ts +++ /dev/null @@ -1,946 +0,0 @@ -import {isSpan} from '@portabletext/schema' -import type {EditorActor} from '../../../../editor/editor-machine' -import {getNode} from '../../../../traversal/get-node' -import {getNodes} from '../../../../traversal/get-nodes' -import {getSpan} from '../../../../traversal/get-span' -import {isLeafObject} from '../../../../traversal/is-leaf-object' -import {DOMEditor} from '../../../dom/plugin/dom-editor' -import { - applyStringDiff, - mergeStringDiffs, - normalizePoint, - normalizeRange, - normalizeStringDiff, - targetRange, - verifyDiffState, - type StringDiff, - type TextDiff, -} from '../../../dom/utils/diff-text' -import {isDOMSelection, isTrackedMutation} from '../../../dom/utils/dom' -import {after} from '../../../editor/after' -import {range as editorRange} from '../../../editor/range' -import {rangeRef} from '../../../editor/range-ref' -import type {Editor} from '../../../interfaces/editor' -import type {Path} from '../../../interfaces/path' -import type {Point} from '../../../interfaces/point' -import type {Range} from '../../../interfaces/range' -import {pathEquals} from '../../../path/path-equals' -import {isPoint} from '../../../point/is-point' -import {isCollapsedRange} from '../../../range/is-collapsed-range' -import {isExpandedRange} from '../../../range/is-expanded-range' -import {rangeEdges} from '../../../range/range-edges' -import {rangeEquals} from '../../../range/range-equals' -import type {DebouncedFunc} from '../../utils/debounce' - -// 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.snapshot.context - const normalized = normalizeRange(editor, pendingSelection) - - debug('apply pending selection', pendingSelection, normalized) - - if (normalized && (!selection || !rangeEquals(normalized, selection))) { - editor.select(normalized) - } - } - } - - const performAction = () => { - const action = editor.pendingAction - editor.pendingAction = null - if (!action) { - return - } - - if (action.at) { - const target = isPoint(action.at) - ? normalizePoint(editor, action.at) - : normalizeRange(editor, action.at) - - if (!target) { - return - } - - const targetRange = editorRange(editor, target) - if ( - !editor.snapshot.context.selection || - !rangeEquals(editor.snapshot.context.selection, targetRange) - ) { - editor.select(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 - setTimeout(() => (flushing = false)) - } - - if (hasPendingAction()) { - flushing = 'action' - } - - const selectionRef = - editor.snapshot.context.selection && - rangeRef(editor, editor.snapshot.context.selection, {affinity: 'forward'}) - - debug('flush', editor.pendingAction, editor.pendingDiffs) - - let scheduleSelectionChange = hasPendingDiffs() - - let diff: TextDiff | undefined - // biome-ignore lint/suspicious/noAssignInExpressions: engine upstream pattern - while ((diff = editor.pendingDiffs?.[0])) { - const range = targetRange(diff) - if ( - !editor.snapshot.context.selection || - !rangeEquals(editor.snapshot.context.selection, range) - ) { - editor.select(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: engine 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 - 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.snapshot.context.selection || - !rangeEquals(selection, editor.snapshot.context.selection)) - ) { - editor.select(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 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 storeDiff = (path: Path, diff: StringDiff) => { - debug('storeDiff', path, diff) - - const pendingDiffs = editor.pendingDiffs - - const targetEntry = getSpan(editor.snapshot, path) - - if (!targetEntry) { - return - } - - const target = targetEntry.node - - const idx = pendingDiffs.findIndex((change) => - pathEquals(change.path, path), - ) - if (idx < 0) { - const normalized = normalizeStringDiff(target.text, diff) - if (normalized) { - pendingDiffs.push({path, diff, id: idCounter++}) - } - - return - } - - const merged = mergeStringDiffs(target.text, pendingDiffs[idx]!.diff, diff) - if (!merged) { - pendingDiffs.splice(idx, 1) - 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 = DOMEditor.toEditorSelection(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 = DOMEditor.getWindow(editor) - const domSelection = window.getSelection() - if (!targetRange && domSelection) { - nativeTargetRange = domSelection - targetRange = DOMEditor.toEditorSelection(editor, domSelection, { - exactMatch: false, - suppressThrow: true, - }) - } - - targetRange = targetRange ?? editor.snapshot.context.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 the engine normalize the editor state. - let canStoreDiff = true - - if (type.startsWith('delete')) { - const direction = type.endsWith('Backward') ? 'backward' : 'forward' - let [start, end] = rangeEdges(targetRange, editor.snapshot.context) - const leafNodeEntry = getNode(editor.snapshot, start.path) - const leafResult = - leafNodeEntry && - (isSpan({schema: editor.snapshot.context.schema}, leafNodeEntry.node) || - isLeafObject(editor.snapshot, leafNodeEntry.node, start.path)) - ? leafNodeEntry - : undefined - - if (!leafResult) { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction}, - editor, - }), - {at: targetRange}, - ) - } - - let leaf = leafResult.node - let path = leafResult.path - - if (!isSpan({schema: editor.snapshot.context.schema}, leaf)) { - return scheduleAction( - () => - editorActor.send({ - type: 'behavior event', - behaviorEvent: {type: 'delete', direction}, - editor, - }), - {at: targetRange}, - ) - } - - if (isExpandedRange(targetRange)) { - if (leaf.text.length === start.offset && end.offset === 0) { - const afterPoint = after(editor, start.path) - const [nextEntry] = afterPoint - ? getNodes(editor.snapshot, { - from: afterPoint.path, - match: (n) => - isSpan({schema: editor.snapshot.context.schema}, n), - }) - : [] - if ( - nextEntry && - isSpan({schema: editor.snapshot.context.schema}, nextEntry.node) && - pathEquals(nextEntry.path, 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 = nextEntry.node - path = nextEntry.path - } else { - targetRange = {anchor: start, focus: start} - end = start - } - } - } - } - - if (!isSpan({schema: editor.snapshot.context.schema}, leaf)) { - return - } - - const diff = { - text: '', - start: start.offset, - end: end.offset, - } - const pendingDiffs = editor.pendingDiffs - const relevantPendingDiffs = pendingDiffs?.find((change) => - pathEquals(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 the engine can normalize instead - // of storing as a diff - canStoreDiff = false - } - - if (isExpandedRange(targetRange)) { - if ( - canStoreDiff && - pathEquals(targetRange.anchor.path, targetRange.focus.path) - ) { - const point = {path: targetRange.anchor.path, offset: start.offset} - const range = editorRange(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 && isCollapsedRange(targetRange)) { - const targetNodeEntry = getSpan(editor.snapshot, anchor.path) - - if ( - targetNodeEntry && - anchor.offset < targetNodeEntry.node.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 engine 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 && - isCollapsedRange(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 ?? '' - - // 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 (pathEquals(targetRange.anchor.path, targetRange.focus.path)) { - const [start, end] = rangeEdges(targetRange, editor.snapshot.context) - - 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 && - isCollapsedRange(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.snapshot.context.selection - storeDiff(start.path, diff) - - if (currentSelection) { - const newPoint = { - path: start.path, - offset: start.offset + text.length, - } - - scheduleAction( - () => { - editor.select({ - 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.snapshot.context - if (!range) { - return - } - - const pathChanged = - !selection || !pathEquals(selection.anchor.path, range.anchor.path) - const parentPathChanged = - !selection || - !pathEquals( - 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) => {} - - 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/engine/react/hooks/android-input-manager/use-android-input-manager.ts b/packages/editor/src/engine/react/hooks/android-input-manager/use-android-input-manager.ts deleted file mode 100644 index c6f04ced0f..0000000000 --- a/packages/editor/src/engine/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 '../../../dom/utils/environment' -import {useEngineStatic} from '../use-engine-static' -import {useIsMounted} from '../use-is-mounted' -import {useMutationObserver} from '../use-mutation-observer' -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: engine's platform-conditional hook pattern (Android-only) - const editor = useEngineStatic() - // biome-ignore lint/correctness/useHookAtTopLevel: engine's platform-conditional hook pattern (Android-only) - const isMounted = useIsMounted() - - // biome-ignore lint/correctness/useHookAtTopLevel: engine's platform-conditional hook pattern (Android-only) - const [inputManager] = useState(() => - createAndroidInputManager({ - editor, - editorActor, - ...options, - }), - ) - - // biome-ignore lint/correctness/useHookAtTopLevel: engine'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/engine/react/hooks/use-mutation-observer.ts b/packages/editor/src/engine/react/hooks/use-mutation-observer.ts index 34ebadf43a..72d16e33a8 100644 --- a/packages/editor/src/engine/react/hooks/use-mutation-observer.ts +++ b/packages/editor/src/engine/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/decorator-toggle-no-ghost.test.tsx b/packages/editor/tests/decorator-toggle-no-ghost.test.tsx new file mode 100644 index 0000000000..b87b3f616d --- /dev/null +++ b/packages/editor/tests/decorator-toggle-no-ghost.test.tsx @@ -0,0 +1,32 @@ +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import {defineSchema} from '../src' +import {IS_MAC} from '../src/internal-utils/is-hotkey' +import {createTestEditor} from '../src/test/vitest' +import {toTextspec} from '../test-utils/to-textspec' + +describe('typing after toggling a decorator at a collapsed cursor', () => { + test('Scenario: the rendered DOM matches the model (no ghost character)', async () => { + const {editor, locator} = await createTestEditor({ + keyGenerator: createTestKeyGenerator(), + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + }) + + await userEvent.click(locator) + await userEvent.type(locator, 'foo') + + await userEvent.keyboard( + IS_MAC ? '{Meta>}b{/Meta}' : '{Control>}b{/Control}', + ) + await userEvent.type(locator, 'bar') + + await vi.waitFor(() => { + expect(toTextspec(editor.getSnapshot().context)).toEqual( + 'B: foo[strong:bar|]', + ) + }) + + expect(locator.element().textContent).toEqual('foobar') + }) +})