From 736775b223e1e4a0877c8b727029f1515f4227de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Wed, 21 Oct 2026 11:51:18 +0000 Subject: [PATCH] feat: emit `change` events for local and remote changes, with corrections marked Hosts had two ways to learn what the editor did to its content: the per-operation `operation` feed, which has no notion of a user action and no patches, and `mutation`, which carries patches but only on the batcher's debounced flush and folds the editor's own normalization fixes into the user's edit. Neither says "this action did these operations, as these patches" at the moment the action finishes. A change collector on the engine now groups applied operations into `change` events. It records an entry for every operation when its apply starts, since the `after` listeners of normalization fixes run before the one of the operation that triggered them, and fills the entry with the public operation and the local patches patch generation hands it. Entries split by their apply-context frames: a `normalization` frame while patching is a correction, a `remote` frame is remote, everything else is an edit. The collector flushes when the outermost unit of work returns: the depth-0 `behavior event` the editor machine performs, a whole value sync (the sync machine's `syncValueCallback`), the outermost `withRemoteChanges` bracket, or, for an apply outside all of them, the outermost apply. A flush emits the unit's remote change first, then its local entries in application order, each run of consecutive edits or consecutive corrections as its own change. A Behavior event whose action groups normalize in between therefore emits edit, correction, edit, and replaying the patches in that order gives the editor's value. A remote change goes out only when `isEqualValues` finds the content after the unit differs from the content before its first remote operation, so a remote `set` of a value the editor already holds emits nothing. Entries are detached before any event is emitted, so a listener that applies again starts a new change instead of adding to one already delivered. Unit depth is tracked with try/finally, apart from the apply depth the operation listeners count. An apply that throws leaves the apply depth raised; the outermost unit resets it on exit, and so does the next apply that can only be outermost (no open unit and no `normalization` frame, since only normalization re-enters `apply`). Remote operations under the `initial-sync` source are dropped, so the first load emits nothing, and the empty-editor placeholder insert runs under a new `placeholder` frame and is never reported. `isPublicOperation` moves from `create-editor.ts` to `types/operation.ts` so the collector shares the `operation` event's allowlist. `mutation`, `patch` and the batcher are unchanged. --- .changeset/emit-change-events.md | 22 + .../src/editor/change-collector.test.ts | 141 +++ .../editor/src/editor/change-collector.ts | 238 ++++ .../src/editor/create-editor-engine.tsx | 13 + packages/editor/src/editor/create-editor.ts | 30 +- packages/editor/src/editor/editor-machine.ts | 53 +- packages/editor/src/editor/relay.ts | 44 + .../src/editor/subscriber.patch-generation.ts | 13 +- packages/editor/src/editor/sync-machine.ts | 17 +- .../engine-plugin.change-unit.ts | 13 + .../engine-plugin.remote-changes.ts | 15 +- .../editor/src/engine/core/apply-context.ts | 10 +- .../editor/src/engine/core/normalize-node.ts | 19 +- packages/editor/src/index.ts | 6 +- packages/editor/src/types/editor-engine.ts | 14 + packages/editor/src/types/operation.ts | 27 + packages/editor/tests/event.change.test.tsx | 1054 +++++++++++++++++ .../editor/tests/event.update-value.test.tsx | 70 ++ packages/editor/tests/validation.test.tsx | 176 +++ 19 files changed, 1901 insertions(+), 74 deletions(-) create mode 100644 .changeset/emit-change-events.md create mode 100644 packages/editor/src/editor/change-collector.test.ts create mode 100644 packages/editor/src/editor/change-collector.ts create mode 100644 packages/editor/src/engine-plugins/engine-plugin.change-unit.ts create mode 100644 packages/editor/tests/event.change.test.tsx diff --git a/.changeset/emit-change-events.md b/.changeset/emit-change-events.md new file mode 100644 index 0000000000..f28794045b --- /dev/null +++ b/.changeset/emit-change-events.md @@ -0,0 +1,22 @@ +--- +'@portabletext/editor': minor +--- + +feat: emit `change` events for local and remote changes, with corrections marked + +The editor now emits a `change` event, synchronously, every time its content changes. Each event carries the `operations` the editor applied to its content (the public `Operation` type), never the input it received: + +```ts +editor.on('change', (event) => { + if (event.origin === 'local') { + // `event.patches` is the change as Content Lake patches. + save(event.patches) + } +}) +``` + +- `origin: 'local'`: what a user action did, including everything the action's Behaviors raised. Undo and redo are one action each. Local changes arrive in application order, and one user action can produce several when the editor fixes something mid-action. `patches` holds the change as Content Lake patches, the same patches its `mutation` carries, available as soon as the action finishes instead of on the `mutation` event's debounced schedule. +- `origin: 'local'` with `correction: true`: the editor's own fixes to the content, such as merging adjacent spans that share marks after a paste, or replacing a duplicate `_key`. A correction follows the change that caused it, with its own operations and patches. Fixes to content the editor received through `patches` or `update value` are corrections too. A host that saves every local change's `patches` saves corrections as well. +- `origin: 'remote'`: one event per `patches` message and per `update value` that changed the content. Remote changes carry no `patches`. + +Loading the initial value emits no `change`, except a `correction` when the editor fixes the loaded value, and a `change` never has an empty `operations` array. `ChangeEvent` is exported as a beta type. `EditorEmittedEvent` gains the `change` member, so exhaustive switches over `event.type` gain a case. diff --git a/packages/editor/src/editor/change-collector.test.ts b/packages/editor/src/editor/change-collector.test.ts new file mode 100644 index 0000000000..d6269b487c --- /dev/null +++ b/packages/editor/src/editor/change-collector.test.ts @@ -0,0 +1,141 @@ +import { + compileSchema, + defineSchema, + type PortableTextBlock, +} from '@portabletext/schema' +import {describe, expect, test} from 'vitest' +import {subscribeToOperations} from '../engine/core/operation-channel' +import {createEditor} from '../engine/create-editor' +import type {PortableTextEditorEngine} from '../types/editor-engine' +import {createChangeCollector} from './change-collector' +import type {ChangeEvent} from './relay' + +describe(createChangeCollector.name, () => { + test('an apply whose `before` listener throws does not stop later changes', () => { + const editor = createBareEngine([block('b1', 's1', 'foo')]) + const changes: Array = [] + const collector = createChangeCollector({ + editor, + emit: (event) => { + changes.push(event) + }, + }) + collector.subscribe() + let throwOnce = true + subscribeToOperations( + editor, + () => { + if (throwOnce) { + throwOnce = false + throw new Error('boom') + } + }, + {phase: 'before'}, + ) + const spanPath = [{_key: 'b1'}, 'children', {_key: 's1'}] + + expect(() => + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}), + ).toThrow('boom') + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '?'}) + + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: '?'}, + ], + patches: [], + }, + ]) + }) + + test('an apply made while a change is emitted goes into a new change', () => { + const editor = createBareEngine([block('b1', 's1', 'foo')]) + const changes: Array = [] + const spanPath = [{_key: 'b1'}, 'children', {_key: 's1'}] + let reentered = false + const collector = createChangeCollector({ + editor, + emit: (event) => { + changes.push(event) + if (!reentered) { + reentered = true + editor.apply({ + type: 'insert.text', + path: spanPath, + offset: 4, + text: '?', + }) + } + }, + }) + collector.subscribe() + + collector.withUnit(() => { + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}) + }) + + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + ], + patches: [], + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 4, text: '?'}, + ], + patches: [], + }, + ]) + }) +}) + +function createBareEngine( + value: Array, +): PortableTextEditorEngine { + const editor = createEditor() as PortableTextEditorEngine + + editor.containers = new Map() + editor.blockIndexMap = new Map() + editor.verifiedUniqueChildGroups = new Set() + editor.isPatching = true + // The bare engine lacks the fields that `withDOM` and `createEditorEngine` + // assign; `apply` reads only these snapshot fields. + editor.snapshot = { + blockIndexMap: editor.blockIndexMap, + context: { + containers: new Map(), + converters: [], + keyGenerator: () => 'generated-key', + readOnly: false, + schema: compileSchema(defineSchema({})), + selection: null, + value, + }, + decoratorState: {}, + } as unknown as PortableTextEditorEngine['snapshot'] + + return editor +} + +function block( + blockKey: string, + spanKey: string, + text: string, +): PortableTextBlock { + return { + _type: 'block', + _key: blockKey, + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: spanKey, text, marks: []}], + } +} diff --git a/packages/editor/src/editor/change-collector.ts b/packages/editor/src/editor/change-collector.ts new file mode 100644 index 0000000000..a2a09750c5 --- /dev/null +++ b/packages/editor/src/editor/change-collector.ts @@ -0,0 +1,238 @@ +import type {Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import { + hasRemoteFrame, + isInNormalization, + type ApplyContextFrame, +} from '../engine/core/apply-context' +import { + subscribeToOperations, + type OperationEvent, +} from '../engine/core/operation-channel' +import {isEqualValues} from '../internal-utils/equality' +import type {PortableTextEditorEngine} from '../types/editor-engine' +import {isPublicOperation, type Operation} from '../types/operation' +import type {ChangeEvent} from './relay' + +/** + * One applied operation, recorded when its apply starts: the `after` + * listeners of normalization fixes run before the `after` listener of the + * operation that triggered them, so only apply start gives application + * order. + */ +type Entry = { + kind: 'edit' | 'correction' | 'remote' + operation: Operation | undefined + patches: Array +} + +/** + * Collects the operations the engine applies into `change` events and + * emits them when the outermost unit of work finishes: a Behavior event + * sent at depth 0, a value sync, a remote application bracket, or, for an + * apply outside all of them, the apply itself. + * + * Operations split three ways by their apply context: remote operations, + * normalization fixes made while patching (corrections) and everything + * else (edits). A unit's remote change goes out first; its local changes + * follow in application order, each run of consecutive edits or + * consecutive corrections as its own change. + */ +export function createChangeCollector({ + editor, + emit, +}: { + editor: PortableTextEditorEngine + emit: (event: ChangeEvent) => void +}) { + let unitDepth = 0 + // Counted by the `before`/`after` operation listeners, so an apply that + // throws leaves it raised. Only normalization re-enters `apply` from + // inside another apply, so an apply with no `normalization` frame and no + // open unit is outermost and resets it. + let applyDepth = 0 + let entries: Array = [] + let entryByEvent = new Map() + // The content before the first remote operation since the last flush, so + // a remote unit that leaves the content as it was emits nothing. + let valueBeforeRemote: Array | undefined + + function flush() { + // Detached before emitting: a listener that applies again starts a new + // change instead of adding to one already delivered. + const unitEntries = entries + const remoteChanged = + valueBeforeRemote !== undefined && + !isEqualValues( + editor.snapshot.context, + valueBeforeRemote, + editor.snapshot.context.value, + ) + entries = [] + entryByEvent = new Map() + valueBeforeRemote = undefined + + const remoteOperations = unitEntries.flatMap((entry) => + entry.kind === 'remote' && entry.operation ? [entry.operation] : [], + ) + + if (remoteOperations.length > 0 && remoteChanged) { + emit({type: 'change', origin: 'remote', operations: remoteOperations}) + } + + for (const run of localRuns(unitEntries)) { + emit( + run.kind === 'correction' + ? { + type: 'change', + origin: 'local', + operations: run.operations, + patches: run.patches, + correction: true, + } + : { + type: 'change', + origin: 'local', + operations: run.operations, + patches: run.patches, + }, + ) + } + } + + return { + /** + * Runs `fn` as one unit of work: everything applied inside it, at any + * nesting, is emitted when the outermost unit returns. + */ + withUnit(fn: () => void) { + unitDepth++ + try { + fn() + } finally { + unitDepth-- + if (unitDepth === 0) { + applyDepth = 0 + flush() + } + } + }, + addPatches(event: OperationEvent, patches: Array) { + const entry = entryByEvent.get(event) + + if (entry === undefined || entry.kind === 'remote') { + return + } + + entry.patches.push(...patches) + }, + subscribe() { + const unsubscribeBefore = subscribeToOperations( + editor, + (event) => { + if (unitDepth === 0 && !isInNormalization(event.context)) { + applyDepth = 0 + } + applyDepth++ + + if ( + valueBeforeRemote === undefined && + hasRemoteFrame(event.context) && + !isInNormalization(event.context) + ) { + valueBeforeRemote = event.beforeValue + } + + const entry: Entry = { + kind: entryKind(event), + operation: undefined, + patches: [], + } + entries.push(entry) + entryByEvent.set(event, entry) + }, + {phase: 'before'}, + ) + const unsubscribeAfter = subscribeToOperations(editor, (event) => { + applyDepth-- + + const entry = entryByEvent.get(event) + + if ( + entry !== undefined && + isPublicOperation(event.operation) && + !event.context.some((frame) => frame.kind === 'placeholder') && + !(entry.kind === 'remote' && isInitialSync(event.context)) + ) { + entry.operation = event.operation + } + + if (unitDepth === 0 && applyDepth === 0) { + flush() + } + }) + + return () => { + unsubscribeBefore() + unsubscribeAfter() + } + }, + } +} + +/** + * Groups a unit's local entries into runs of consecutive edits or + * consecutive corrections, skipping entries that applied no public + * operation (selection changes, the empty-editor placeholder). + */ +function localRuns(entries: Array): Array<{ + kind: 'edit' | 'correction' + operations: Array + patches: Array +}> { + const runs: Array<{ + kind: 'edit' | 'correction' + operations: Array + patches: Array + }> = [] + + for (const entry of entries) { + if (entry.kind === 'remote' || entry.operation === undefined) { + continue + } + + const lastRun = runs.at(-1) + + if (lastRun !== undefined && lastRun.kind === entry.kind) { + lastRun.operations.push(entry.operation) + lastRun.patches.push(...entry.patches) + continue + } + + runs.push({ + kind: entry.kind, + operations: [entry.operation], + patches: [...entry.patches], + }) + } + + return runs +} + +function entryKind(event: OperationEvent): Entry['kind'] { + if (isInNormalization(event.context) && event.isPatching) { + return 'correction' + } + + if (hasRemoteFrame(event.context)) { + return 'remote' + } + + return 'edit' +} + +function isInitialSync(context: ReadonlyArray): boolean { + return context.some( + (frame) => frame.kind === 'remote' && frame.source === 'initial-sync', + ) +} diff --git a/packages/editor/src/editor/create-editor-engine.tsx b/packages/editor/src/editor/create-editor-engine.tsx index f9e766af2f..637b5750bb 100644 --- a/packages/editor/src/editor/create-editor-engine.tsx +++ b/packages/editor/src/editor/create-editor-engine.tsx @@ -6,6 +6,7 @@ import {buildIndexMaps} from '../internal-utils/build-index-maps' import {createPlaceholderBlock} from '../internal-utils/create-placeholder-block' import {debug} from '../internal-utils/debug' import type {PortableTextEditorEngine} from '../types/editor-engine' +import {createChangeCollector} from './change-collector' import type {EditorActor} from './editor-machine' import {setupRemotePatches} from './remote-patches' import {subscribeHistory} from './subscriber.history' @@ -85,6 +86,7 @@ export function createEditorEngine( editor.isPatching = true editor.isPerformingBehaviorOperation = false editor.withHistory = true + editor.onChangeEvent = () => {} const editorEngine = plugins(withDOM(editor), { editorActor: config.editorActor, @@ -92,10 +94,21 @@ export function createEditorEngine( subscribeUpdateValue(context, editorEngine) subscribeRepairJournal(editorEngine) + const changeCollector = createChangeCollector({ + editor: editorEngine, + emit: (event) => { + editorEngine.onChangeEvent(event) + }, + }) + editorEngine.withChangeUnit = changeCollector.withUnit subscribePatchGeneration({ editorActor: config.editorActor, editor: editorEngine, + onPatches: changeCollector.addPatches, }) + // Subscribed after patch generation: an operation's patches must reach + // the collector before the outermost apply flushes it. + changeCollector.subscribe() setupRemotePatches({ editorActor: config.editorActor, subscriptions: config.subscriptions, diff --git a/packages/editor/src/editor/create-editor.ts b/packages/editor/src/editor/create-editor.ts index 42743ecea0..b057280e7f 100644 --- a/packages/editor/src/editor/create-editor.ts +++ b/packages/editor/src/editor/create-editor.ts @@ -4,13 +4,12 @@ import {coreConverters} from '../converters/converters.core' import type {Editor, EditorConfig} from '../editor' import {hasRemoteFrame} from '../engine/core/apply-context' import {subscribeToOperations} from '../engine/core/operation-channel' -import type {EngineOperation} from '../engine/interfaces/operation' import {debug} from '../internal-utils/debug' import {corePriority} from '../priority/priority.core' import {createEditorPriority} from '../priority/priority.types' import type {EditableAPI} from '../types/editor' import type {PortableTextEditorEngine} from '../types/editor-engine' -import type {Operation} from '../types/operation' +import {isPublicOperation} from '../types/operation' import {defaultKeyGenerator} from '../utils/key-generator' import {createEditableAPI} from './create-editable-api' import {createEditorEngine} from './create-editor-engine' @@ -174,6 +173,7 @@ export function createInternalEditor(config: EditorConfig): { return relay.on(type, (event) => { switch (event.type) { case 'blurred': + case 'change': case 'editable': case 'focused': case 'invalid value': @@ -229,28 +229,6 @@ function editorConfigToMachineInput(config: EditorConfig) { } as const } -/** - * The public operation types. The `Record` keying makes completeness - * compile-checked: adding a variant to the public `Operation` union in - * `types/operation.ts` (which carries the tripwire that fires when the - * engine vocabulary grows) errors here until the allowlist catches up. - */ -const publicOperationTypeRecord: Record = { - 'insert': true, - 'insert.text': true, - 'remove.text': true, - 'set': true, - 'unset': true, -} - -const publicOperationTypes: ReadonlySet = new Set( - Object.keys(publicOperationTypeRecord), -) - -function isPublicOperation(operation: EngineOperation): operation is Operation { - return publicOperationTypes.has(operation.type) -} - function createActors(config: { editorActor: EditorActor rangeDecorationsActor: RangeDecorationsActor @@ -277,6 +255,10 @@ function createActors(config: { }, }) + config.editorEngine.onChangeEvent = (event) => { + config.relay.send(event) + } + config.subscriptions.push(mutationBatcher.subscribe) config.subscriptions.push(() => { diff --git a/packages/editor/src/editor/editor-machine.ts b/packages/editor/src/editor/editor-machine.ts index b8e9cf9bed..77745ff94b 100644 --- a/packages/editor/src/editor/editor-machine.ts +++ b/packages/editor/src/editor/editor-machine.ts @@ -16,6 +16,7 @@ import type { ExternalBehaviorEvent, } from '../behaviors/behavior.types.event' import type {Converter} from '../converters/converter.types' +import {withChangeUnit} from '../engine-plugins/engine-plugin.change-unit' import {DOMEditor} from '../engine/dom/plugin/dom-editor' import {normalize} from '../engine/editor/normalize' import {debug} from '../internal-utils/debug' @@ -383,32 +384,34 @@ export const editorMachine = setup({ (config) => config.behavior, ) - performEvent({ - mode: 'send', - depth: 0, - behaviors, - remainingEventBehaviors: behaviors, - event: event.behaviorEvent, - editor: event.editor, - converters: event.editor.snapshot.context.converters, - keyGenerator: event.editor.snapshot.context.keyGenerator, - schema: event.editor.snapshot.context.schema, - readOnly: event.editor.snapshot.context.readOnly, - nativeEvent: event.nativeEvent, - sendBack: (eventSentBack) => { - if (eventSentBack.type === 'set drag ghost') { - self.send(eventSentBack) - return - } + withChangeUnit(event.editor, () => + performEvent({ + mode: 'send', + depth: 0, + behaviors, + remainingEventBehaviors: behaviors, + event: event.behaviorEvent, + editor: event.editor, + converters: event.editor.snapshot.context.converters, + keyGenerator: event.editor.snapshot.context.keyGenerator, + schema: event.editor.snapshot.context.schema, + readOnly: event.editor.snapshot.context.readOnly, + nativeEvent: event.nativeEvent, + sendBack: (eventSentBack) => { + if (eventSentBack.type === 'set drag ghost') { + self.send(eventSentBack) + return + } - self.send( - rerouteExternalBehaviorEvent({ - event: eventSentBack, - editorEngine: event.editor, - }), - ) - }, - }) + self.send( + rerouteExternalBehaviorEvent({ + event: eventSentBack, + editorEngine: event.editor, + }), + ) + }, + }), + ) } catch (error) { console.error( new Error( diff --git a/packages/editor/src/editor/relay.ts b/packages/editor/src/editor/relay.ts index f5fb2595c9..2ff3d0fc75 100644 --- a/packages/editor/src/editor/relay.ts +++ b/packages/editor/src/editor/relay.ts @@ -12,6 +12,7 @@ export type EditorEmittedEvent = type: 'blurred' event: FocusEvent } + | ChangeEvent | { type: 'editable' } @@ -75,6 +76,49 @@ export type EditorEmittedEvent = value: Array | undefined } +/** + * Emitted once for every change to the editor's content, synchronously, in + * the order the changes were applied: + * + * - `origin: 'local'`: what a user action did, the outermost Behavior + * event and everything it raised. Undo and redo are one action each. + * Local changes arrive in application order, and one user action can + * produce several when the editor fixes something mid-action. `patches` + * are the change as Content Lake patches, the same patches its + * `mutation` carries, available as soon as the action finishes instead + * of on the `mutation` event's debounced schedule. + * - `origin: 'local'` with `correction: true`: the editor's own fixes to + * the content (normalization, such as merging adjacent spans with the + * same marks or replacing a duplicate `_key`), emitted right after the + * change that caused them, with their own operations and patches. A fix + * to content the editor received (remote patches or `update value`) is + * a correction too. A host that persists patches saves corrections like + * any other local change. + * - `origin: 'remote'`: one event per applied `patches` message and per + * `update value` that changed the content. Remote changes carry no + * `patches`: there is nothing to save. + * + * `operations` are the {@link Operation}s the editor applied to its + * content, never the inputs it received. The first load of the initial + * value emits no `change`, except a `correction` when the editor fixes + * the loaded value. A `change` never has an empty `operations` array. + * + * @beta + */ +export type ChangeEvent = + | { + type: 'change' + origin: 'local' + operations: Array + patches: Array + correction?: true + } + | { + type: 'change' + origin: 'remote' + operations: Array + } + /** * @public * diff --git a/packages/editor/src/editor/subscriber.patch-generation.ts b/packages/editor/src/editor/subscriber.patch-generation.ts index f0cb69fd01..3a13a24c1f 100644 --- a/packages/editor/src/editor/subscriber.patch-generation.ts +++ b/packages/editor/src/editor/subscriber.patch-generation.ts @@ -1,5 +1,9 @@ +import type {Patch} from '@portabletext/patches' import {hasRemoteFrame, isInNormalization} from '../engine/core/apply-context' -import {subscribeToOperations} from '../engine/core/operation-channel' +import { + subscribeToOperations, + type OperationEvent, +} from '../engine/core/operation-channel' import {addFieldLifecyclePatches} from '../internal-utils/field-lifecycle-patches' import {operationToPatches} from '../internal-utils/operation-to-patches' import type {PortableTextEditorEngine} from '../types/editor-engine' @@ -12,9 +16,11 @@ import type {EditorActor} from './editor-machine' export function subscribePatchGeneration({ editorActor, editor, + onPatches, }: { editorActor: EditorActor editor: PortableTextEditorEngine + onPatches: (event: OperationEvent, patches: Array) => void }): () => void { return subscribeToOperations(editor, (event) => { if (!event.isPatching) { @@ -50,6 +56,11 @@ export function subscribePatchGeneration({ editor.lastSyncedValue = state.lastSyncedValue editor.valueUnsetEmitted = state.valueUnsetEmitted + onPatches( + event, + patches.map((patch) => ({...patch, origin: 'local'})), + ) + for (const patch of patches) { editorActor.send({ type: 'internal.patch', diff --git a/packages/editor/src/editor/sync-machine.ts b/packages/editor/src/editor/sync-machine.ts index f45389293c..93af80f8da 100644 --- a/packages/editor/src/editor/sync-machine.ts +++ b/packages/editor/src/editor/sync-machine.ts @@ -13,6 +13,7 @@ import { type AnyEventObject, type CallbackLogicFunction, } from 'xstate' +import {withChangeUnit} from '../engine-plugins/engine-plugin.change-unit' import {withRemoteChanges} from '../engine-plugins/engine-plugin.remote-changes' import {pluginWithoutHistory} from '../engine-plugins/engine-plugin.without-history' import {withoutPatching} from '../engine-plugins/engine-plugin.without-patching' @@ -80,12 +81,16 @@ const syncValueCallback: CallbackLogicFunction< value: Array | undefined } > = ({sendBack, input}) => { - updateValue({ - context: input.context, - sendBack, - editorEngine: input.editorEngine, - value: input.value, - streamBlocks: input.streamBlocks, + // One `change` for the whole value sync. `streamBlocks` syncs across + // ticks, but only for the first load, which emits no `change`. + withChangeUnit(input.editorEngine, () => { + updateValue({ + context: input.context, + sendBack, + editorEngine: input.editorEngine, + value: input.value, + streamBlocks: input.streamBlocks, + }) }) } diff --git a/packages/editor/src/engine-plugins/engine-plugin.change-unit.ts b/packages/editor/src/engine-plugins/engine-plugin.change-unit.ts new file mode 100644 index 0000000000..f58491dccd --- /dev/null +++ b/packages/editor/src/engine-plugins/engine-plugin.change-unit.ts @@ -0,0 +1,13 @@ +import type {PortableTextEditorEngine} from '../types/editor-engine' + +export function withChangeUnit( + editor: PortableTextEditorEngine, + fn: () => void, +): void { + if (editor.withChangeUnit === undefined) { + fn() + return + } + + editor.withChangeUnit(fn) +} diff --git a/packages/editor/src/engine-plugins/engine-plugin.remote-changes.ts b/packages/editor/src/engine-plugins/engine-plugin.remote-changes.ts index 30d824e804..4c5629b719 100644 --- a/packages/editor/src/engine-plugins/engine-plugin.remote-changes.ts +++ b/packages/editor/src/engine-plugins/engine-plugin.remote-changes.ts @@ -1,15 +1,18 @@ import type {ApplyContextFrame} from '../engine/core/apply-context' import type {PortableTextEditorEngine} from '../types/editor-engine' +import {withChangeUnit} from './engine-plugin.change-unit' export function withRemoteChanges( editor: PortableTextEditorEngine, source: Extract['source'], fn: () => void, ): void { - editor.applyContext.push(Object.freeze({kind: 'remote', source})) - try { - fn() - } finally { - editor.applyContext.pop() - } + withChangeUnit(editor, () => { + editor.applyContext.push(Object.freeze({kind: 'remote', source})) + try { + fn() + } finally { + editor.applyContext.pop() + } + }) } diff --git a/packages/editor/src/engine/core/apply-context.ts b/packages/editor/src/engine/core/apply-context.ts index a60a08d940..2670ea6c06 100644 --- a/packages/editor/src/engine/core/apply-context.ts +++ b/packages/editor/src/engine/core/apply-context.ts @@ -2,16 +2,18 @@ import type {OperationOrigin} from './operation-channel' /** * A frame pushed onto `editor.applyContext` for the duration of an - * attribution bracket (remote changes, undo, redo, normalization). Brackets - * nest, so a normalization that fires while replaying a remote patch pushes - * a `normalization` frame on top of the still-active `remote` one; both - * stay visible to `getOrigin` below instead of only the innermost. + * attribution bracket (remote changes, undo, redo, normalization, the + * empty-editor placeholder insert). Brackets nest, so a normalization that + * fires while replaying a remote patch pushes a `normalization` frame on + * top of the still-active `remote` one; both stay visible to `getOrigin` + * below instead of only the innermost. */ export type ApplyContextFrame = | {kind: 'remote'; source: 'patches' | 'update-value' | 'initial-sync'} | {kind: 'undo'} | {kind: 'redo'} | {kind: 'normalization'} + | {kind: 'placeholder'} /** * Reduces the frame stack to an `OperationOrigin` by fixed precedence diff --git a/packages/editor/src/engine/core/normalize-node.ts b/packages/editor/src/engine/core/normalize-node.ts index c003141c10..ed24e64588 100644 --- a/packages/editor/src/engine/core/normalize-node.ts +++ b/packages/editor/src/engine/core/normalize-node.ts @@ -43,13 +43,18 @@ export const normalizeNode: WithEditorFirstArg = ( * Add a placeholder block when the editor is empty */ if (isEditor(node) && node.snapshot.context.value.length === 0) { - withoutPatching(editor, () => { - applyInsertNodeAtPath( - editor, - createPlaceholderBlock(editor.snapshot), - [0], - ) - }) + editor.applyContext.push(Object.freeze({kind: 'placeholder'})) + try { + withoutPatching(editor, () => { + applyInsertNodeAtPath( + editor, + createPlaceholderBlock(editor.snapshot), + [0], + ) + }) + } finally { + editor.applyContext.pop() + } return } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index ffd32918b3..194e4b94f2 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -42,7 +42,11 @@ export {usePortableTextEditor} from './editor/usePortableTextEditor' export {usePortableTextEditorSelection} from './editor/usePortableTextEditorSelection' export {defaultKeyGenerator as keyGenerator} from './utils/key-generator' export {PortableTextEditor} from './editor/PortableTextEditor' -export type {EditorEmittedEvent, MutationEvent} from './editor/relay' +export type { + ChangeEvent, + EditorEmittedEvent, + MutationEvent, +} from './editor/relay' export {useEditor} from './editor/use-editor' export { defineAnnotation, diff --git a/packages/editor/src/types/editor-engine.ts b/packages/editor/src/types/editor-engine.ts index 986c6ea2c5..a3aa072cb5 100644 --- a/packages/editor/src/types/editor-engine.ts +++ b/packages/editor/src/types/editor-engine.ts @@ -2,6 +2,7 @@ import type {Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@portabletext/schema' import type {EditorSnapshot} from '../editor/editor-snapshot' import type {DecoratedRange} from '../editor/range-decorations-machine' +import type {ChangeEvent} from '../editor/relay' import type {ApplyContextFrame} from '../engine/core/apply-context' import type {DOMEditor} from '../engine/dom/plugin/dom-editor' import type {Node} from '../engine/interfaces/node' @@ -152,6 +153,19 @@ export interface PortableTextEditorEngine extends DOMEditor { isPerformingBehaviorOperation: boolean withHistory: boolean + /** + * Runs `fn` as one unit of work for `change` events: everything applied + * inside it, at any nesting, goes out as one change (plus its correction) + * when the outermost unit returns. Installed by `createEditorEngine`; + * absent on engines assembled without it. + */ + withChangeUnit?: (fn: () => void) => void + /** + * Receives each `change` event the engine collects. Wired to the relay + * at editor setup. + */ + onChangeEvent: (event: ChangeEvent) => void + /** * The current {@link EditorSnapshot}. Reassigned (new object reference) * whenever the editor's observable state changes, and only then. Reads diff --git a/packages/editor/src/types/operation.ts b/packages/editor/src/types/operation.ts index 6e92637264..4bbf56b935 100644 --- a/packages/editor/src/types/operation.ts +++ b/packages/editor/src/types/operation.ts @@ -1,4 +1,5 @@ import type { + EngineOperation, InsertOperation, InsertTextOperation, RemoveTextOperation, @@ -44,3 +45,29 @@ export type Operation = | RemoveTextOperation | SetOperation | UnsetOperation + +/** + * The `Record` keying makes completeness compile-checked: adding a variant + * to {@link Operation} errors here until the allowlist catches up. + */ +const publicOperationTypeRecord: Record = { + 'insert': true, + 'insert.text': true, + 'remove.text': true, + 'set': true, + 'unset': true, +} + +const publicOperationTypes: ReadonlySet = new Set( + Object.keys(publicOperationTypeRecord), +) + +/** + * Narrows an engine operation to the public vocabulary, excluding + * `set.selection`. + */ +export function isPublicOperation( + operation: EngineOperation, +): operation is Operation { + return publicOperationTypes.has(operation.type) +} diff --git a/packages/editor/tests/event.change.test.tsx b/packages/editor/tests/event.change.test.tsx new file mode 100644 index 0000000000..a799474eb3 --- /dev/null +++ b/packages/editor/tests/event.change.test.tsx @@ -0,0 +1,1054 @@ +import { + applyAll, + diffMatchPatch, + insert, + set, + setIfMissing, + unset, +} from '@portabletext/patches' +import {defineSchema, type PortableTextBlock} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import type { + ChangeEvent, + Editor, + EditorEmittedEvent, + MutationEvent, +} from '../src' +import {execute} from '../src/behaviors/behavior.types.action' +import {defineBehavior} from '../src/behaviors/behavior.types.behavior' +import {safeStringify} from '../src/internal-utils/safe-json' +import {BehaviorPlugin} from '../src/plugins/plugin.behavior' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' +import {createTestEditor} from '../src/test/vitest' + +describe('event.change', () => { + test('Scenario: Typing emits one local `change` per keystroke with its operations and patches', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor, locator} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, '')], + }) + const changes = collectChanges(editor) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + + await userEvent.type(locator, 'foo') + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'foo'), + ]) + }) + + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 0, text: 'f'}, + ], + patches: [ + {...diffMatchPatch('', 'f', [...spanPath, 'text']), origin: 'local'}, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 1, text: 'o'}, + ], + patches: [ + { + ...diffMatchPatch('f', 'fo', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 2, text: 'o'}, + ], + patches: [ + { + ...diffMatchPatch('fo', 'foo', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + ]) + }) + + test('Scenario: A local `change` is emitted synchronously, before the mutation batcher flushes', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + editor.send({ + type: 'select', + at: { + anchor: {path: spanPath, offset: 3}, + focus: {path: spanPath, offset: 3}, + }, + }) + const changes = collectChanges(editor) + const mutations = collectMutations(editor) + + editor.send({type: 'insert.text', text: '!'}) + + expect(mutations).toEqual([]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foo!', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + ]) + }) + + test('Scenario: Pasting blocks emits one local `change`', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, '')], + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + editor.send({ + type: 'select', + at: { + anchor: {path: spanPath, offset: 0}, + focus: {path: spanPath, offset: 0}, + }, + }) + const barBlock = block(keyGenerator(), keyGenerator(), 'bar') + const bazBlock = block(keyGenerator(), keyGenerator(), 'baz') + const changes = collectChanges(editor) + + const dataTransfer = new DataTransfer() + dataTransfer.setData( + 'application/x-portable-text', + safeStringify([barBlock, bazBlock]), + ) + editor.send({ + type: 'clipboard.paste', + originEvent: {dataTransfer}, + position: {selection: editor.getSnapshot().context.selection!}, + }) + + expect(editor.getSnapshot().context.value).toEqual([barBlock, bazBlock]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'insert', + path: [{_key: blockKey}], + position: 'before', + node: barBlock, + }, + { + type: 'unset', + path: [{_key: blockKey}], + }, + { + type: 'insert', + path: [{_key: barBlock._key}], + position: 'after', + node: bazBlock, + }, + ], + patches: [ + { + ...insert([barBlock], 'before', [{_key: blockKey}]), + origin: 'local', + }, + {...unset([{_key: blockKey}]), origin: 'local'}, + { + ...insert([bazBlock], 'after', [{_key: barBlock._key}]), + origin: 'local', + }, + ], + }, + ]) + }) + + test('Scenario: Pasting text into a span emits the paste, then a `correction` merging the pasted span', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + editor.send({ + type: 'select', + at: { + anchor: {path: spanPath, offset: 3}, + focus: {path: spanPath, offset: 3}, + }, + }) + const changes = collectChanges(editor) + const pastedSpanPath = [{_key: blockKey}, 'children', {_key: 'k6'}] + const pastedSpan = {_type: 'span', _key: 'k6', text: 'bar', marks: []} + + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'bar') + editor.send({ + type: 'clipboard.paste', + originEvent: {dataTransfer}, + position: {selection: editor.getSnapshot().context.selection!}, + }) + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'foobar'), + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'set', path: [{_key: blockKey}, 'markDefs'], value: []}, + { + type: 'insert', + path: spanPath, + position: 'after', + node: pastedSpan, + }, + ], + patches: [ + {...set([], [{_key: blockKey}, 'markDefs']), origin: 'local'}, + { + ...setIfMissing([], [{_key: blockKey}, 'children']), + origin: 'local', + }, + {...insert([pastedSpan], 'after', spanPath), origin: 'local'}, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: 'bar'}, + {type: 'unset', path: pastedSpanPath}, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foobar', [...spanPath, 'text']), + origin: 'local', + }, + {...unset(pastedSpanPath), origin: 'local'}, + ], + correction: true, + }, + ]) + }) + + test('Scenario: Undo and redo each emit one local `change`', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor, locator} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + + await userEvent.type(locator, 'bar') + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'barfoo'), + ]) + }) + + const changes = collectChanges(editor) + + editor.send({type: 'history.undo'}) + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'foo'), + ]) + + editor.send({type: 'history.redo'}) + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'barfoo'), + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'remove.text', path: spanPath, offset: 2, text: 'r'}, + {type: 'remove.text', path: spanPath, offset: 1, text: 'a'}, + {type: 'remove.text', path: spanPath, offset: 0, text: 'b'}, + ], + patches: [ + { + ...diffMatchPatch('barfoo', 'bafoo', [...spanPath, 'text']), + origin: 'local', + }, + { + ...diffMatchPatch('bafoo', 'bfoo', [...spanPath, 'text']), + origin: 'local', + }, + { + ...diffMatchPatch('bfoo', 'foo', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 0, text: 'b'}, + {type: 'insert.text', path: spanPath, offset: 1, text: 'a'}, + {type: 'insert.text', path: spanPath, offset: 2, text: 'r'}, + ], + patches: [ + { + ...diffMatchPatch('foo', 'bfoo', [...spanPath, 'text']), + origin: 'local', + }, + { + ...diffMatchPatch('bfoo', 'bafoo', [...spanPath, 'text']), + origin: 'local', + }, + { + ...diffMatchPatch('bafoo', 'barfoo', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + ]) + }) + + test('Scenario: An edit whose normalization merges spans emits the edit, then a `correction` with the fix', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const fooSpanKey = keyGenerator() + const barSpanKey = keyGenerator() + const bazSpanKey = keyGenerator() + const initialBlock = { + _type: 'block', + _key: blockKey, + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: ['strong']}, + {_type: 'span', _key: barSpanKey, text: 'bar', marks: ['strong']}, + {_type: 'span', _key: bazSpanKey, text: 'baz', marks: []}, + ], + } + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [initialBlock], + }) + const fooSpanPath = [{_key: blockKey}, 'children', {_key: fooSpanKey}] + const barSpanPath = [{_key: blockKey}, 'children', {_key: barSpanKey}] + const bazSpanPath = [{_key: blockKey}, 'children', {_key: bazSpanKey}] + editor.send({ + type: 'select', + at: { + anchor: {path: bazSpanPath, offset: 3}, + focus: {path: bazSpanPath, offset: 3}, + }, + }) + const changes = collectChanges(editor) + + editor.send({type: 'insert.text', text: '!'}) + + expect(editor.getSnapshot().context.value).toEqual([ + { + ...initialBlock, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foobar', marks: ['strong']}, + {_type: 'span', _key: bazSpanKey, text: 'baz!', marks: []}, + ], + }, + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: bazSpanPath, offset: 3, text: '!'}, + ], + patches: [ + { + ...diffMatchPatch('baz', 'baz!', [...bazSpanPath, 'text']), + origin: 'local', + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: fooSpanPath, offset: 3, text: 'bar'}, + { + type: 'unset', + path: barSpanPath, + }, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foobar', [...fooSpanPath, 'text']), + origin: 'local', + }, + {...unset(barSpanPath), origin: 'local'}, + ], + correction: true, + }, + ]) + }) + + test('Scenario: A fix between two action groups of one Behavior event emits the edit, the correction and the next edit in application order', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const fooSpanKey = keyGenerator() + const barSpanKey = keyGenerator() + const bazSpanKey = keyGenerator() + const initialBlock = { + _type: 'block', + _key: blockKey, + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: ['strong']}, + {_type: 'span', _key: barSpanKey, text: 'bar', marks: ['strong']}, + {_type: 'span', _key: bazSpanKey, text: 'baz', marks: []}, + ], + } + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [initialBlock], + children: ( + [execute({type: 'insert.text', text: '!'})], + () => [execute({type: 'insert.text', text: '?'})], + ], + }), + ]} + /> + ), + }) + const fooSpanPath = [{_key: blockKey}, 'children', {_key: fooSpanKey}] + const barSpanPath = [{_key: blockKey}, 'children', {_key: barSpanKey}] + const bazSpanPath = [{_key: blockKey}, 'children', {_key: bazSpanKey}] + editor.send({ + type: 'select', + at: { + anchor: {path: bazSpanPath, offset: 3}, + focus: {path: bazSpanPath, offset: 3}, + }, + }) + const changes = collectChanges(editor) + + editor.send({type: 'custom.punctuate'}) + + expect(editor.getSnapshot().context.value).toEqual([ + { + ...initialBlock, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foobar', marks: ['strong']}, + {_type: 'span', _key: bazSpanKey, text: 'baz!?', marks: []}, + ], + }, + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: bazSpanPath, offset: 3, text: '!'}, + ], + patches: [ + { + ...diffMatchPatch('baz', 'baz!', [...bazSpanPath, 'text']), + origin: 'local', + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: fooSpanPath, offset: 3, text: 'bar'}, + {type: 'unset', path: barSpanPath}, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foobar', [...fooSpanPath, 'text']), + origin: 'local', + }, + {...unset(barSpanPath), origin: 'local'}, + ], + correction: true, + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: bazSpanPath, offset: 4, text: '?'}, + ], + patches: [ + { + ...diffMatchPatch('baz!', 'baz!?', [...bazSpanPath, 'text']), + origin: 'local', + }, + ], + }, + ]) + expect( + applyAll( + [initialBlock], + changes.flatMap((change) => + change.origin === 'local' ? change.patches : [], + ), + ), + ).toEqual(editor.getSnapshot().context.value) + }) + + test('Scenario: Remote patches emit a remote `change` with the applied operations and no patches', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const changes = collectChanges(editor) + const textPath = [{_key: blockKey}, 'children', {_key: spanKey}, 'text'] + + editor.send({ + type: 'patches', + patches: [{...set('bar', textPath), origin: 'remote'}], + snapshot: [block(blockKey, spanKey, 'bar')], + }) + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'bar'), + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [{type: 'set', path: textPath, value: 'bar'}], + }, + ]) + }) + + test('Scenario: A remote patch that triggers a fix emits the remote `change`, then a local `correction` with the fix', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const changes = collectChanges(editor) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + const duplicateKeyPath = [{_key: blockKey}, 'children', 1, '_key'] + const duplicateSpan = { + _type: 'span', + _key: spanKey, + text: 'bar', + marks: ['strong'], + } + + editor.send({ + type: 'patches', + patches: [ + {...insert([duplicateSpan], 'after', spanPath), origin: 'remote'}, + ], + snapshot: undefined, + }) + + expect(editor.getSnapshot().context.value).toEqual([ + { + ...block(blockKey, spanKey, 'foo'), + children: [ + {_type: 'span', _key: spanKey, text: 'foo', marks: []}, + {...duplicateSpan, _key: 'k4'}, + ], + }, + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [ + { + type: 'insert', + path: spanPath, + position: 'after', + node: duplicateSpan, + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + }, + ], + patches: [{...set('k4', duplicateKeyPath), origin: 'local'}], + correction: true, + }, + ]) + }) + + test('Scenario: `update value` emits one remote `change` for the whole update', async () => { + const keyGenerator = createTestKeyGenerator() + const fooBlockKey = keyGenerator() + const fooSpanKey = keyGenerator() + const bazBlockKey = keyGenerator() + const bazSpanKey = keyGenerator() + const barBlockKey = keyGenerator() + const barSpanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [ + block(fooBlockKey, fooSpanKey, 'foo'), + block(bazBlockKey, bazSpanKey, 'baz'), + block(barBlockKey, barSpanKey, 'bar'), + ], + }) + const changes = collectChanges(editor) + const fooSpanPath = [{_key: fooBlockKey}, 'children', {_key: fooSpanKey}] + const bazSpanPath = [{_key: bazBlockKey}, 'children', {_key: bazSpanKey}] + + editor.send({ + type: 'update value', + value: [ + block(fooBlockKey, fooSpanKey, 'foo!'), + block(bazBlockKey, bazSpanKey, 'baz!'), + ], + }) + + expect(editor.getSnapshot().context.value).toEqual([ + block(fooBlockKey, fooSpanKey, 'foo!'), + block(bazBlockKey, bazSpanKey, 'baz!'), + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [ + {type: 'unset', path: [{_key: barBlockKey}]}, + {type: 'set', path: [{_key: fooBlockKey}, 'markDefs'], value: []}, + {type: 'set', path: [...fooSpanPath, 'marks'], value: []}, + {type: 'remove.text', path: fooSpanPath, offset: 0, text: 'foo'}, + {type: 'insert.text', path: fooSpanPath, offset: 0, text: 'foo!'}, + {type: 'set', path: [{_key: bazBlockKey}, 'markDefs'], value: []}, + {type: 'set', path: [...bazSpanPath, 'marks'], value: []}, + {type: 'remove.text', path: bazSpanPath, offset: 0, text: 'baz'}, + {type: 'insert.text', path: bazSpanPath, offset: 0, text: 'baz!'}, + ], + }, + ]) + }) + + test('Scenario: A remote patch that leaves the content as it was emits no `change`', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const changes = collectChanges(editor) + const textPath = [{_key: blockKey}, 'children', {_key: spanKey}, 'text'] + + editor.send({ + type: 'patches', + patches: [{...set('foo', textPath), origin: 'remote'}], + snapshot: [block(blockKey, spanKey, 'foo')], + }) + editor.send({ + type: 'patches', + patches: [{...set('bar', textPath), origin: 'remote'}], + snapshot: [block(blockKey, spanKey, 'bar')], + }) + + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [{type: 'set', path: textPath, value: 'bar'}], + }, + ]) + }) + + test('Scenario: `update value` that needs a fix emits the remote `change`, then a local `correction` with the fix', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const duplicateKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const changes = collectChanges(editor) + const duplicateKeyChildren = [ + {_type: 'span', _key: duplicateKey, text: 'foo', marks: []}, + {_type: 'span', _key: duplicateKey, text: 'bar', marks: ['strong']}, + ] + const duplicateKeyPath = [{_key: blockKey}, 'children', 1, '_key'] + + editor.send({ + type: 'update value', + value: [ + { + ...block(blockKey, spanKey, ''), + children: duplicateKeyChildren, + }, + ], + }) + + expect(editor.getSnapshot().context.value).toEqual([ + { + ...block(blockKey, spanKey, ''), + children: [ + duplicateKeyChildren[0], + {...duplicateKeyChildren[1], _key: 'k5'}, + ], + }, + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [ + { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + }, + { + type: 'set', + path: [{_key: blockKey}, 'children'], + value: duplicateKeyChildren, + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: duplicateKeyPath, + value: 'k5', + }, + ], + patches: [{...set('k5', duplicateKeyPath), origin: 'local'}], + correction: true, + }, + ]) + }) + + test('Scenario: A remote patch that fails to apply does not stop later `change` events', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const changes = collectChanges(editor) + const textPath = [{_key: blockKey}, 'children', {_key: spanKey}, 'text'] + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + editor.send({ + type: 'patches', + patches: [ + { + ...set('bar', [{_key: blockKey}, 'children', 5, 'text']), + origin: 'remote', + }, + ], + snapshot: undefined, + }) + editor.send({ + type: 'patches', + patches: [{...set('bar', textPath), origin: 'remote'}], + snapshot: undefined, + }) + consoleError.mockRestore() + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'bar'), + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [{type: 'set', path: textPath, value: 'bar'}], + }, + ]) + }) + + test('Scenario: The first load of a value that needs no fix emits no `change`', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const events: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + children: ( + { + events.push(withoutInverses(event)) + }} + /> + ), + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'ready')).toBe(true) + }) + + editor.send({ + type: 'select', + at: { + anchor: {path: spanPath, offset: 3}, + focus: {path: spanPath, offset: 3}, + }, + }) + editor.send({type: 'insert.text', text: '!'}) + + expect(events.filter((event) => event.type === 'change')).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foo!', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + ]) + }) + + test('Scenario: A first load the editor has to fix emits only a `correction` with the fix', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const duplicateKey = keyGenerator() + const events: Array = [] + await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [ + { + ...block(blockKey, duplicateKey, ''), + children: [ + {_type: 'span', _key: duplicateKey, text: 'foo', marks: []}, + {_type: 'span', _key: duplicateKey, text: 'bar', marks: ['strong']}, + ], + }, + ], + children: ( + { + events.push(withoutInverses(event)) + }} + /> + ), + }) + const duplicateKeyPath = [{_key: blockKey}, 'children', 1, '_key'] + + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'ready')).toBe(true) + }) + + expect(events.filter((event) => event.type === 'change')).toEqual([ + { + type: 'change', + origin: 'local', + operations: [{type: 'set', path: duplicateKeyPath, value: 'k4'}], + patches: [{...set('k4', duplicateKeyPath), origin: 'local'}], + correction: true, + }, + ]) + }) + + test('Scenario: Actions that change nothing emit no `change`', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const changes = collectChanges(editor) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + + editor.send({ + type: 'update value', + value: [block(blockKey, spanKey, 'foo')], + }) + editor.send({ + type: 'select', + at: { + anchor: {path: spanPath, offset: 3}, + focus: {path: spanPath, offset: 3}, + }, + }) + editor.send({type: 'insert.text', text: '!'}) + + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foo!', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + ]) + }) + + test('Scenario: The local `change` patches of a session equal the patches of its `mutation` events', async () => { + const keyGenerator = createTestKeyGenerator() + const {editor, locator} = await createTestEditor({keyGenerator}) + const changes = collectChanges(editor) + const mutations = collectMutations(editor) + const fooSpanPath = [{_key: 'k0'}, 'children', {_key: 'k1'}] + const barSpanPath = [{_key: 'k2'}, 'children', {_key: 'k3'}] + + // No intake repairs here: the batcher may drop a superseded one from + // `mutation`, while `change` always carries its patches. + await userEvent.type(locator, 'foo') + await userEvent.keyboard('{Enter}') + await userEvent.type(locator, 'bar') + editor.send({type: 'history.undo'}) + + const sessionPatches = [ + {...setIfMissing([], []), origin: 'local'}, + {...insert([block('k0', 'k1', '')], 'before', [0]), origin: 'local'}, + {...diffMatchPatch('', 'f', [...fooSpanPath, 'text']), origin: 'local'}, + {...diffMatchPatch('f', 'fo', [...fooSpanPath, 'text']), origin: 'local'}, + { + ...diffMatchPatch('fo', 'foo', [...fooSpanPath, 'text']), + origin: 'local', + }, + { + ...insert([block('k2', 'k3', '')], 'after', [{_key: 'k0'}]), + origin: 'local', + }, + {...diffMatchPatch('', 'b', [...barSpanPath, 'text']), origin: 'local'}, + {...diffMatchPatch('b', 'ba', [...barSpanPath, 'text']), origin: 'local'}, + { + ...diffMatchPatch('ba', 'bar', [...barSpanPath, 'text']), + origin: 'local', + }, + { + ...diffMatchPatch('bar', 'ba', [...barSpanPath, 'text']), + origin: 'local', + }, + {...diffMatchPatch('ba', 'b', [...barSpanPath, 'text']), origin: 'local'}, + {...diffMatchPatch('b', '', [...barSpanPath, 'text']), origin: 'local'}, + ] + + await vi.waitFor(() => { + expect(mutations.flatMap((mutation) => mutation.patches)).toEqual( + sessionPatches, + ) + }) + expect( + changes.flatMap((change) => + change.origin === 'local' ? change.patches : [], + ), + ).toEqual(sessionPatches) + }) +}) + +function collectChanges(editor: Editor): Array { + const changes: Array = [] + editor.on('change', (event) => { + changes.push(withoutInverses(event)) + }) + return changes +} + +function withoutInverses( + event: TEvent, +): TEvent { + if (event.type !== 'change') { + return event + } + + return { + ...event, + operations: event.operations.map((operation) => + 'inverse' in operation ? {...operation, inverse: undefined} : operation, + ), + } +} + +function collectMutations(editor: Editor): Array { + const mutations: Array = [] + editor.on('mutation', (event) => { + mutations.push(event) + }) + return mutations +} + +function block( + blockKey: string, + spanKey: string, + text: string, +): PortableTextBlock { + return { + _type: 'block', + _key: blockKey, + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: spanKey, text, marks: []}], + } +} diff --git a/packages/editor/tests/event.update-value.test.tsx b/packages/editor/tests/event.update-value.test.tsx index 5c62451ca3..84e6c8d75c 100644 --- a/packages/editor/tests/event.update-value.test.tsx +++ b/packages/editor/tests/event.update-value.test.tsx @@ -664,6 +664,23 @@ describe('event.update value', () => { }, origin: 'remote', }, + { + type: 'change', + origin: 'remote', + operations: [ + {type: 'unset', path: [{_key: 'k0'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'foo', marks: []}], + }, + }, + ], + }, { type: 'invalid value', resolution: { @@ -751,6 +768,59 @@ describe('event.update value', () => { }, origin: 'local', }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'insert.text', + path: [{_key: 'k2'}, 'children', {_key: 'k3'}], + offset: 3, + text: '!', + }, + ], + patches: [ + { + type: 'diffMatchPatch', + origin: 'local', + path: [{_key: 'k2'}, 'children', {_key: 'k3'}, 'text'], + value: stringifyPatches(makePatches(makeDiff('foo', 'foo!'))), + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: [{_key: 'k2'}, 'markDefs'], + value: [], + inverse: {type: 'unset', path: [{_key: 'k2'}, 'markDefs']}, + }, + { + type: 'set', + path: [{_key: 'k2'}, 'style'], + value: 'normal', + inverse: {type: 'unset', path: [{_key: 'k2'}, 'style']}, + }, + ], + patches: [ + { + type: 'set', + origin: 'local', + path: [{_key: 'k2'}, 'markDefs'], + value: [], + }, + { + type: 'set', + origin: 'local', + path: [{_key: 'k2'}, 'style'], + value: 'normal', + }, + ], + correction: true, + }, { type: 'patch', patch: { diff --git a/packages/editor/tests/validation.test.tsx b/packages/editor/tests/validation.test.tsx index d1d9f50e4a..a9b968cb70 100644 --- a/packages/editor/tests/validation.test.tsx +++ b/packages/editor/tests/validation.test.tsx @@ -190,6 +190,59 @@ describe('Value validation', () => { }, origin: 'local', }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'insert.text', + path: [{_key: 'k0'}, 'children', {_key: 'k1'}], + offset: 3, + text: '!', + }, + ], + patches: [ + { + type: 'diffMatchPatch', + origin: 'local', + path: [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text'], + value: stringifyPatches(makePatches(makeDiff('foo', 'foo!'))), + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: [{_key: 'k0'}, 'markDefs'], + value: [], + inverse: {type: 'unset', path: [{_key: 'k0'}, 'markDefs']}, + }, + { + type: 'set', + path: [{_key: 'k0'}, 'style'], + value: 'normal', + inverse: {type: 'unset', path: [{_key: 'k0'}, 'style']}, + }, + ], + patches: [ + { + type: 'set', + origin: 'local', + path: [{_key: 'k0'}, 'markDefs'], + value: [], + }, + { + type: 'set', + origin: 'local', + path: [{_key: 'k0'}, 'style'], + value: 'normal', + }, + ], + correction: true, + }, { type: 'patch', patch: { @@ -471,6 +524,59 @@ describe('Value validation', () => { }, origin: 'local', }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'insert.text', + path: [{_key: blockKey}, 'children', {_key: fooKey}], + offset: 3, + text: '!', + }, + ], + patches: [ + { + type: 'diffMatchPatch', + origin: 'local', + path: [{_key: blockKey}, 'children', {_key: fooKey}, 'text'], + value: stringifyPatches(makePatches(makeDiff('foo', 'foo!'))), + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + inverse: {type: 'unset', path: [{_key: blockKey}, 'markDefs']}, + }, + { + type: 'set', + path: [{_key: blockKey}, 'style'], + value: 'normal', + inverse: {type: 'unset', path: [{_key: blockKey}, 'style']}, + }, + ], + patches: [ + { + type: 'set', + origin: 'local', + path: [{_key: blockKey}, 'markDefs'], + value: [], + }, + { + type: 'set', + origin: 'local', + path: [{_key: blockKey}, 'style'], + value: 'normal', + }, + ], + correction: true, + }, { type: 'patch', patch: { @@ -627,6 +733,23 @@ describe('Value validation', () => { }, origin: 'remote', }, + { + type: 'change', + origin: 'remote', + operations: [ + {type: 'unset', path: [{_key: 'k0'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'foo', marks: []}], + }, + }, + ], + }, { type: 'invalid value', resolution: { @@ -709,6 +832,59 @@ describe('Value validation', () => { }, origin: 'local', }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'insert.text', + path: [{_key: 'k2'}, 'children', {_key: 'k3'}], + offset: 3, + text: '!', + }, + ], + patches: [ + { + type: 'diffMatchPatch', + origin: 'local', + path: [{_key: 'k2'}, 'children', {_key: 'k3'}, 'text'], + value: stringifyPatches(makePatches(makeDiff('foo', 'foo!'))), + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: [{_key: 'k2'}, 'markDefs'], + value: [], + inverse: {type: 'unset', path: [{_key: 'k2'}, 'markDefs']}, + }, + { + type: 'set', + path: [{_key: 'k2'}, 'style'], + value: 'normal', + inverse: {type: 'unset', path: [{_key: 'k2'}, 'style']}, + }, + ], + patches: [ + { + type: 'set', + origin: 'local', + path: [{_key: 'k2'}, 'markDefs'], + value: [], + }, + { + type: 'set', + origin: 'local', + path: [{_key: 'k2'}, 'style'], + value: 'normal', + }, + ], + correction: true, + }, { type: 'patch', patch: {