diff --git a/.changeset/emit-change-events.md b/.changeset/emit-change-events.md new file mode 100644 index 0000000000..aaf12c6b8f --- /dev/null +++ b/.changeset/emit-change-events.md @@ -0,0 +1,32 @@ +--- +'@portabletext/editor': minor +--- + +feat: emit `change` events with the applied operations for local and remote changes + +The editor now emits a `change` event for every edit applied to the document. Each event carries the applied operations and an `origin` that tells you whether the edit was made in this editor (`'local'`) or arrived from the outside (`'remote'`). Use it to keep derived state (indexes, anchors, external copies of the value) in sync without diffing the value yourself. Previously only local edits were observable, through the `mutation` event. + +```tsx +import {EventListenerPlugin} from '@portabletext/editor/plugins' + + { + if (event.type === 'change' && event.origin === 'remote') { + for (const operation of event.operations) { + invalidateBlock(operation.path[0]) + } + } + }} +/> +``` + +Notes: + +- `operations` uses the same operation types as the `operation` event: `insert`, `insert.text`, `remove.text`, `set`, and `unset`. +- Local events arrive at the same cadence as `mutation` events. Undo and redo count as local. +- Remote updates emit one event per applied block, in application order. Apply them in that order. +- An `update value` that changes nothing emits nothing. +- The initial value sync also emits a `change`, taking the editor's empty seed document to your configured initial value. If you maintain a copy of the value loaded from storage, start applying events at the `ready` event; otherwise you would re-apply content your copy already has. +- The `operations` array is a fresh copy per event, but the operation objects in it are shared with the editor: treat them as read-only and copy anything you keep around. + +New exports: `ChangeEvent`, and the `'change'` member on `EditorEmittedEvent`. Exhaustive switches over emitted event types gain a case. diff --git a/packages/editor/src/editor/create-editor-engine.tsx b/packages/editor/src/editor/create-editor-engine.tsx index 0dac728ae3..399679f882 100644 --- a/packages/editor/src/editor/create-editor-engine.tsx +++ b/packages/editor/src/editor/create-editor-engine.tsx @@ -80,6 +80,7 @@ export function createEditorEngine( editor.isPatching = true editor.isPerformingBehaviorOperation = false editor.withHistory = true + editor.onRemoteChange = () => {} const editorEngine = plugins(withDOM(editor), { editorActor: config.editorActor, diff --git a/packages/editor/src/editor/create-editor.test.ts b/packages/editor/src/editor/create-editor.test.ts new file mode 100644 index 0000000000..75c2d839b6 --- /dev/null +++ b/packages/editor/src/editor/create-editor.test.ts @@ -0,0 +1,78 @@ +import {defineSchema} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {stopActor} from '../internal-utils/stop-actor' +import {createInternalEditor} from './create-editor' +import type {EditorEmittedEvent} from './relay' + +/** + * Pins the `mutation`-`change` pairing for edits applied while the editor + * actor is idle: `editorEngine.apply` is the same direct-engine call + * `Editable.tsx`'s focus handler uses, bypassing `editorActor.send` and + * with it the actor's mailbox deferral. + */ +describe('createInternalEditor: local `change` joins a `mutation` applied outside actor processing', () => { + test('a `set` operation applied directly on the engine still reports a `change`', async () => { + const internalEditor = createInternalEditor({ + keyGenerator: createTestKeyGenerator(), + schemaDefinition: defineSchema({}), + initialValue: [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'b1-span', text: 'foo', marks: []}], + }, + ], + }) + + const unsubscribers = internalEditor.subscriptions.map((subscribe) => + subscribe(), + ) + internalEditor.actors.editorActor.start() + internalEditor.actors.editorActor.send({ + type: 'add editor engine', + editor: internalEditor.editorEngine, + }) + internalEditor.relay.start() + internalEditor.actors.syncActor.start() + + const events: Array = [] + internalEditor.editor.on('*', (event) => { + events.push(event) + }) + + // The initial value reaches the engine through the sync machine's own + // (async) reconciliation: `apply` must wait for it, or it targets the + // placeholder block the engine starts with instead of `b1`. + await vi.waitFor(() => { + expect(internalEditor.editorEngine.snapshot.context.value).toEqual([ + expect.objectContaining({_key: 'b1'}), + ]) + }) + + internalEditor.editorEngine.apply({ + type: 'set', + path: [{_key: 'b1'}, 'style'], + value: 'h1', + }) + + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'mutation')).toBe(true) + }) + + expect( + events.some( + (event) => event.type === 'change' && event.origin === 'local', + ), + ).toBe(true) + + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + stopActor(internalEditor.actors.editorActor) + internalEditor.relay.stop() + stopActor(internalEditor.actors.syncActor) + }) +}) diff --git a/packages/editor/src/editor/create-editor.ts b/packages/editor/src/editor/create-editor.ts index f65a843709..bb47d16e7c 100644 --- a/packages/editor/src/editor/create-editor.ts +++ b/packages/editor/src/editor/create-editor.ts @@ -3,13 +3,12 @@ import {createActor} from 'xstate' import {coreConverters} from '../converters/converters.core' import type {Editor, EditorConfig} from '../editor' 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' @@ -142,6 +141,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': @@ -196,28 +196,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 relay: Relay @@ -234,6 +212,12 @@ function createActors(config: { relay: config.relay, }) + // `withRemoteChanges` brackets every remote application; this is its + // only path to the relay. + config.editorEngine.onRemoteChange = (operations) => { + config.relay.send({type: 'change', operations, origin: 'remote'}) + } + const syncActor = createActor(syncMachine, { input: { initialValue: config.editorActor.getSnapshot().context.initialValue, @@ -312,8 +296,23 @@ function createActors(config: { config.subscriptions.push(() => { const subscription = config.editorActor.on('*', (event) => { switch (event.type) { + case 'mutation': { + // Internal fields stripped: they never widen the public + // `MutationEvent`. + const {operations, ...mutationEvent} = event + config.relay.send(mutationEvent) + // A flush with no operations (a repair-only or auto-resolution + // `mutation`) emits no `change`. + if (operations.length > 0) { + config.relay.send({ + type: 'change', + operations: [...operations], + origin: 'local', + }) + } + break + } case 'editable': - case 'mutation': case 'ready': case 'read only': case 'selection': diff --git a/packages/editor/src/editor/editor-machine.ts b/packages/editor/src/editor/editor-machine.ts index e6fbcf3082..f8be8c44f5 100644 --- a/packages/editor/src/editor/editor-machine.ts +++ b/packages/editor/src/editor/editor-machine.ts @@ -27,6 +27,7 @@ import {pathContains} from '../traversal/path-contains' import type {NamespaceEvent, OmitFromUnion} from '../type-utils' import type {EditorSelection} from '../types/editor' import type {PortableTextEditorEngine} from '../types/editor-engine' +import type {Operation} from '../types/operation' import type {EditorSchema} from './editor-schema' import { registerNodeOnEngine, @@ -58,6 +59,15 @@ export type ExternalEditorEvent = type InternalPatchEvent = NamespaceEvent & { operationId?: string value: Array + // Set on an operation's first patch only; the batcher bulks one entry + // per applied operation. + operation?: Operation +} + +// Internal-only: the flushed bulk's operations ride to the relay bridge, +// which strips them before the `mutation` reaches consumers. +type InternalMutationEvent = MutationEvent & { + operations: Array } /** @@ -102,7 +112,7 @@ type InternalEditorEvent = editor: PortableTextEditorEngine nativeEvent?: {preventDefault: () => void} } - | MutationEvent + | InternalMutationEvent | InternalPatchEvent | { type: 'set drag ghost' @@ -129,8 +139,9 @@ type InternalEditorEvent = * @internal */ type InternalEditorEmittedEvent = - | OmitFromUnion + | OmitFromUnion | InternalPatchEvent + | InternalMutationEvent | PatchesEvent export function rerouteExternalBehaviorEvent({ @@ -186,7 +197,7 @@ export const editorMachine = setup({ behaviorsSorted: boolean initialConverters: Array keyGenerator: () => string - pendingEvents: Array + pendingEvents: Array pendingIncomingPatchesEvents: Array pendingRegistrations: Array schema: EditorSchema diff --git a/packages/editor/src/editor/mutation-batcher.ts b/packages/editor/src/editor/mutation-batcher.ts index 13add535aa..83ee3e0f93 100644 --- a/packages/editor/src/editor/mutation-batcher.ts +++ b/packages/editor/src/editor/mutation-batcher.ts @@ -3,6 +3,7 @@ import type {PortableTextBlock} from '@portabletext/schema' import {subscribeToOperations} from '../engine/core/operation-channel' import {isNormalizing} from '../engine/editor/is-normalizing' import type {PortableTextEditorEngine} from '../types/editor-engine' +import type {Operation} from '../types/operation' import type {EditorActor} from './editor-machine' import type {Relay} from './relay' @@ -10,6 +11,7 @@ type PendingMutation = { operationId?: string value: Array | undefined patches: Array + operations: Array } const TYPE_DEBOUNCE = 250 @@ -67,6 +69,7 @@ export function createMutationBatcher({ patch: Patch operationId?: string value: Array + operation?: Operation }) { editorEngine.isDeferringMutations = true @@ -81,11 +84,15 @@ export function createMutationBatcher({ if (lastBulk && lastBulk.operationId === event.operationId) { lastBulk.value = event.value lastBulk.patches.push(event.patch) + if (event.operation !== undefined) { + lastBulk.operations.push(event.operation) + } } else { pendingMutations.push({ operationId: event.operationId, value: event.value, patches: [event.patch], + operations: event.operation !== undefined ? [event.operation] : [], }) } @@ -128,6 +135,7 @@ export function createMutationBatcher({ type: 'mutation', patches: bulk.patches, value: bulk.value, + operations: bulk.operations, }) } } diff --git a/packages/editor/src/editor/relay.ts b/packages/editor/src/editor/relay.ts index 4aa4c29ecb..8e2de47afd 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,61 @@ export type EditorEmittedEvent = value: Array | undefined } +/** + * @beta + * The document's change ledger: what was applied, from any origin, in + * order. The `mutation` event is the outbox (local patches to persist); + * `change` is the ledger, so a local edit appears in both, each serving + * its own consumers. Subscribe to `mutation` to persist, to `change` to + * track what happened to the document. + * + * `operations` are the same {@link Operation} vocabulary the `operation` + * event carries (`set.selection` excluded), at full available fidelity: + * no patch conversion, no `diffMatchPatch` round trip. They are what was + * applied to the document, never the received inputs: a local edit's + * operations are the engine's own local edit operations; a remote + * change's operations are whatever the engine actually applied to reach + * the fed patches, the `update value` reconciliation, or the initial + * value sync, not the fed patches or value themselves. Never emitted + * with an empty `operations` array. + * + * The `operations` array is the consumer's own copy, safe to hold onto + * past the listener call. The operation objects inside it are the + * engine's own, passed by reference, the same as on the `operation` + * event: treat them as read-only and copy any object you retain. + * + * A remote update emits one or more `change` events, in application + * order: the sync machine applies a changed value block by block, and + * each applied block's operations arrive as their own event. Fold the + * events in delivery order to reproduce the full set of applied changes; + * never coalesce them yourself, the streamed sync path can interleave a + * local flush's own `change` between two remote ones, and coalescing + * would misorder that interleaving. + * + * The initial value sync emits its own remote `change`, taking the + * editor's seed document to the configured initial value: a consumer + * folding stored positions onto live changes starts at the `ready` + * event (skip everything before it) to avoid applying that sync as a + * spurious delta. + * + * A local bulk's `operations` holds the operations whose application + * produced an outgoing patch, matching the outbox: an applied operation + * whose patch conversion yields nothing is absent. A remote bulk's + * `operations` holds every applied public operation. Editor-structural + * bookkeeping is neither patched nor reported: the placeholder block the + * engine inserts when the document empties is uninhabitable by any + * position, and its creation appears on no channel. An operation that + * later removes or replaces that placeholder (real content arriving) does + * appear, as part of the update that applied it: it folds as a no-op + * against a stored value, an `unset`/remove of a key the stored value + * never had. + */ +export type ChangeEvent = { + type: 'change' + operations: Array + origin: 'local' | 'remote' +} + /** * @public */ diff --git a/packages/editor/src/editor/subscriber.patch-generation.ts b/packages/editor/src/editor/subscriber.patch-generation.ts index 6ad8a32cce..2d4900f7dd 100644 --- a/packages/editor/src/editor/subscriber.patch-generation.ts +++ b/packages/editor/src/editor/subscriber.patch-generation.ts @@ -5,6 +5,7 @@ import { unset, type Patch, } from '@portabletext/patches' +import {hasRemoteFrame} from '../engine/core/apply-context' import {subscribeToOperations} from '../engine/core/operation-channel' import {isEqualValues} from '../internal-utils/equality' import { @@ -13,6 +14,7 @@ import { } from '../internal-utils/operation-to-patches' import {isEqualToEmptyEditor} from '../internal-utils/values' import type {PortableTextEditorEngine} from '../types/editor-engine' +import {isPublicOperation} from '../types/operation' import type {EditorActor} from './editor-machine' /** @@ -113,14 +115,23 @@ export function subscribePatchGeneration({ // Emit all patches if (patches.length > 0) { - for (const patch of patches) { + // Once per operation (first patch only): the batcher bulks + // operations, not patches. Absent for remote-bracket repairs, whose + // operations the remote `change` already reports. + const operation = + !hasRemoteFrame(event.context) && isPublicOperation(event.operation) + ? event.operation + : undefined + + patches.forEach((patch, index) => { editorActor.send({ type: 'internal.patch', patch: {...patch, origin: 'local'}, operationId: event.undoStepId, value: editor.snapshot.context.value, + operation: index === 0 ? operation : undefined, }) - } + }) } }) } diff --git a/packages/editor/src/editor/sync-machine.test.ts b/packages/editor/src/editor/sync-machine.test.ts index 02a6445d78..c16eff947e 100644 --- a/packages/editor/src/editor/sync-machine.test.ts +++ b/packages/editor/src/editor/sync-machine.test.ts @@ -17,6 +17,7 @@ function createTestEngine(keyGenerator: () => string) { e.containers = new Map() e.blockIndexMap = new Map() e.verifiedUniqueChildGroups = new Set() + e.onRemoteChange = () => {} e.snapshot = { blockIndexMap: e.blockIndexMap, context: { 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..3721b8929f 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,41 @@ -import type {ApplyContextFrame} from '../engine/core/apply-context' +import { + hasRemoteFrame, + type ApplyContextFrame, +} from '../engine/core/apply-context' +import {subscribeToOperations} from '../engine/core/operation-channel' import type {PortableTextEditorEngine} from '../types/editor-engine' +import {isPublicOperation, type Operation} from '../types/operation' export function withRemoteChanges( editor: PortableTextEditorEngine, source: Extract['source'], fn: () => void, ): void { + // The outermost bracket collects and emits; a nested `withRemoteChanges` + // call just contributes its operations to it. + const isOutermost = !hasRemoteFrame(editor.applyContext) + + const operations: Array = [] + const unsubscribe = isOutermost + ? subscribeToOperations(editor, (event) => { + if ( + isPublicOperation(event.operation) && + !event.context.some((frame) => frame.kind === 'placeholder') + ) { + operations.push(event.operation) + } + }) + : undefined + editor.applyContext.push(Object.freeze({kind: 'remote', source})) try { fn() } finally { editor.applyContext.pop() + unsubscribe?.() + } + + if (isOutermost && operations.length > 0) { + editor.onRemoteChange(operations) } } diff --git a/packages/editor/src/engine/core/apply-context.test.ts b/packages/editor/src/engine/core/apply-context.test.ts new file mode 100644 index 0000000000..749fd47cc6 --- /dev/null +++ b/packages/editor/src/engine/core/apply-context.test.ts @@ -0,0 +1,18 @@ +import {describe, expect, test} from 'vitest' +import {getOrigin} from './apply-context' + +describe('getOrigin', () => { + test('`placeholder` frames never affect the origin', () => { + expect(getOrigin([{kind: 'placeholder'}])).toEqual('local') + expect( + getOrigin([ + {kind: 'remote', source: 'patches'}, + {kind: 'normalization'}, + {kind: 'placeholder'}, + ]), + ).toEqual('remote') + expect(getOrigin([{kind: 'normalization'}, {kind: 'placeholder'}])).toEqual( + 'normalization', + ) + }) +}) diff --git a/packages/editor/src/engine/core/apply-context.ts b/packages/editor/src/engine/core/apply-context.ts index fd0862b503..87e3eec740 100644 --- a/packages/editor/src/engine/core/apply-context.ts +++ b/packages/editor/src/engine/core/apply-context.ts @@ -12,6 +12,7 @@ export type ApplyContextFrame = | {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 063ff05d1f..7f0bead9c3 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/engine/core/operation-channel.test.ts b/packages/editor/src/engine/core/operation-channel.test.ts index 181f5765a7..41c6c13017 100644 --- a/packages/editor/src/engine/core/operation-channel.test.ts +++ b/packages/editor/src/engine/core/operation-channel.test.ts @@ -32,6 +32,7 @@ function createBareEditor(value: Array): Editor { editor.containers = new Map() editor.blockIndexMap = new Map() editor.verifiedUniqueChildGroups = new Set() + editor.onRemoteChange = () => {} editor.snapshot = { blockIndexMap: editor.blockIndexMap, context: { diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index e797356ca4..f5fdec7563 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/plugins/plugin.event-listener.tsx b/packages/editor/src/plugins/plugin.event-listener.tsx index b008862ecd..9156b7061c 100644 --- a/packages/editor/src/plugins/plugin.event-listener.tsx +++ b/packages/editor/src/plugins/plugin.event-listener.tsx @@ -6,6 +6,7 @@ import {useEditor} from '../editor/use-editor' * @public * Listen for events emitted by the editor. Must be used inside `EditorProvider`. Events available include: * - 'blurred' + * - 'change' * - 'editable' * - 'focused' * - 'invalid value' diff --git a/packages/editor/src/types/editor-engine.ts b/packages/editor/src/types/editor-engine.ts index bce48c0293..5cb80561c7 100644 --- a/packages/editor/src/types/editor-engine.ts +++ b/packages/editor/src/types/editor-engine.ts @@ -14,6 +14,7 @@ import type { TextBlockConfig, } from '../renderers/renderer.types' import type {ResolvedContainers} from '../schema/resolve-containers' +import type {Operation} from './operation' type HistoryItem = { operations: EngineOperation[] @@ -89,6 +90,13 @@ export interface PortableTextEditorEngine extends DOMEditor { isPerformingBehaviorOperation: boolean withHistory: boolean + /** + * Called by `withRemoteChanges` once per bracket, with the operations + * applied inside it (`set.selection` excluded). Never called with an + * empty `operations` array. Wired to the relay at editor setup. + */ + onRemoteChange: (operations: Array) => 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..0d6c071e16 --- /dev/null +++ b/packages/editor/tests/event.change.test.tsx @@ -0,0 +1,763 @@ +import { + diffMatchPatch, + insert, + setIfMissing, + unset, +} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import { + defineSchema, + type ChangeEvent, + type Editor, + type EditorEmittedEvent, + type MutationEvent, +} from '../src' +import {IS_MAC} from '../src/internal-utils/is-hotkey' +import {safeParse, safeStringify} from '../src/internal-utils/safe-json' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' +import {createTestEditor} from '../src/test/vitest' + +// Not `ControlOrMeta`: `userEvent` resolves that from the host OS while the +// shortcut guard resolves the platform from the user agent (see +// `select-all.test.tsx`). +const selectAllChord = IS_MAC ? '{Meta>}a{/Meta}' : '{Control>}a{/Control}' + +describe('event.change', () => { + test("Scenario: Local typing produces a `change` event adjacent to the flush's `mutation`", async () => { + const events: Array = [] + const {locator} = await createTestEditor({ + initialValue: [block('b1', '')], + children: ( + { + events.push(event) + }} + /> + ), + }) + + await userEvent.type(locator, 'foo') + + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'mutation')).toBe(true) + }) + + const mutationIndex = events.findIndex((event) => event.type === 'mutation') + + // The `change` event is the very next event after its `mutation`: same + // flush, no event slipped in between. + expect(events[mutationIndex + 1]).toEqual({ + type: 'change', + operations: [ + { + type: 'insert.text', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}], + offset: 0, + text: 'f', + }, + { + type: 'insert.text', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}], + offset: 1, + text: 'o', + }, + { + type: 'insert.text', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}], + offset: 2, + text: 'o', + }, + ], + origin: 'local', + }) + }) + + test("Scenario: Local typing across two flushes reports each flush's own operations, never a cumulative total", async () => { + const changes: Array = [] + const {locator} = await createTestEditor({ + initialValue: [block('b1', '')], + children: ( + { + if (event.type !== 'change' || event.origin !== 'local') { + return + } + // Cloned at receipt: a consumer holding onto `changes` must see + // what was reported at the time, not a live array the engine + // keeps mutating after this listener returns. + changes.push(safeParse(safeStringify(event)) as ChangeEvent) + }} + /> + ), + }) + + await userEvent.type(locator, 'f') + + await vi.waitFor(() => { + expect(changes).toHaveLength(1) + }) + + await userEvent.type(locator, 'o') + + await vi.waitFor(() => { + expect(changes).toHaveLength(2) + }) + + const spanPath = [{_key: 'b1'}, 'children', {_key: 'b1-span'}] + expect(changes).toEqual([ + { + type: 'change', + operations: [ + {type: 'insert.text', path: spanPath, offset: 0, text: 'f'}, + ], + origin: 'local', + }, + { + type: 'change', + operations: [ + {type: 'insert.text', path: spanPath, offset: 1, text: 'o'}, + ], + origin: 'local', + }, + ]) + }) + + test("Scenario: The sync machine's auto-resolution patches produce a `mutation` with no matching local bulk, and emit no `change`", async () => { + const events: Array = [] + const {editor} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + children: ( + { + events.push(event) + }} + /> + ), + }) + + // A second `update value` with a real content change (`foo` -> `foo!`) + // and an orphaned `markDefs` entry (unused by any span mark): the sync + // machine's validation auto-resolves the orphan by sending its own + // `patch` (sync-machine.ts's `Resolve validations that can be resolved + // automatically` branch), outside `withoutPatching`'s bracket and with + // no local operation ever recorded for it. + editor.send({ + type: 'update value', + value: [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [{_type: 'link', _key: 'orphan', href: 'x'}], + children: [{_type: 'span', _key: 'b1-span', text: 'foo!', marks: []}], + }, + ], + }) + + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'mutation')).toBe(true) + }) + + expect(events).toEqual( + expect.arrayContaining([ + { + type: 'mutation', + patches: [ + {type: 'unset', path: [{_key: 'b1'}, 'markDefs', {_key: 'orphan'}]}, + ], + value: expect.anything(), + }, + ]), + ) + + // The auto-resolution `mutation` carries no local operations, so it + // must report no `change`: `origin: 'local'` never appears here. + expect( + events.some( + (event) => event.type === 'change' && event.origin === 'local', + ), + ).toBe(false) + }) + + test('Scenario: A retired local bulk is not re-reported when a later, unrelated auto-resolution mutation flushes', async () => { + const events: Array = [] + const {editor, locator} = await createTestEditor({ + initialValue: [block('b1', '')], + children: ( + { + events.push(event) + }} + /> + ), + }) + + await userEvent.type(locator, 'f') + + await vi.waitFor(() => { + expect( + events.some( + (event) => event.type === 'change' && event.origin === 'local', + ), + ).toBe(true) + }) + + const localChangesAfterTyping = events.filter( + (event) => event.type === 'change' && event.origin === 'local', + ) + + // A second `update value` with an orphaned `markDefs` entry, well + // after typing's own flush: the auto-resolution mutation that follows + // must not re-report `f`'s operations. + editor.send({ + type: 'update value', + value: [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [{_type: 'link', _key: 'orphan', href: 'x'}], + children: [{_type: 'span', _key: 'b1-span', text: 'f', marks: []}], + }, + ], + }) + + await vi.waitFor(() => { + expect( + events.some( + (event) => + event.type === 'mutation' && + event.patches.some((patch) => patch.path.includes('markDefs')), + ), + ).toBe(true) + }) + + expect( + events.filter( + (event) => event.type === 'change' && event.origin === 'local', + ), + ).toEqual(localChangesAfterTyping) + }) + + test('Scenario: Clearing the editor and typing again reports only content operations on `change`, while `mutation` carries the structural bookkeeping', async () => { + const {editor, locator} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + }) + const changes = collectChanges(editor) + const mutations: Array = [] + editor.on('mutation', (event) => { + mutations.push(event) + }) + + const spanPath = [{_key: 'b1'}, 'children', {_key: 'b1-span'}] + + await userEvent.click(locator) + await userEvent.keyboard(selectAllChord) + await userEvent.keyboard('{Backspace}') + + await vi.waitFor(() => { + expect(mutations).toHaveLength(1) + }) + + // Emptying the last block to nothing collapses the whole document: + // the outbox reports it as `unset([])`, in the same flush as the + // text removal, while the ledger reports only the text removal. + expect(mutations).toEqual([ + { + type: 'mutation', + patches: [ + { + ...diffMatchPatch('foo', '', [...spanPath, 'text']), + origin: 'local', + }, + {...unset([]), origin: 'local'}, + ], + value: expect.anything(), + }, + ]) + + expect(changes).toEqual([ + { + type: 'change', + operations: [ + {type: 'remove.text', path: spanPath, offset: 0, text: 'foo'}, + ], + origin: 'local', + }, + ]) + + await userEvent.type(locator, 'f') + + await vi.waitFor(() => { + expect(mutations).toHaveLength(2) + }) + + // Typing into the emptied editor re-synthesizes the document: the + // outbox replays the placeholder block that was living in memory all + // along (same `b1`/`b1-span` keys, never regenerated) via its own + // `setIfMissing`/`insert` bookkeeping patches. None of that reaches + // the ledger: the `change` carries only the typed character. + expect(mutations).toEqual([ + mutations[0], + { + type: 'mutation', + patches: [ + {...setIfMissing([], []), origin: 'local'}, + { + ...insert([block('b1', '')], 'before', [0]), + origin: 'local', + }, + { + ...diffMatchPatch('', 'f', [...spanPath, 'text']), + origin: 'local', + }, + ], + value: expect.anything(), + }, + ]) + + expect(changes).toEqual([ + changes[0], + { + type: 'change', + operations: [ + {type: 'insert.text', path: spanPath, offset: 0, text: 'f'}, + ], + origin: 'local', + }, + ]) + + // Every `change` bulk paired with its own `mutation`: neither channel + // dropped or coalesced a burst the other kept. + expect(changes).toHaveLength(mutations.length) + }) + + test('Scenario: A local bulk reusing an id still pending from an earlier bulk is not dropped', async () => { + // Two primitive `insert`s carry the default `undefined` id; a + // `decorator.toggle` between them opens a distinct one. All three land + // in one flush, and every `mutation` must keep its own `change` with + // its own operations despite the id reuse. + const {editor} = await createTestEditor({ + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [block('b1', 'foo')], + }) + const changes = collectChanges(editor) + const mutations: Array = [] + editor.on('mutation', (event) => { + mutations.push(event) + }) + + const insertAfterB1 = (blockKey: string, spanKey: string, text: string) => + editor.send({ + type: 'insert', + at: [{_key: 'b1'}], + value: { + _type: 'block', + _key: blockKey, + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: spanKey, text, marks: []}], + }, + position: 'after', + }) + + insertAfterB1('b2', 'b2-span', 'one') + editor.send({ + type: 'select', + at: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}], + offset: 0, + }, + focus: {path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}], offset: 3}, + }, + }) + editor.send({type: 'decorator.toggle', decorator: 'strong'}) + insertAfterB1('b3', 'b3-span', 'two') + + await vi.waitFor(() => { + expect(mutations).toHaveLength(3) + }) + + expect(changes).toEqual([ + { + type: 'change', + operations: [ + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'after', + node: { + _type: 'block', + _key: 'b2', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'b2-span', text: 'one', marks: []}, + ], + }, + inverse: {type: 'unset', path: [{_key: 'b2'}]}, + }, + ], + origin: 'local', + }, + { + type: 'change', + operations: [ + { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}, 'marks'], + value: ['strong'], + inverse: { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}, 'marks'], + value: [], + }, + }, + ], + origin: 'local', + }, + { + type: 'change', + operations: [ + { + type: 'insert', + path: [{_key: 'b1'}], + position: 'after', + node: { + _type: 'block', + _key: 'b3', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 'b3-span', text: 'two', marks: []}, + ], + }, + inverse: {type: 'unset', path: [{_key: 'b3'}]}, + }, + ], + origin: 'local', + }, + ]) + }) + + test('Scenario: A normalization repair triggered by a remote patch is reported only in the remote `change`', async () => { + // The fed patch inserts a span with no `_key`: `normalize` repairs it + // by setting one (`engine/core/normalize-node.ts`'s "Set missing key on + // node" rule, which runs for remote content too). The repair operation + // applies with `isPatching` already restored (its own `withoutPatching` + // bracket, nested inside `withRemoteChanges`'s, has closed) but still + // inside the remote bracket: it must join only that bracket's `change`, + // never a second, local one. + const {editor} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + }) + const changes = collectChanges(editor) + const mutations: Array = [] + editor.on('mutation', (event) => { + mutations.push(event) + }) + + editor.send({ + type: 'patches', + patches: [ + { + type: 'insert', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}], + position: 'after', + items: [{_type: 'span', text: 'bar', marks: []}], + origin: 'remote', + }, + ], + snapshot: undefined, + }) + + const repairPath = [{_key: 'b1'}, 'children', 1, '_key'] + + // The repair's own outgoing patch still reaches the outbox: waiting for + // its `mutation` proves the flush that would carry a spurious local + // `change` (the bug) has already happened by the time the assertion + // below runs. + await vi.waitFor(() => { + expect(mutations).toEqual([ + { + type: 'mutation', + patches: [ + {type: 'set', path: repairPath, value: 'k2', origin: 'local'}, + ], + value: expect.anything(), + }, + ]) + }) + + expect(changes).toEqual([ + { + type: 'change', + operations: [ + { + type: 'insert', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}], + position: 'after', + node: {_type: 'span', text: 'bar', marks: []}, + }, + { + type: 'set', + path: repairPath, + value: 'k2', + inverse: {type: 'unset', path: repairPath}, + }, + ], + origin: 'remote', + }, + ]) + }) + + test('Scenario: A remote patch that empties the document reports only the removal, never the placeholder insert', async () => { + // Emptying the document runs `normalizeNode`'s empty-editor branch, + // which inserts the placeholder block still inside `withRemoteChanges`'s + // bracket. The placeholder never lived in the stored document, so the + // remote `change` bulk must carry only the operation that removed the + // real content. + const {editor} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + }) + const changes = collectChanges(editor) + + editor.send({ + type: 'patches', + patches: [{type: 'unset', path: [{_key: 'b1'}], origin: 'remote'}], + snapshot: [], + }) + + await vi.waitFor(() => { + expect(changes).toEqual([ + { + type: 'change', + operations: [{type: 'unset', path: [{_key: 'b1'}]}], + origin: 'remote', + }, + ]) + }) + }) + + test('Scenario: Remote fed patches produce one `change` event with origin `remote`', async () => { + const {editor} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + }) + const changes = collectChanges(editor) + + editor.send({ + type: 'patches', + patches: [ + { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}, 'text'], + value: 'bar', + origin: 'remote', + }, + ], + snapshot: [block('b1', 'bar')], + }) + + await vi.waitFor(() => { + expect(changes).toEqual([ + { + type: 'change', + operations: [ + { + type: 'set', + path: [{_key: 'b1'}, 'children', {_key: 'b1-span'}, 'text'], + value: 'bar', + }, + ], + origin: 'remote', + }, + ]) + }) + }) + + test('Scenario: `update value` with a changed value produces a `change` event with the applied, non-empty operations', async () => { + const {editor} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + }) + const changes = collectChanges(editor) + + // No patch is fed here: the sync machine invents the operations that + // turn `foo` into `bar`, pinning that `change` carries what was + // applied, not what was received (there is nothing received). + editor.send({ + type: 'update value', + value: [block('b1', 'bar')], + }) + + await vi.waitFor(() => { + expect(changes).toEqual([ + { + type: 'change', + operations: reconciliationOperations('b1', 'foo', 'bar'), + origin: 'remote', + }, + ]) + }) + }) + + test('Scenario: `update value` changing two blocks emits one `change` event per block, in order', async () => { + const {editor} = await createTestEditor({ + initialValue: [block('b1', 'foo'), block('b2', 'baz')], + }) + const changes = collectChanges(editor) + + editor.send({ + type: 'update value', + value: [block('b1', 'foo!'), block('b2', 'baz!')], + }) + + await vi.waitFor(() => { + expect(changes).toEqual([ + { + type: 'change', + operations: reconciliationOperations('b1', 'foo', 'foo!'), + origin: 'remote', + }, + { + type: 'change', + operations: reconciliationOperations('b2', 'baz', 'baz!'), + origin: 'remote', + }, + ]) + }) + }) + + test('Scenario: `update value` with an identical value emits no `change` event', async () => { + const {editor} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + }) + const changes = collectChanges(editor) + + editor.send({ + type: 'update value', + value: [block('b1', 'foo')], + }) + editor.send({ + type: 'update value', + value: [block('b1', 'bar')], + }) + + // The identical update is proven silent by the next real update still + // landing exactly once: a spurious `change` from the identical update + // would show up here as a second event. + await vi.waitFor(() => { + expect(changes).toEqual([ + { + type: 'change', + operations: reconciliationOperations('b1', 'foo', 'bar'), + origin: 'remote', + }, + ]) + }) + }) + + test('Scenario: Undo after local typing produces a `change` event with origin `local`', async () => { + const {editor, locator} = await createTestEditor({ + initialValue: [block('b1', 'foo')], + }) + const mutations: Array = [] + editor.on('mutation', (event) => { + mutations.push(event) + }) + + await userEvent.type(locator, 'bar') + // Waiting for the typing's own `mutation` flush first keeps it from + // sharing an undo step (and thus one `change` bulk) with the undo + // that follows. + await vi.waitFor(() => { + expect(mutations.length).toBeGreaterThan(0) + expect(firstSpanText(editor)).toBe('barfoo') + }) + + const changes = collectChanges(editor) + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(firstSpanText(editor)).toBe('foo') + }) + + const spanPath = [{_key: 'b1'}, 'children', {_key: 'b1-span'}] + await vi.waitFor(() => { + expect(changes).toEqual([ + { + type: 'change', + 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'}, + ], + origin: 'local', + }, + ]) + }) + }) +}) + +function collectChanges(editor: Editor): Array { + const changes: Array = [] + editor.on('change', (event) => { + changes.push(event) + }) + return changes +} + +function block(key: string, text: string): PortableTextBlock { + return { + _type: 'block', + _key: key, + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: `${key}-span`, text, marks: []}], + } +} + +/** + * The operations the sync machine's reconciliation applies to turn a text + * block's `text` from `before` to `after`: normalization first fills in + * the missing `markDefs`/`marks` defaults (each carrying its own inverse, + * even though remote operations do not need one to be undoable), then the + * text itself is replaced by removing and re-inserting it wholesale rather + * than diffing. + */ +function reconciliationOperations( + blockKey: string, + before: string, + after: string, +) { + const blockPath = [{_key: blockKey}] + const spanPath = [{_key: blockKey}, 'children', {_key: `${blockKey}-span`}] + + return [ + { + type: 'set', + path: [...blockPath, 'markDefs'], + value: [], + inverse: {type: 'set', path: [...blockPath, 'markDefs'], value: []}, + }, + { + type: 'set', + path: [...spanPath, 'marks'], + value: [], + inverse: {type: 'set', path: [...spanPath, 'marks'], value: []}, + }, + {type: 'remove.text', path: spanPath, offset: 0, text: before}, + {type: 'insert.text', path: spanPath, offset: 0, text: after}, + ] +} + +function firstSpanText(editor: Editor): string { + const firstBlock = editor.getSnapshot().context.value[0] as + | (PortableTextBlock & {children: Array<{text?: string}>}) + | undefined + const span = firstBlock?.children[0] + return typeof span?.text === 'string' ? span.text : '' +} diff --git a/packages/editor/tests/event.update-value.test.tsx b/packages/editor/tests/event.update-value.test.tsx index ef376cb419..6f386d5e89 100644 --- a/packages/editor/tests/event.update-value.test.tsx +++ b/packages/editor/tests/event.update-value.test.tsx @@ -575,6 +575,23 @@ describe('event.update value', () => { }, origin: 'remote', }, + { + type: 'change', + 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: []}], + }, + }, + ], + origin: 'remote', + }, { type: 'invalid value', resolution: { @@ -729,6 +746,30 @@ describe('event.update value', () => { }, ], }, + { + type: 'change', + operations: [ + { + type: 'insert.text', + path: [{_key: 'k2'}, 'children', {_key: 'k3'}], + offset: 3, + text: '!', + }, + { + 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']}, + }, + ], + origin: 'local', + }, ]) }) // The engine's own document agrees with the emitted patches: the @@ -977,6 +1018,23 @@ describe('event.update value', () => { }, origin: 'remote', }, + { + type: 'change', + operations: [ + {type: 'unset', path: [{_key: 'k1'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: { + _type: 'image', + _key: imageKey, + src: 'https://example.com/image.jpg', + }, + }, + ], + origin: 'remote', + }, { type: 'value changed', value: [ diff --git a/packages/editor/tests/setup.test.tsx b/packages/editor/tests/setup.test.tsx index 7b114a0587..891b9edbc0 100644 --- a/packages/editor/tests/setup.test.tsx +++ b/packages/editor/tests/setup.test.tsx @@ -61,7 +61,7 @@ describe('Setup', () => { expect(toTextspec(editor.getSnapshot().context)).toEqual('B: foo') }) - expect(events.slice(0, 4)).toEqual([ + expect(events.slice(0, 5)).toEqual([ // Sync applies the valid first block (replacing the seed block `k5`; // the missing `markDefs`/`style` defaults are deferred until a local // edit touches the block), then stops at the unknown block object. @@ -84,6 +84,23 @@ describe('Setup', () => { }, origin: 'remote', }, + { + type: 'change', + operations: [ + {type: 'unset', path: [{_key: 'k5'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: { + _key: 'k0', + _type: 'block', + children: [{_type: 'span', _key: 'k1', text: 'foo'}], + }, + }, + ], + origin: 'remote', + }, { type: 'invalid value', resolution: { diff --git a/packages/editor/tests/validation.test.tsx b/packages/editor/tests/validation.test.tsx index 421ee57920..6128093bd6 100644 --- a/packages/editor/tests/validation.test.tsx +++ b/packages/editor/tests/validation.test.tsx @@ -91,6 +91,23 @@ describe('Value validation', () => { }, origin: 'remote', }, + { + type: 'change', + operations: [ + {type: 'unset', path: [{_key: 'k3'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + }, + ], + origin: 'remote', + }, { type: 'invalid value', resolution: { @@ -242,6 +259,30 @@ describe('Value validation', () => { }, ], }, + { + type: 'change', + operations: [ + { + type: 'insert.text', + path: [{_key: 'k0'}, 'children', {_key: 'k1'}], + offset: 3, + text: '!', + }, + { + 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']}, + }, + ], + origin: 'local', + }, ]) }) // The engine's own document agrees with the emitted patches: the @@ -319,6 +360,19 @@ describe('Value validation', () => { }, origin: 'remote', }, + { + type: 'change', + operations: [ + {type: 'unset', path: [{_key: 'k3'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: syncedBlock, + }, + ], + origin: 'remote', + }, {type: 'value changed', value: [syncedBlock]}, {type: 'ready'}, ]) @@ -529,6 +583,30 @@ describe('Value validation', () => { }, ], }, + { + type: 'change', + operations: [ + { + type: 'insert.text', + path: [{_key: blockKey}, 'children', {_key: fooKey}], + offset: 3, + text: '!', + }, + { + 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']}, + }, + ], + origin: 'local', + }, ]) }) // The engine's own document agrees with the emitted patches: the @@ -606,6 +684,23 @@ describe('Value validation', () => { }, origin: 'remote', }, + { + type: 'change', + 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: []}], + }, + }, + ], + origin: 'remote', + }, { type: 'invalid value', resolution: { @@ -755,6 +850,30 @@ describe('Value validation', () => { }, ], }, + { + type: 'change', + operations: [ + { + type: 'insert.text', + path: [{_key: 'k2'}, 'children', {_key: 'k3'}], + offset: 3, + text: '!', + }, + { + 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']}, + }, + ], + origin: 'local', + }, ]) }) // The engine's own document agrees with the emitted patches: the