diff --git a/.changeset/emit-change-events.md b/.changeset/emit-change-events.md new file mode 100644 index 0000000000..42589b0f7c --- /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 Sanity 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 Sanity 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 36a33d1fb1..c833d7969b 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' @@ -81,16 +82,28 @@ export function createEditorEngine( editor.isPatching = true editor.isPerformingBehaviorOperation = false editor.withHistory = true + editor.onChangeEvent = () => {} const editorEngine = plugins(withDOM(editor), { editorActor: config.editorActor, }) subscribeUpdateValue(context, 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 2007595ed1..0d55c9d0a1 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 @@ -280,6 +258,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 e6fbcf3082..29cb450b8c 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 {isInNormalization} from '../engine/core/apply-context' import {DOMEditor} from '../engine/dom/plugin/dom-editor' import {normalize} from '../engine/editor/normalize' @@ -403,32 +404,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 4aa4c29ecb..86f3979333 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' } @@ -73,6 +74,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 Sanity 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 a39d5ebd86..e4664e7d24 100644 --- a/packages/editor/src/editor/subscriber.patch-generation.ts +++ b/packages/editor/src/editor/subscriber.patch-generation.ts @@ -1,4 +1,8 @@ -import {subscribeToOperations} from '../engine/core/operation-channel' +import type {Patch} from '@portabletext/patches' +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' @@ -11,9 +15,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) { @@ -46,6 +52,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 1f29be8f5a..3cb88f51c6 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' @@ -77,12 +78,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 ed5a0abd56..b286112220 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 0e4470a2d5..5393235bac 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 {EngineOperation} from '../engine/interfaces/operation' @@ -104,6 +105,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 6d43593fcf..d191a101b9 100644 --- a/packages/editor/tests/event.update-value.test.tsx +++ b/packages/editor/tests/event.update-value.test.tsx @@ -662,6 +662,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: { @@ -749,6 +766,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 d2981c9d77..1cd3fc070c 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: { @@ -468,6 +521,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: { @@ -621,6 +727,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: { @@ -703,6 +826,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: {