diff --git a/.changeset/emit-change-events.md b/.changeset/emit-change-events.md new file mode 100644 index 0000000000..c22bf55f7b --- /dev/null +++ b/.changeset/emit-change-events.md @@ -0,0 +1,24 @@ +--- +'@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 the `patch` event emits for the action, 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. Registering or unregistering a node with `editor.registerNode` after load can make the editor fix existing content, and those fixes are corrections. A host that saves every local change's `patches` saves corrections as well. A correction to received content arrives even when the editor drops it from `mutation` because a newer value or a conflicting remote patch arrived first. A key or unused mark definition the editor repairs while validating an `update value` is not part of any `change`, and still reaches the host only through `patch` and `mutation`. +- `origin: 'remote'`: one event per `patches` message and per `update value` that changed the content. Remote changes carry no `patches`. + +Every `change` of one action is delivered before any `change` that a listener sets off while receiving them. 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. A long initial value that the editor loads across several ticks counts as one load, so its fixes arrive as one `correction`. `ChangeEvent` is exported as a beta type. `EditorEmittedEvent` gains the `change` member, so exhaustive switches over `event.type` gain a case. + +When the DOM selection leaves a read-only editor's content, the editor now deselects through the `select` event with `at: null`, so Behaviors listening to `select` see it. diff --git a/apps/playground/src/changes-list.tsx b/apps/playground/src/changes-list.tsx new file mode 100644 index 0000000000..884b758b1d --- /dev/null +++ b/apps/playground/src/changes-list.tsx @@ -0,0 +1,134 @@ +import {useActorRef, useSelector} from '@xstate/react' +import {ChevronRightIcon, GitCommitHorizontalIcon} from 'lucide-react' +import {useState} from 'react' +import {tv} from 'tailwind-variants' +import {highlightMachine} from './highlight-json-machine' +import type {ChangeFeedEntry} from './playground-machine' + +const changeCardStyle = tv({ + base: 'rounded-md border transition-colors duration-150', + variants: { + isNew: { + true: 'bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800', + false: + 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700', + }, + }, +}) + +const badgeStyle = tv({ + base: 'px-1.5 py-0.5 rounded text-xs font-medium', + variants: { + kind: { + local: 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300', + remote: + 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300', + correction: + 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300', + editor: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300', + }, + }, +}) + +export function ChangesList(props: { + entries: Array + showEditorLabel: boolean +}) { + if (props.entries.length === 0) { + return ( +
+ +

+ No changes yet +

+

+ Start typing to see changes +

+
+ ) + } + + const newestTimestamp = props.entries[0]?.timestamp + + return ( +
+ {props.entries.map((entry) => ( + + ))} +
+ ) +} + +function ChangeCard(props: { + entry: ChangeFeedEntry + isNew: boolean + showEditorLabel: boolean +}) { + const {entry} = props + const [isExpanded, setIsExpanded] = useState(false) + const operationCount = entry.event.operations.length + + return ( +
+ + {isExpanded ? ( +
+ + {entry.event.origin === 'local' ? ( + + ) : null} +
+ ) : null} +
+ ) +} + +function HighlightedJson(props: {label: string; data: unknown}) { + const highlightRef = useActorRef(highlightMachine, { + input: {code: JSON.stringify(props.data), variant: 'ghost'}, + }) + const highlightedCode = useSelector( + highlightRef, + (s) => s.context.highlightedCode, + ) + + return ( +
+
+ {props.label} +
+
+
+ ) +} diff --git a/apps/playground/src/editor.tsx b/apps/playground/src/editor.tsx index 73df395942..9fcbcb7b39 100644 --- a/apps/playground/src/editor.tsx +++ b/apps/playground/src/editor.tsx @@ -315,6 +315,16 @@ function EditorEventListener(props: { } }, [props.editorRef, editor, patchSubscriptionActive]) + useEffect(() => { + const subscription = editor.on('change', (event) => { + props.editorRef.send(event) + }) + + return () => { + subscription.unsubscribe() + } + }, [editor, props.editorRef]) + useEffect(() => { const subscription = editor.on('*', props.on) diff --git a/apps/playground/src/inspector.tsx b/apps/playground/src/inspector.tsx index a02b66fb41..1425f90565 100644 --- a/apps/playground/src/inspector.tsx +++ b/apps/playground/src/inspector.tsx @@ -6,14 +6,18 @@ import {useActorRef, useSelector} from '@xstate/react' import { CheckIcon, CopyIcon, + GitCommitHorizontalIcon, HistoryIcon, LayersIcon, MessageSquareTextIcon, + PauseIcon, + PlayIcon, TrashIcon, } from 'lucide-react' import {useEffect, useState} from 'react' import {TooltipTrigger, type Key} from 'react-aria-components' import {tv} from 'tailwind-variants' +import {ChangesList} from './changes-list' import {highlightMachine} from './highlight-json-machine' import {MarkdownLogo, PortableTextLogo, ReactLogo} from './logos' import {PatchesList} from './patches-list' @@ -29,11 +33,13 @@ import {Button} from './primitives/button' import {Container} from './primitives/container' import {Spinner} from './primitives/spinner' import {Tab, TabList, TabPanel, Tabs} from './primitives/tabs' +import {ToggleButton} from './primitives/toggle-button' import {Tooltip} from './primitives/tooltip' type TabId = | 'output' | 'patches' + | 'changes' | 'react-preview' | 'markdown-preview' | 'comments' @@ -66,6 +72,12 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) { Patches + + + + Changes + + @@ -106,6 +118,12 @@ export function Inspector(props: {playgroundRef: PlaygroundActorRef}) { + + + + + + @@ -144,6 +162,12 @@ function TabActions(props: { const isCopyingPatches = useSelector(playgroundRef, (s) => s.matches({'copying patches': 'copied'}), ) + const isCopyingChanges = useSelector(playgroundRef, (s) => + s.matches({'copying changes': 'copied'}), + ) + const isRecordingChanges = useSelector(playgroundRef, (s) => + s.matches({'change recording': 'recording'}), + ) const isCopyingMarkdown = useSelector(playgroundRef, (s) => s.matches({'copying markdown': 'copied'}), ) @@ -198,6 +222,56 @@ function TabActions(props: { ) } + if (activeTab === 'changes') { + return ( +
+ + + playgroundRef.send({type: 'toggle change recording'}) + } + > + {isRecordingChanges ? ( + + ) : ( + + )} + + + {isRecordingChanges ? 'Pause changes' : 'Resume changes'} + + + + + {isCopyingChanges ? 'Copied!' : 'Copy changes'} + + + + Clear changes + +
+ ) + } + if (activeTab === 'markdown-preview') { return ( @@ -461,3 +535,16 @@ function PatchesPanel(props: {playgroundRef: PlaygroundActorRef}) { return 1} /> } + +function ChangesPanel(props: {playgroundRef: PlaygroundActorRef}) { + const changeFeed = useSelector( + props.playgroundRef, + (s) => s.context.changeFeed, + ) + const editorCount = useSelector( + props.playgroundRef, + (s) => s.context.editors.length, + ) + + return 1} /> +} diff --git a/apps/playground/src/playground-machine.ts b/apps/playground/src/playground-machine.ts index 28793eb90f..954d81cc0f 100644 --- a/apps/playground/src/playground-machine.ts +++ b/apps/playground/src/playground-machine.ts @@ -1,5 +1,6 @@ import { keyGenerator, + type ChangeEvent, type EditorSelection, type MutationEvent, type PatchesEvent, @@ -61,6 +62,7 @@ const editorMachine = setup({ featureFlags: EditorFeatureFlags }, events: {} as + | ChangeEvent | MutationEvent | PatchesEvent | {type: 'value'; value?: Array} @@ -118,6 +120,15 @@ const editorMachine = setup({ featureFlags: defaultEditorFeatureFlags, }), on: { + 'change': { + actions: [ + sendParent(({event, self}) => ({ + type: 'editor.change', + editorId: self.id, + event, + })), + ], + }, 'mutation': { actions: [ sendParent(({event, self}) => ({ @@ -246,6 +257,13 @@ export type GlobalPatchEntry = { timestamp: number } +export type ChangeFeedEntry = { + id: string + editorId: string + event: ChangeEvent + timestamp: number +} + export type PlaygroundActorRef = ActorRefFrom export const playgroundMachine = setup({ @@ -257,6 +275,7 @@ export const playgroundMachine = setup({ value: Array | undefined patchDerivedValue: Array | undefined patchFeed: Array + changeFeed: Array comments: Array selections: Record layers: Record< @@ -270,13 +289,21 @@ export const playgroundMachine = setup({ MutationEvent, 'type' >) + | { + type: 'editor.change' + editorId: EditorActorRef['id'] + event: ChangeEvent + } | {type: 'editor.remove'; editorId: EditorActorRef['id']} | {type: 'toggle value'} | {type: 'toggle patches'} | {type: 'toggle inspector'} | {type: 'clear patches'} + | {type: 'clear changes'} + | {type: 'toggle change recording'} | {type: 'copy value'} | {type: 'copy patches'} + | {type: 'copy changes'} | {type: 'copy markdown'} | { type: 'add comment' @@ -364,6 +391,23 @@ export const playgroundMachine = setup({ 'clear patch feed': assign({ patchFeed: [], }), + 'add to change feed': assign({ + changeFeed: ({context, event}) => { + assertEvent(event, 'editor.change') + return [ + { + id: keyGenerator(), + editorId: event.editorId, + event: event.event, + timestamp: Date.now(), + }, + ...context.changeFeed, + ] + }, + }), + 'clear change feed': assign({ + changeFeed: [], + }), 'broadcast value': ({context}) => { const value = context.value if (value !== null) { @@ -517,6 +561,7 @@ export const playgroundMachine = setup({ patchDerivedValue: undefined, editors: [], patchFeed: [], + changeFeed: [], comments: [], selections: {}, layers: {}, @@ -545,6 +590,9 @@ export const playgroundMachine = setup({ 'clear patches': { actions: ['clear patch feed'], }, + 'clear changes': { + actions: ['clear change feed'], + }, 'add comment': { actions: ['add comment'], @@ -599,6 +647,18 @@ export const playgroundMachine = setup({ hidden: {on: {'toggle patches': {target: 'shown'}}}, }, }, + 'change recording': { + initial: 'recording', + states: { + recording: { + on: { + 'editor.change': {actions: ['add to change feed']}, + 'toggle change recording': {target: 'paused'}, + }, + }, + paused: {on: {'toggle change recording': {target: 'recording'}}}, + }, + }, 'inspector visibility': { initial: 'shown', states: { @@ -666,6 +726,36 @@ export const playgroundMachine = setup({ }, }, }, + 'copying changes': { + initial: 'idle', + states: { + idle: { + on: { + 'copy changes': {target: 'copying'}, + }, + }, + copying: { + invoke: { + src: 'copy text to clipboard', + input: ({context}) => ({ + text: JSON.stringify(context.changeFeed, null, 2), + }), + onDone: { + target: 'copied', + }, + onError: { + target: 'idle', + actions: [({event}) => console.error(event)], + }, + }, + }, + copied: { + after: { + 2000: {target: 'idle'}, + }, + }, + }, + }, 'copying markdown': { initial: 'idle', states: { diff --git a/packages/editor/src/editor/change-events.ts b/packages/editor/src/editor/change-events.ts new file mode 100644 index 0000000000..a53c1581cc --- /dev/null +++ b/packages/editor/src/editor/change-events.ts @@ -0,0 +1,124 @@ +import type {Patch} from '@portabletext/patches' +import { + getChangeEntryKind, + type Change, + type ChangeEntry, +} from '../engine/core/change' +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' + +/** + * The `change` events for a committed change: its remote operations first, + * then its local operations in application order, each run of consecutive + * edits or consecutive corrections as its own event. + */ +export function toChangeEvents( + editor: PortableTextEditorEngine, + change: Change, +): Array { + const entries = change.entries + const events: Array = [] + + const remoteEntries = entries.filter( + (entry) => getChangeEntryKind(change, entry) === 'remote', + ) + const remoteOperations = remoteEntries.flatMap((entry) => + isReported(change, entry) ? [entry.event.operation] : [], + ) + const valueBeforeRemote = remoteEntries.at(0)?.event.beforeValue + + if ( + remoteOperations.length > 0 && + valueBeforeRemote !== undefined && + !isEqualValues( + editor.snapshot.context, + valueBeforeRemote, + editor.snapshot.context.value, + ) + ) { + events.push({ + type: 'change', + origin: 'remote', + operations: remoteOperations, + }) + } + + for (const run of localRuns(change)) { + events.push( + 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 events +} + +function localRuns(change: Change): Array<{ + kind: 'edit' | 'correction' + operations: Array + patches: Array +}> { + const runs: Array<{ + kind: 'edit' | 'correction' + operations: Array + patches: Array + }> = [] + + for (const entry of change.entries) { + const kind = getChangeEntryKind(change, entry) + + if (kind === 'remote' || !isReported(change, entry)) { + continue + } + + const operation = entry.event.operation + const lastRun = runs.at(-1) + + if (lastRun !== undefined && lastRun.kind === kind) { + lastRun.operations.push(operation) + lastRun.patches.push(...entry.patches) + continue + } + + runs.push({kind, operations: [operation], patches: [...entry.patches]}) + } + + return runs +} + +/** + * Selection changes, the empty-editor placeholder and the first load's + * remote operations are applied like any other operation but are not + * changes to report. A correction to the first load is. + */ +function isReported( + change: Change, + entry: ChangeEntry, +): entry is ChangeEntry & {event: {operation: Operation}} { + if ( + !isPublicOperation(entry.event.operation) || + entry.event.context.some((frame) => frame.kind === 'placeholder') + ) { + return false + } + + return !( + change.cause.type === 'value sync' && + change.cause.initial && + getChangeEntryKind(change, entry) === 'remote' + ) +} diff --git a/packages/editor/src/editor/create-editor.ts b/packages/editor/src/editor/create-editor.ts index 4dfbfdc39c..d59f87a51e 100644 --- a/packages/editor/src/editor/create-editor.ts +++ b/packages/editor/src/editor/create-editor.ts @@ -3,15 +3,16 @@ import {createActor} from 'xstate' import {coreConverters} from '../converters/converters.core' import type {Editor, EditorConfig} from '../editor' import {hasRemoteFrame} from '../engine/core/apply-context' +import {subscribeToChanges} from '../engine/core/change' 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 {toChangeEvents} from './change-events' import {createEditableAPI} from './create-editable-api' import {createEditorEngine} from './create-editor-engine' import {createEditorDom} from './editor-dom' @@ -198,28 +199,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 @@ -249,6 +228,12 @@ function createActors(config: { }, }) + config.subscriptions.push(() => + subscribeToChanges(config.editorEngine, (change) => { + config.relay.sendAll(toChangeEvents(config.editorEngine, change)) + }), + ) + 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..8f1f63a307 100644 --- a/packages/editor/src/editor/editor-machine.ts +++ b/packages/editor/src/editor/editor-machine.ts @@ -17,6 +17,7 @@ import type { } from '../behaviors/behavior.types.event' import type {Converter} from '../converters/converter.types' import {isInNormalization} from '../engine/core/apply-context' +import {withChange} from '../engine/core/change' import {DOMEditor} from '../engine/dom/plugin/dom-editor' import {normalize} from '../engine/editor/normalize' import {debug} from '../internal-utils/debug' @@ -244,7 +245,10 @@ export const editorMachine = setup({ context.pendingRegistrations.push(event.node) return } - registerNodeOnEngine(context.editorEngine, event.node) + const engine = context.editorEngine + withChange(engine, {type: 'registration'}, () => { + registerNodeOnEngine(engine, event.node) + }) }, 'unregister': ({context, event}) => { assertEvent(event, 'unregister') @@ -257,7 +261,10 @@ export const editorMachine = setup({ } return } - unregisterNodeOnEngine(context.editorEngine, event.node) + const engine = context.editorEngine + withChange(engine, {type: 'registration'}, () => { + unregisterNodeOnEngine(engine, event.node) + }) }, 'attach maps to editor engine': ({context}) => { // After the editor engine is constructed, drain any registrations @@ -268,11 +275,13 @@ export const editorMachine = setup({ return } const engine = context.editorEngine - for (const node of context.pendingRegistrations) { - registerNodeOnEngine(engine, node) - } - context.pendingRegistrations.length = 0 - normalize(engine, {force: true}) + withChange(engine, {type: 'registration'}, () => { + for (const node of context.pendingRegistrations) { + registerNodeOnEngine(engine, node) + } + context.pendingRegistrations.length = 0 + normalize(engine, {force: true}) + }) engine.onChange() }, 'emit patch event': emit(({event}) => { @@ -403,32 +412,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 - } + withChange(event.editor, {type: 'behavior event'}, () => + 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.test.ts b/packages/editor/src/editor/relay.test.ts index 8c1b5b1f34..aa39bca159 100644 --- a/packages/editor/src/editor/relay.test.ts +++ b/packages/editor/src/editor/relay.test.ts @@ -59,6 +59,27 @@ describe('relay', () => { expect(deliveries).toEqual(['ready-first', 'ready-second', 'editable']) }) + test('events sent during dispatch of a `sendAll` are queued after every event of it', () => { + const relay = createRelay() + relay.start() + const deliveries: Array = [] + + relay.on('ready', () => { + deliveries.push('ready') + relay.send({type: 'read only'}) + }) + relay.on('editable', () => { + deliveries.push('editable') + }) + relay.on('read only', () => { + deliveries.push('read only') + }) + + relay.sendAll([{type: 'ready'}, {type: 'editable'}]) + + expect(deliveries).toEqual(['ready', 'editable', 'read only']) + }) + test('deduplicates consecutive selection events carrying the same reference', () => { const relay = createRelay() relay.start() diff --git a/packages/editor/src/editor/relay.ts b/packages/editor/src/editor/relay.ts index 4aa4c29ecb..6cfc84df47 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,54 @@ 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 the `patch` event + * emits for the action, 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. A key or unused mark definition repaired + * while validating an `update value` is not part of any `change`; it + * reaches the host only through `patch` and `mutation`. + * - `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. + * + * Every `change` of one unit of work is delivered before any `change` + * a listener sets off while receiving them. + * + * `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 */ @@ -119,6 +168,8 @@ export type EditorEventListenerOptions = { * after type-specific ones. * - Events sent by a listener during dispatch are queued and dispatched once * the current event has been delivered to every listener. + * - Events passed to one `sendAll` are queued together, so an event a + * listener sends while they are delivered comes after all of them. * - Events sent before `start()` are buffered and dispatched on start; * events sent after `stop()` are dropped. * - Consecutive `selection` events carrying the same selection reference are @@ -128,6 +179,7 @@ export type EditorEventListenerOptions = { */ export type Relay = { send: (event: EditorEmittedEvent) => void + sendAll: (events: Array) => void on: { ( type: TType, @@ -312,18 +364,23 @@ export function createRelay(): Relay { } } - return { - send: (event) => { - if (status === 'stopped') { - return - } + function sendAll(events: Array) { + if (status === 'stopped') { + return + } - mailbox.push(event) + mailbox.push(...events) - if (status === 'started') { - drainMailbox() - } + if (status === 'started') { + drainMailbox() + } + } + + return { + send: (event) => { + sendAll([event]) }, + sendAll, on: on as Relay['on'], start: () => { status = 'started' diff --git a/packages/editor/src/editor/remote-patches.ts b/packages/editor/src/editor/remote-patches.ts index 0d93b83bc6..3d63157b72 100644 --- a/packages/editor/src/editor/remote-patches.ts +++ b/packages/editor/src/editor/remote-patches.ts @@ -2,6 +2,7 @@ import type {Patch} from '@portabletext/patches' 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' +import {withChange} from '../engine/core/change' import {normalize} from '../engine/editor/normalize' import {withoutNormalizing} from '../engine/editor/without-normalizing' import {createApplyPatch} from '../internal-utils/applyPatch' @@ -36,34 +37,36 @@ export function setupRemotePatches({ bufferedPatches = [] let changed = false - withRemoteChanges(editor, 'patches', () => { - withoutNormalizing(editor, () => { - withoutPatching(editor, () => { - pluginWithoutHistory(editor, () => { - for (const patch of patches) { - try { - changed = applyPatch(editor, patch) + withChange(editor, {type: 'remote patches'}, () => { + withRemoteChanges(editor, 'patches', () => { + withoutNormalizing(editor, () => { + withoutPatching(editor, () => { + pluginWithoutHistory(editor, () => { + for (const patch of patches) { + try { + changed = applyPatch(editor, patch) - if (debug.syncPatch.enabled) { - if (changed) { - debug.syncPatch(`(applied) ${safeStringify(patch, 2)}`) - } else { - debug.syncPatch(`(ignored) ${safeStringify(patch, 2)}`) + if (debug.syncPatch.enabled) { + if (changed) { + debug.syncPatch(`(applied) ${safeStringify(patch, 2)}`) + } else { + debug.syncPatch(`(ignored) ${safeStringify(patch, 2)}`) + } } + } catch (error) { + console.error( + `Applying patch ${safeStringify(patch)} failed due to: ${error instanceof Error ? error.message : error}`, + ) } - } catch (error) { - console.error( - `Applying patch ${safeStringify(patch)} failed due to: ${error instanceof Error ? error.message : error}`, - ) } - } + }) }) }) + if (changed) { + normalize(editor) + editor.onChange() + } }) - if (changed) { - normalize(editor) - editor.onChange() - } }) } diff --git a/packages/editor/src/editor/subscriber.patch-generation.ts b/packages/editor/src/editor/subscriber.patch-generation.ts index a39d5ebd86..7bd7887a58 100644 --- a/packages/editor/src/editor/subscriber.patch-generation.ts +++ b/packages/editor/src/editor/subscriber.patch-generation.ts @@ -1,3 +1,4 @@ +import {setChangeEntryPatches} from '../engine/core/change' import {subscribeToOperations} from '../engine/core/operation-channel' import {addFieldLifecyclePatches} from '../internal-utils/field-lifecycle-patches' import {operationToPatches} from '../internal-utils/operation-to-patches' @@ -5,8 +6,9 @@ import type {PortableTextEditorEngine} from '../types/editor-engine' import type {EditorActor} from './editor-machine' /** - * Converts every applied operation into `@portabletext/patches` and sends - * them to the editor actor as `internal.patch` events. + * Converts every applied operation into `@portabletext/patches`, sends them + * to the editor actor as `internal.patch` events and sets them on the + * change recording the operation. */ export function subscribePatchGeneration({ editorActor, @@ -46,10 +48,17 @@ export function subscribePatchGeneration({ editor.lastSyncedValue = state.lastSyncedValue editor.valueUnsetEmitted = state.valueUnsetEmitted - for (const patch of patches) { + const localPatches = patches.map((patch) => ({ + ...patch, + origin: 'local' as const, + })) + + setChangeEntryPatches(editor, event, localPatches) + + for (const patch of localPatches) { editorActor.send({ type: 'internal.patch', - patch: {...patch, origin: 'local'}, + patch, operationId: event.undoStepId, value: editor.snapshot.context.value, }) diff --git a/packages/editor/src/editor/sync-machine.ts b/packages/editor/src/editor/sync-machine.ts index afd9f999ad..725c3a7f57 100644 --- a/packages/editor/src/editor/sync-machine.ts +++ b/packages/editor/src/editor/sync-machine.ts @@ -20,6 +20,12 @@ import { hasRemoteFrame, type ApplyContextFrame, } from '../engine/core/apply-context' +import { + commitChange, + createChange, + resumeChange, + type Change, +} from '../engine/core/change' import {start} from '../engine/editor/start' import {withoutNormalizing} from '../engine/editor/without-normalizing' import type {Node} from '../engine/interfaces/node' @@ -77,12 +83,35 @@ 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. A streamed first load syncs its + // blocks across ticks, so the change is resumed for each tick's work and + // commits once the sync is done. + const change = createChange({ + type: 'value sync', + initial: input.streamBlocks, + }) + + resumeChange(input.editorEngine, change, () => + updateValue({ + context: input.context, + sendBack: (event) => { + // Committed before `done syncing` is announced: that ends the + // editor's setup, drains the remote patches buffered during the + // load and starts a pending value's sync, all of which must come + // after this change's events. + if (event.type === 'done syncing') { + commitChange(input.editorEngine, change) + } + + sendBack(event) + }, + editorEngine: input.editorEngine, + change, + value: input.value, + streamBlocks: input.streamBlocks, + }), + ).finally(() => { + commitChange(input.editorEngine, change) }) } @@ -435,6 +464,7 @@ async function updateValue({ context, sendBack, editorEngine, + change, streamBlocks, value, }: { @@ -446,6 +476,7 @@ async function updateValue({ } sendBack: (event: SyncValueEvent) => void editorEngine: PortableTextEditorEngine + change: Change streamBlocks: boolean value: PortableTextBlock[] | undefined }) { @@ -486,15 +517,20 @@ async function updateValue({ ] of getStreamedBlocks({ value, })) { - const {blockChanged, blockValid} = syncBlock({ - context, - sendBack, - block: currentBlock, - index: currentBlockIndex, + const {blockChanged, blockValid} = resumeChange( editorEngine, - value, - remoteSource, - }) + change, + () => + syncBlock({ + context, + sendBack, + block: currentBlock, + index: currentBlockIndex, + editorEngine, + value, + remoteSource, + }), + ) isChanged = blockChanged || isChanged isValid = isValid && blockValid @@ -573,7 +609,9 @@ async function updateValue({ !editorEngine.snapshot.context.selection && editorEngine.snapshot.context.value.length > 0 ) { - applySelect(editorEngine, start(editorEngine, [])) + resumeChange(editorEngine, change, () => { + applySelect(editorEngine, start(editorEngine, [])) + }) editorEngine.onChange() } 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/apply-operation.test.ts b/packages/editor/src/engine/core/apply-operation.test.ts index 418168c286..3e5225681c 100644 --- a/packages/editor/src/engine/core/apply-operation.test.ts +++ b/packages/editor/src/engine/core/apply-operation.test.ts @@ -12,6 +12,7 @@ import {resolveContainers} from '../../schema/resolve-containers-batch' import type {PortableTextEditorEngine} from '../../types/editor-engine' import {createEditor} from '../create-editor' import {applyOperation} from './apply-operation' +import {inTestChange} from './change.test-utils' describe(applyOperation.name, () => { describe('set on a node property', () => { @@ -22,11 +23,13 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', alt: 'foo'}, ]) - editor.apply({ - type: 'set', - path: [{_key: imageKey}], - value: {_key: imageKey, _type: 'image', title: 'bar'}, - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}], + value: {_key: imageKey, _type: 'image', title: 'bar'}, + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', title: 'bar'}, @@ -40,8 +43,12 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', alt: 'foo'}, ]) - editor.apply({type: 'set', path: [{_key: imageKey}], value: 'bar'}) - editor.apply({type: 'set', path: [{_key: imageKey}], value: ['bar']}) + inTestChange(editor, () => + editor.apply({type: 'set', path: [{_key: imageKey}], value: 'bar'}), + ) + inTestChange(editor, () => + editor.apply({type: 'set', path: [{_key: imageKey}], value: ['bar']}), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', alt: 'foo'}, @@ -55,7 +62,13 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', alt: 'foo'}, ]) - editor.apply({type: 'set', path: [{_key: imageKey}, 'alt'], value: 'bar'}) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'alt'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', alt: 'bar'}, @@ -69,11 +82,13 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', asset: {ref: 'foo', title: 'baz'}}, ]) - editor.apply({ - type: 'set', - path: [{_key: imageKey}, 'asset', 'ref'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'asset', 'ref'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', asset: {ref: 'bar', title: 'baz'}}, @@ -91,11 +106,13 @@ describe(applyOperation.name, () => { }, ]) - editor.apply({ - type: 'set', - path: [{_key: imageKey}, 'asset', 'crop', 'top'], - value: 3, - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'asset', 'crop', 'top'], + value: 3, + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -115,7 +132,9 @@ describe(applyOperation.name, () => { {_key: otherImageKey, _type: 'image', asset: {ref: 'foo'}}, ]) - editor.apply({type: 'set', path: [1, 'asset', 'ref'], value: 'bar'}) + inTestChange(editor, () => + editor.apply({type: 'set', path: [1, 'asset', 'ref'], value: 'bar'}), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image'}, @@ -128,11 +147,13 @@ describe(applyOperation.name, () => { const imageKey = keyGenerator() const editor = createBareEditor([{_key: imageKey, _type: 'image'}]) - editor.apply({ - type: 'set', - path: [{_key: imageKey}, 'asset', 'crop', 'top'], - value: 3, - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'asset', 'crop', 'top'], + value: 3, + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', asset: {crop: {top: 3}}}, @@ -146,16 +167,20 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', asset: 'foo', meta: {crop: null}}, ]) - editor.apply({ - type: 'set', - path: [{_key: imageKey}, 'asset', 'ref'], - value: 'bar', - }) - editor.apply({ - type: 'set', - path: [{_key: imageKey}, 'meta', 'crop', 'top'], - value: 3, - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'asset', 'ref'], + value: 'bar', + }), + ) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'meta', 'crop', 'top'], + value: 3, + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -174,11 +199,13 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', meta: {tags: ['foo', 'bar']}}, ]) - editor.apply({ - type: 'set', - path: [{_key: imageKey}, 'meta', 'tags', 'baz'], - value: true, - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'meta', 'tags', 'baz'], + value: true, + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -200,11 +227,13 @@ describe(applyOperation.name, () => { textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), ]) - editor.apply({ - type: 'set', - path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toEqual([ textBlock({blockKey, spanKey, markDefKey, href: 'bar'}), @@ -218,11 +247,13 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', hotspots: [{x: 1}, {x: 2}]}, ]) - editor.apply({ - type: 'set', - path: [{_key: imageKey}, 'hotspots', 1, 'x'], - value: 3, - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: imageKey}, 'hotspots', 1, 'x'], + value: 3, + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', hotspots: [{x: 1}, {x: 3}]}, @@ -238,17 +269,19 @@ describe(applyOperation.name, () => { textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), ]) - editor.apply({ - type: 'set', - path: [ - {_key: blockKey}, - 'markDefs', - {_key: markDefKey}, - 'meta', - 'title', - ], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [ + {_key: blockKey}, + 'markDefs', + {_key: markDefKey}, + 'meta', + 'title', + ], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toEqual([ textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), @@ -266,11 +299,13 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - editor.apply({ - type: 'set', - path: ['markDefs', {_key: markDefKey}, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: ['markDefs', {_key: markDefKey}, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -290,11 +325,13 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - editor.apply({ - type: 'set', - path: [{_key: missingKey}, 'markDefs', {_key: markDefKey}, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: missingKey}, 'markDefs', {_key: markDefKey}, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -313,11 +350,13 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - editor.apply({ - type: 'set', - path: [1, 'markDefs', 0, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [1, 'markDefs', 0, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -336,11 +375,13 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - editor.apply({ - type: 'set', - path: [-2, 'markDefs', 0, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [-2, 'markDefs', 0, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -357,7 +398,9 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', alt: 'foo', title: 'bar'}, ]) - editor.apply({type: 'unset', path: [{_key: imageKey}, 'alt']}) + inTestChange(editor, () => + editor.apply({type: 'unset', path: [{_key: imageKey}, 'alt']}), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', title: 'bar'}, @@ -371,7 +414,9 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', asset: {ref: 'foo', title: 'baz'}}, ]) - editor.apply({type: 'unset', path: [{_key: imageKey}, 'asset', 'ref']}) + inTestChange(editor, () => + editor.apply({type: 'unset', path: [{_key: imageKey}, 'asset', 'ref']}), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', asset: {title: 'baz'}}, @@ -389,10 +434,12 @@ describe(applyOperation.name, () => { }, ]) - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'asset', 'crop', 'top'], - }) + inTestChange(editor, () => + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'asset', 'crop', 'top'], + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', asset: {ref: 'foo', crop: {left: 2}}}, @@ -408,7 +455,9 @@ describe(applyOperation.name, () => { {_key: otherImageKey, _type: 'image', asset: {ref: 'foo'}}, ]) - editor.apply({type: 'unset', path: [1, 'asset', 'ref']}) + inTestChange(editor, () => + editor.apply({type: 'unset', path: [1, 'asset', 'ref']}), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', asset: {ref: 'foo'}}, @@ -423,11 +472,15 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', asset: {ref: 'foo'}}, ]) - editor.apply({type: 'unset', path: [{_key: imageKey}, 'alt']}) - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'asset', 'crop', 'top'], - }) + inTestChange(editor, () => + editor.apply({type: 'unset', path: [{_key: imageKey}, 'alt']}), + ) + inTestChange(editor, () => + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'asset', 'crop', 'top'], + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', asset: {ref: 'foo'}}, @@ -441,25 +494,27 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', asset: {ref: 'foo', crop: 'bar'}}, ]) - withRemoteChanges(editor, 'patches', () => { - editor.apply({type: 'unset', path: [{_key: imageKey}, 'alt']}) - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'asset', 'title'], - }) - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'meta', 'title'], - }) - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'asset', 'crop', 'top'], - }) - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'asset', 'meta', 'title'], - }) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply({type: 'unset', path: [{_key: imageKey}, 'alt']}) + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'asset', 'title'], + }) + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'meta', 'title'], + }) + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'asset', 'crop', 'top'], + }) + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'asset', 'meta', 'title'], + }) + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', asset: {ref: 'foo', crop: 'bar'}}, @@ -473,12 +528,14 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', meta: {tags: ['foo', 'bar']}}, ]) - withRemoteChanges(editor, 'patches', () => { - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'meta', 'tags', 'baz'], - }) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'meta', 'tags', 'baz'], + }) + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', meta: {tags: {0: 'foo', 1: 'bar'}}}, @@ -496,10 +553,12 @@ describe(applyOperation.name, () => { textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), ]) - editor.apply({ - type: 'unset', - path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], - }) + inTestChange(editor, () => + editor.apply({ + type: 'unset', + path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -516,10 +575,12 @@ describe(applyOperation.name, () => { {_key: imageKey, _type: 'image', hotspots: [{x: 1}, {x: 2, y: 3}]}, ]) - editor.apply({ - type: 'unset', - path: [{_key: imageKey}, 'hotspots', 1, 'x'], - }) + inTestChange(editor, () => + editor.apply({ + type: 'unset', + path: [{_key: imageKey}, 'hotspots', 1, 'x'], + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image', hotspots: [{x: 1}, {y: 3}]}, @@ -537,12 +598,14 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - withRemoteChanges(editor, 'patches', () => { - editor.apply({ - type: 'unset', - path: ['markDefs', {_key: markDefKey}, 'href'], - }) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply({ + type: 'unset', + path: ['markDefs', {_key: markDefKey}, 'href'], + }) + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -562,12 +625,14 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - withRemoteChanges(editor, 'patches', () => { - editor.apply({ - type: 'unset', - path: [{_key: missingKey}, 'markDefs', {_key: markDefKey}, 'href'], - }) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply({ + type: 'unset', + path: [{_key: missingKey}, 'markDefs', {_key: markDefKey}, 'href'], + }) + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -586,9 +651,11 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - withRemoteChanges(editor, 'patches', () => { - editor.apply({type: 'unset', path: [-2, 'markDefs', 0, 'href']}) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply({type: 'unset', path: [-2, 'markDefs', 0, 'href']}) + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -618,12 +685,14 @@ describe(applyOperation.name, () => { }, ]) - editor.apply({ - type: 'insert', - path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}], - position: 'after', - node: {_key: newMarkDefKey, _type: 'link', href: 'bar'}, - }) + inTestChange(editor, () => + editor.apply({ + type: 'insert', + path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}], + position: 'after', + node: {_key: newMarkDefKey, _type: 'link', href: 'bar'}, + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -665,11 +734,13 @@ describe(applyOperation.name, () => { }, ]) - editor.apply({ - type: 'set', - path: [{_key: blockKey}, 'children', {_key: spanKey}, 'marks', 1], - value: 'strong', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: blockKey}, 'children', {_key: spanKey}, 'marks', 1], + value: 'strong', + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -695,10 +766,12 @@ describe(applyOperation.name, () => { textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), ]) - editor.apply({ - type: 'unset', - path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}], - }) + inTestChange(editor, () => + editor.apply({ + type: 'unset', + path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}], + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -719,7 +792,9 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - editor.apply({type: 'unset', path: ['markDefs', {_key: markDefKey}]}) + inTestChange(editor, () => + editor.apply({type: 'unset', path: ['markDefs', {_key: markDefKey}]}), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -739,10 +814,12 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - editor.apply({ - type: 'unset', - path: [{_key: missingKey}, 'markDefs', {_key: markDefKey}], - }) + inTestChange(editor, () => + editor.apply({ + type: 'unset', + path: [{_key: missingKey}, 'markDefs', {_key: markDefKey}], + }), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -761,7 +838,9 @@ describe(applyOperation.name, () => { const value = editor.snapshot.context.value - editor.apply({type: 'unset', path: [-2, 'markDefs', 0]}) + inTestChange(editor, () => + editor.apply({type: 'unset', path: [-2, 'markDefs', 0]}), + ) expect(editor.snapshot.context.value).toBe(value) expect(editor.snapshot.context.value).toEqual([ @@ -781,12 +860,14 @@ describe(applyOperation.name, () => { textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), ]) - editor.apply({ - type: 'insert', - path: [{_key: blockKey}, 'children', {_key: spanKey}], - position: 'before', - node: {_key: newSpanKey, _type: 'span', text: 'bar', marks: []}, - }) + inTestChange(editor, () => + editor.apply({ + type: 'insert', + path: [{_key: blockKey}, 'children', {_key: spanKey}], + position: 'before', + node: {_key: newSpanKey, _type: 'span', text: 'bar', marks: []}, + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -819,12 +900,14 @@ describe(applyOperation.name, () => { 0, ) - withRemoteChanges(editor, 'patches', () => { - editor.apply({ - type: 'unset', - path: [{_key: blockKey}, 'children', {_key: spanKey}], - }) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply({ + type: 'unset', + path: [{_key: blockKey}, 'children', {_key: spanKey}], + }) + }), + ) expect(editor.snapshot.context.value).toEqual([ { @@ -848,12 +931,14 @@ describe(applyOperation.name, () => { ]) expect(() => - editor.apply({ - type: 'insert', - path: [{_key: blockKey}, 'children', {_key: missingKey}], - position: 'after', - node: {_key: newSpanKey, _type: 'span', text: 'bar', marks: []}, - }), + inTestChange(editor, () => + editor.apply({ + type: 'insert', + path: [{_key: blockKey}, 'children', {_key: missingKey}], + position: 'after', + node: {_key: newSpanKey, _type: 'span', text: 'bar', marks: []}, + }), + ), ).toThrow( `Cannot apply an "insert" operation at path [_key=="k0"].children[_key=="k3"] because the sibling was not found.`, ) @@ -873,10 +958,12 @@ describe(applyOperation.name, () => { ]) expect(() => - editor.apply({ - type: 'unset', - path: [{_key: blockKey}, 'children', {_key: missingKey}], - }), + inTestChange(editor, () => + editor.apply({ + type: 'unset', + path: [{_key: blockKey}, 'children', {_key: missingKey}], + }), + ), ).toThrow( `Cannot apply an "unset" (node removal) operation at path [_key=="k0"].children[_key=="k3"] because the node was not found.`, ) @@ -898,11 +985,13 @@ describe(applyOperation.name, () => { textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), ]) - editor.apply({ - type: 'set', - path: [1, 'markDefs', 0, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [1, 'markDefs', 0, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image'}, @@ -921,11 +1010,13 @@ describe(applyOperation.name, () => { textBlock({blockKey, spanKey, markDefKey, href: 'foo'}), ]) - editor.apply({ - type: 'set', - path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image'}, @@ -945,11 +1036,13 @@ describe(applyOperation.name, () => { ]) editor.blockIndexMap.set(serializePath([{_key: blockKey}]), 0) - editor.apply({ - type: 'set', - path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], - value: 'bar', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: blockKey}, 'markDefs', {_key: markDefKey}, 'href'], + value: 'bar', + }), + ) expect(editor.snapshot.context.value).toEqual([ {_key: imageKey, _type: 'image'}, diff --git a/packages/editor/src/engine/core/apply.ts b/packages/editor/src/engine/core/apply.ts index 4f38715eb9..34ffd872b9 100644 --- a/packages/editor/src/engine/core/apply.ts +++ b/packages/editor/src/engine/core/apply.ts @@ -12,19 +12,17 @@ import {transformRangeRef} from '../range-ref/transform-range-ref' import {isCollapsedRange} from '../range/is-collapsed-range' import type {WithEditorFirstArg} from '../utils/types' import {applyOperation} from './apply-operation' +import {assertChangeOpen, getChangePosition, recordApply} from './change' import {createOperationEvent, emitOperationEvent} from './operation-channel' import {updateDirtyPaths} from './update-dirty-paths' export const apply: WithEditorFirstArg = (editor, op) => { - const beforeListeners = editor.operationListeners.before - const operationEvent = - beforeListeners.length > 0 || editor.operationListeners.after.length > 0 - ? createOperationEvent(editor, op) - : undefined - - if (operationEvent) { - emitOperationEvent(beforeListeners, operationEvent) - } + assertChangeOpen(editor, op) + + const operationEvent = createOperationEvent(editor, op) + const changePosition = getChangePosition(editor) + + emitOperationEvent(editor.operationListeners.before, operationEvent) for (const ref of editor.pathRefs) { PathRef.transform(ref, op) @@ -142,9 +140,8 @@ export const apply: WithEditorFirstArg = (editor, op) => { }) } - if (operationEvent) { - // Emitted last so that, when normalization fixes re-enter `apply`, a fix - // operation's `after` listeners run before the triggering operation's. - emitOperationEvent(editor.operationListeners.after, operationEvent) - } + recordApply(editor, operationEvent, changePosition) + // Emitted last so that, when normalization fixes re-enter `apply`, a fix + // operation's `after` listeners run before the triggering operation's. + emitOperationEvent(editor.operationListeners.after, operationEvent) } diff --git a/packages/editor/src/engine/core/change.test-utils.ts b/packages/editor/src/engine/core/change.test-utils.ts new file mode 100644 index 0000000000..7f24e3d128 --- /dev/null +++ b/packages/editor/src/engine/core/change.test-utils.ts @@ -0,0 +1,11 @@ +import type {Editor} from '../interfaces/editor' +import {withChange} from './change' + +/** + * Opens a change for unit tests that drive a bare engine directly, where no + * entry point exists to open one. The cause is a Behavior event, so what + * `fn` applies is classified like a user action. + */ +export function inTestChange(editor: Editor, fn: () => void): void { + withChange(editor, {type: 'behavior event'}, fn) +} diff --git a/packages/editor/src/engine/core/change.test.ts b/packages/editor/src/engine/core/change.test.ts new file mode 100644 index 0000000000..b7c374f9cf --- /dev/null +++ b/packages/editor/src/engine/core/change.test.ts @@ -0,0 +1,325 @@ +import { + compileSchema, + defineSchema, + type PortableTextBlock, +} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import type {PortableTextEditorEngine} from '../../types/editor-engine' +import {createEditor} from '../create-editor' +import type {EngineOperation} from '../interfaces/operation' +import { + commitChange, + createChange, + resumeChange, + subscribeToChanges, + withChange, + type ChangeCause, +} from './change' +import {subscribeToOperations} from './operation-channel' + +describe(withChange.name, () => { + test('an apply whose `before` listener throws does not stop later changes', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEngine(keyGenerator, [ + block(blockKey, spanKey, 'foo'), + ]) + const committed: Array> = [] + subscribeToChanges(editor, (change) => { + committed.push(change.entries.map((entry) => entry.event.operation)) + }) + let throwOnce = true + subscribeToOperations( + editor, + () => { + if (throwOnce) { + throwOnce = false + throw new Error('boom') + } + }, + {phase: 'before'}, + ) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + + expect(() => + withChange(editor, {type: 'behavior event'}, () => { + editor.apply({ + type: 'insert.text', + path: spanPath, + offset: 3, + text: '!', + }) + }), + ).toThrow('boom') + withChange(editor, {type: 'behavior event'}, () => { + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '?'}) + }) + + expect(editor.change).toEqual(undefined) + expect(committed).toEqual([ + [{type: 'insert.text', path: spanPath, offset: 3, text: '?'}], + ]) + }) + + test('a change opened while a change commits is a new change', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEngine(keyGenerator, [ + block(blockKey, spanKey, 'foo'), + ]) + const committed: Array> = [] + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + let reentered = false + subscribeToChanges(editor, (change) => { + committed.push(change.entries.map((entry) => entry.event.operation)) + if (!reentered) { + reentered = true + withChange(editor, {type: 'behavior event'}, () => { + editor.apply({ + type: 'insert.text', + path: spanPath, + offset: 4, + text: '?', + }) + }) + } + }) + + withChange(editor, {type: 'behavior event'}, () => { + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}) + }) + + expect(committed).toEqual([ + [{type: 'insert.text', path: spanPath, offset: 3, text: '!'}], + [{type: 'insert.text', path: spanPath, offset: 4, text: '?'}], + ]) + }) + + test('an apply outside a change throws', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEngine(keyGenerator, [ + block(blockKey, spanKey, 'foo'), + ]) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + + expect(() => + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}), + ).toThrow( + '`insert.text` was applied outside a change. Only an entry point (a Behavior event, remote patches, a value sync or a registration) may open one.', + ) + }) + + test('a change for another cause cannot open inside an open change', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEngine(keyGenerator, [ + block(blockKey, spanKey, 'foo'), + ]) + + expect(() => + withChange(editor, {type: 'behavior event'}, () => { + withChange(editor, {type: 'remote patches'}, () => {}) + }), + ).toThrow( + 'A `remote patches` change was opened while a `behavior event` change is open', + ) + }) +}) + +describe(resumeChange.name, () => { + test('a change resumed across ticks commits once', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEngine(keyGenerator, [ + block(blockKey, spanKey, 'foo'), + ]) + const committed: Array> = [] + subscribeToChanges(editor, (change) => { + committed.push(change.entries.map((entry) => entry.event.operation)) + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + const change = createChange({type: 'value sync', initial: true}) + + resumeChange(editor, change, () => { + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}) + }) + await Promise.resolve() + resumeChange(editor, change, () => { + editor.apply({type: 'insert.text', path: spanPath, offset: 4, text: '?'}) + }) + + expect(editor.change).toEqual(undefined) + expect(committed).toEqual([]) + + commitChange(editor, change) + commitChange(editor, change) + + expect(committed).toEqual([ + [ + {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + {type: 'insert.text', path: spanPath, offset: 4, text: '?'}, + ], + ]) + }) + + test('a change resumed while another change is open publishes what the open change applied first and keeps it open', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEngine(keyGenerator, [ + block(blockKey, spanKey, 'foo'), + ]) + const committed: Array<{ + cause: ChangeCause + operations: Array + }> = [] + subscribeToChanges(editor, (change) => { + committed.push({ + cause: change.cause, + operations: change.entries.map((entry) => entry.event.operation), + }) + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + const sync = createChange({type: 'value sync', initial: false}) + + withChange(editor, {type: 'behavior event'}, () => { + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}) + resumeChange(editor, sync, () => { + editor.apply({ + type: 'insert.text', + path: spanPath, + offset: 4, + text: '?', + }) + }) + commitChange(editor, sync) + editor.apply({type: 'insert.text', path: spanPath, offset: 5, text: '.'}) + }) + + expect(editor.change).toEqual(undefined) + expect(committed).toEqual([ + { + cause: {type: 'behavior event'}, + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + ], + }, + { + cause: {type: 'value sync', initial: false}, + operations: [ + {type: 'insert.text', path: spanPath, offset: 4, text: '?'}, + ], + }, + { + cause: {type: 'behavior event'}, + operations: [ + {type: 'insert.text', path: spanPath, offset: 5, text: '.'}, + ], + }, + ]) + }) + + test('a change listener that throws while an interrupted change publishes leaves the interrupted change open', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEngine(keyGenerator, [ + block(blockKey, spanKey, 'foo'), + ]) + let throwOnce = true + subscribeToChanges(editor, () => { + if (throwOnce) { + throwOnce = false + throw new Error('boom') + } + }) + const committed: Array<{ + cause: ChangeCause + operations: Array + }> = [] + subscribeToChanges(editor, (change) => { + committed.push({ + cause: change.cause, + operations: change.entries.map((entry) => entry.event.operation), + }) + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + const sync = createChange({type: 'value sync', initial: false}) + let resumed = false + + withChange(editor, {type: 'behavior event'}, () => { + editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}) + const interrupted = editor.change + + expect(() => + resumeChange(editor, sync, () => { + resumed = true + }), + ).toThrow('boom') + expect(editor.change).toBe(interrupted) + + editor.apply({type: 'insert.text', path: spanPath, offset: 4, text: '.'}) + }) + + expect(resumed).toEqual(false) + expect(editor.change).toEqual(undefined) + expect(committed).toEqual([ + { + cause: {type: 'behavior event'}, + operations: [ + {type: 'insert.text', path: spanPath, offset: 4, text: '.'}, + ], + }, + ]) + }) +}) + +function createBareEngine( + keyGenerator: () => string, + 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, + 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/engine/core/change.ts b/packages/editor/src/engine/core/change.ts new file mode 100644 index 0000000000..deace9bd16 --- /dev/null +++ b/packages/editor/src/engine/core/change.ts @@ -0,0 +1,261 @@ +import type {Patch} from '@portabletext/patches' +import type {Editor} from '../interfaces/editor' +import {isInNormalization} from './apply-context' +import type {OperationEvent} from './operation-channel' + +/** + * One applied operation in a {@link Change}. + */ +export type ChangeEntry = { + event: OperationEvent + /** + * The local patches for the operation, set by patch generation as the + * apply completes. + */ + patches: Array +} + +/** + * Why a change was opened. Only the editor's entry points open a change, + * and each names its cause: a Behavior event (a user action), remote + * patches, a value sync (the first load or an updated value), or a + * registration that reshapes existing content. + */ +export type ChangeCause = + | {type: 'behavior event'} + | {type: 'remote patches'} + | {type: 'value sync'; initial: boolean} + | {type: 'registration'} + +/** + * One unit of work: every operation the engine applied for one cause, + * normalization fixes included, in application order. The change commits + * exactly once. + * + * Operation listeners see every operation live, as it applies; the change + * only marks where the unit of work begins and ends. + */ +export type Change = { + cause: ChangeCause + entries: Array + entryByEvent: Map + committed: boolean +} + +export type ChangeListener = (change: Change) => void + +/** + * `remote`: content that arrived from outside. `correction`: a + * normalization fix made while patching, which is local content the host + * has to save. `edit`: everything else. + */ +export type ChangeEntryKind = 'edit' | 'correction' | 'remote' + +export function getChangeEntryKind( + change: Change, + entry: ChangeEntry, +): ChangeEntryKind { + switch (change.cause.type) { + case 'remote patches': + case 'value sync': + // Remote content is applied without patching; what the editor then + // applies while patching is its own fix of that content. + return entry.event.isPatching ? 'correction' : 'remote' + case 'registration': + return entry.event.isPatching ? 'correction' : 'edit' + case 'behavior event': + // A user action interleaves its own operations with the fixes they + // trigger, and only the frame tells them apart. + return isInNormalization(entry.event.context) && entry.event.isPatching + ? 'correction' + : 'edit' + } +} + +/** + * Runs `fn` inside a new change for `cause` and commits it when `fn` + * returns or throws. An entry point reached while a change with the same + * cause type is open joins it, and one with another cause type is reported + * like an apply outside a change. + */ +export function withChange( + editor: Editor, + cause: ChangeCause, + fn: () => void, +): void { + if (editor.change !== undefined) { + if (editor.change.cause.type !== cause.type) { + reportChangeError( + `A \`${cause.type}\` change was opened while a \`${editor.change.cause.type}\` change is open`, + ) + } + fn() + return + } + + const change = createChange(cause) + + try { + resumeChange(editor, change, fn) + } finally { + commitChange(editor, change) + } +} + +export function createChange(cause: ChangeCause): Change { + return {cause, entries: [], entryByEvent: new Map(), committed: false} +} + +/** + * Runs `fn` inside `change`, which may span several ticks: a value sync + * that streams its blocks resumes its change once per tick and commits it + * when the sync is done. A change open when `fn` starts is set aside until + * `fn` returns. + */ +export function resumeChange( + editor: Editor, + change: Change, + fn: () => Result, +): Result { + if (editor.change === change) { + return fn() + } + + const interrupted = editor.change + + try { + if (interrupted !== undefined) { + // A value sync runs on its own actor, so a Behavior effect can start + // one through the public `editor.send` while the Behavior's change is + // open. What the interrupted change applied so far is published + // first, so the sync's operations neither join it nor precede it. + editor.change = undefined + publishEntries(editor, interrupted) + } + + editor.change = change + + return fn() + } finally { + // `fn` may have committed `change`, which detaches it. + if (editor.change === change || editor.change === undefined) { + editor.change = interrupted + } + } +} + +export function commitChange(editor: Editor, change: Change): void { + if (change.committed) { + return + } + + change.committed = true + + // Detached before committing: a listener that applies again needs a + // cause of its own instead of adding to a change already committed. + if (editor.change === change) { + editor.change = undefined + } + + // Only applies that complete are recorded, so a change whose applies + // all threw changed nothing. + if (change.entries.length > 0) { + for (const listener of editor.changeListeners) { + listener(change) + } + } +} + +/** + * Publishes the entries `change` has recorded so far as a committed change + * of the same cause and leaves `change` open and empty. + */ +function publishEntries(editor: Editor, change: Change): void { + if (change.entries.length === 0) { + return + } + + const published: Change = {...change, committed: true} + change.entries = [] + change.entryByEvent = new Map() + + for (const listener of editor.changeListeners) { + listener(published) + } +} + +/** + * Every apply needs a cause. An apply outside a change throws under test + * and is logged in development; in production it applies unrecorded. + */ +export function assertChangeOpen(editor: Editor, operation: {type: string}) { + if (editor.change === undefined) { + reportChangeError( + `\`${operation.type}\` was applied outside a change. Only an entry point (a Behavior event, remote patches, a value sync or a registration) may open one.`, + ) + } +} + +function reportChangeError(message: string): void { + // @ts-expect-error - dot notation required for Vite to replace at build time + if (process.env.NODE_ENV === 'test') { + throw new Error(message) + } + + // @ts-expect-error - dot notation required for Vite to replace at build time + if (process.env.NODE_ENV !== 'production') { + console.error(new Error(message)) + } +} + +/** + * Where an operation starting now lands in the open change. A + * normalization fix re-enters `apply`, so it starts after but completes + * before the operation that triggered it: entries are inserted at their + * start position once their apply completes, which puts the trigger ahead + * of its fixes, and an apply that throws is never inserted. + */ +export function getChangePosition(editor: Editor): number | undefined { + return editor.change?.entries.length +} + +export function recordApply( + editor: Editor, + event: OperationEvent, + position: number | undefined, +): void { + const change = editor.change + + if (change === undefined || position === undefined) { + return + } + + const entry: ChangeEntry = {event, patches: []} + change.entries.splice(position, 0, entry) + change.entryByEvent.set(event, entry) +} + +export function setChangeEntryPatches( + editor: Editor, + event: OperationEvent, + patches: Array, +): void { + const entry = editor.change?.entryByEvent.get(event) + + if (entry !== undefined) { + entry.patches = patches + } +} + +export function subscribeToChanges( + editor: Editor, + listener: ChangeListener, +): () => void { + editor.changeListeners = [...editor.changeListeners, listener] + + return () => { + editor.changeListeners = editor.changeListeners.filter( + (changeListener) => changeListener !== listener, + ) + } +} diff --git a/packages/editor/src/engine/core/deselect.ts b/packages/editor/src/engine/core/deselect.ts deleted file mode 100644 index a47eec27a0..0000000000 --- a/packages/editor/src/engine/core/deselect.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type {Editor} from '../interfaces/editor' - -export function deselect(editor: Editor): void { - const selection = editor.snapshot.context.selection - - if (selection) { - editor.apply({ - type: 'set.selection', - properties: selection, - newProperties: null, - }) - } -} diff --git a/packages/editor/src/engine/core/normalize-node.ts b/packages/editor/src/engine/core/normalize-node.ts index 5a3481b12f..d9a402c73a 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 2a0c15cfee..7770e52fd8 100644 --- a/packages/editor/src/engine/core/operation-channel.test.ts +++ b/packages/editor/src/engine/core/operation-channel.test.ts @@ -22,6 +22,7 @@ import {createEditor} from '../create-editor' import type {Editor} from '../interfaces/editor' import type {EngineOperation} from '../interfaces/operation' import {isInNormalization} from './apply-context' +import {inTestChange} from './change.test-utils' import {subscribeToOperations, type OperationEvent} from './operation-channel' const schema = compileSchema(defineSchema({})) @@ -108,7 +109,7 @@ describe('operation channel', () => { }) }) - editor.apply(insertTextOperation) + inTestChange(editor, () => editor.apply(insertTextOperation)) expect( observations.map(({phase, liveText, beforeText}) => ({ @@ -139,7 +140,7 @@ describe('operation channel', () => { callOrder.push('third') }) - editor.apply(insertTextOperation) + inTestChange(editor, () => editor.apply(insertTextOperation)) expect(callOrder).toEqual(['first', 'second', 'third']) }) @@ -152,9 +153,11 @@ describe('operation channel', () => { callCount++ }) - editor.apply(insertTextOperation) + inTestChange(editor, () => editor.apply(insertTextOperation)) unsubscribe() - editor.apply({...insertTextOperation, offset: 0}) + inTestChange(editor, () => + editor.apply({...insertTextOperation, offset: 0}), + ) expect(callCount).toBe(1) }) @@ -167,8 +170,10 @@ describe('operation channel', () => { inProgressFlags.push(event.operationsInProgress) }) - editor.apply(insertTextOperation) - editor.apply({...insertTextOperation, offset: 0}) + inTestChange(editor, () => editor.apply(insertTextOperation)) + inTestChange(editor, () => + editor.apply({...insertTextOperation, offset: 0}), + ) expect(inProgressFlags).toEqual([false, true]) }) @@ -186,11 +191,13 @@ describe('operation channel', () => { focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 3}, } - editor.apply({ - type: 'set.selection', - properties: null, - newProperties: range, - }) + inTestChange(editor, () => + editor.apply({ + type: 'set.selection', + properties: null, + newProperties: range, + }), + ) expect(beforeSelections).toEqual([null]) expect(editor.snapshot.context.selection).toEqual({ @@ -265,7 +272,7 @@ describe('operation channel', () => { capturedEvent = capturedEvent ?? event }) - editor.apply(operationCase.operation) + inTestChange(editor, () => editor.apply(operationCase.operation)) expect( capturedEvent?.beforeValue, @@ -292,7 +299,9 @@ describe('operation channel', () => { capturedEvent = capturedEvent ?? event }) - editor.apply({...insertTextOperation, offset: 0}) + inTestChange(editor, () => + editor.apply({...insertTextOperation, offset: 0}), + ) expect(capturedEvent?.beforeSelection).toBe(preApplySelection) expect(editor.snapshot.context.selection).not.toBe(preApplySelection) @@ -308,15 +317,19 @@ describe('operation channel', () => { origins.push(event.origin) }) - editor.apply(insertTextOperation) + inTestChange(editor, () => editor.apply(insertTextOperation)) - withRemoteChanges(editor, 'patches', () => { - editor.apply({...insertTextOperation, offset: 0}) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply({...insertTextOperation, offset: 0}) + }), + ) - pluginUndoing(editor, () => { - editor.apply({...insertTextOperation, offset: 0}) - }) + inTestChange(editor, () => + pluginUndoing(editor, () => { + editor.apply({...insertTextOperation, offset: 0}) + }), + ) expect(origins).toEqual(['local', 'remote', 'undo']) }) @@ -344,11 +357,13 @@ describe('operation channel', () => { eventLog.push(`after:${event.operation.type}:${event.origin}`) }) - editor.apply({ - type: 'set', - path: [{_key: 'b1'}, 'style'], - value: 'h1', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: 'b1'}, 'style'], + value: 'h1', + }), + ) // The fix operation’s `after` event fires before the triggering // operation’s. @@ -394,11 +409,13 @@ describe('operation channel', () => { }) }) - editor.apply({ - type: 'set', - path: [{_key: 'b1'}, 'style'], - value: 'h1', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: 'b1'}, 'style'], + value: 'h1', + }), + ) expect(events).toEqual([ {origin: 'normalization', isInNormalization: true}, @@ -486,11 +503,13 @@ describe('operation channel', () => { editor.apply(insertTextOperation) } - editor.apply({ - type: 'set', - path: [{_key: 'b1'}, 'style'], - value: 'h1', - }) + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: 'b1'}, 'style'], + value: 'h1', + }), + ) const insertTextEvent = events.find( (event) => event.operationType === 'insert.text', @@ -527,17 +546,21 @@ describe('operation channel', () => { value, }) - editor.apply(setStyle('h1')) + inTestChange(editor, () => editor.apply(setStyle('h1'))) fixApplied = false - withRemoteChanges(editor, 'patches', () => { - editor.apply(setStyle('normal')) - }) + inTestChange(editor, () => + withRemoteChanges(editor, 'patches', () => { + editor.apply(setStyle('normal')) + }), + ) fixApplied = false - pluginUndoing(editor, () => { - editor.apply(setStyle('h1')) - }) + inTestChange(editor, () => + pluginUndoing(editor, () => { + editor.apply(setStyle('h1')) + }), + ) expect(events).toEqual([ {origin: 'normalization', context: [{kind: 'normalization'}]}, @@ -571,7 +594,7 @@ describe('operation channel', () => { expect(editor.applyContext).toEqual([]) - editor.apply(insertTextOperation) + inTestChange(editor, () => editor.apply(insertTextOperation)) expect(origins).toEqual(['local']) }) @@ -593,7 +616,7 @@ describe('operation channel', () => { expect(editor.isPatching).toEqual(true) - editor.apply(insertTextOperation) + inTestChange(editor, () => editor.apply(insertTextOperation)) expect(isPatchingFlags).toEqual([true]) }) @@ -615,7 +638,7 @@ describe('operation channel', () => { expect(editor.withHistory).toEqual(true) - editor.apply(insertTextOperation) + inTestChange(editor, () => editor.apply(insertTextOperation)) expect(withHistoryFlags).toEqual([true]) }) @@ -638,11 +661,13 @@ describe('operation channel', () => { } expect(() => - editor.apply({ - type: 'set', - path: [{_key: 'b1'}, 'style'], - value: 'h1', - }), + inTestChange(editor, () => + editor.apply({ + type: 'set', + path: [{_key: 'b1'}, 'style'], + value: 'h1', + }), + ), ).toThrow('boom') expect(editor.applyContext).toEqual([]) diff --git a/packages/editor/src/engine/create-editor.ts b/packages/editor/src/engine/create-editor.ts index e34117b4dd..8505e0dd47 100644 --- a/packages/editor/src/engine/create-editor.ts +++ b/packages/editor/src/engine/create-editor.ts @@ -22,6 +22,8 @@ export const createEditor = (): Editor => { applyContext: [], operations: [], operationListeners: {before: [], after: []}, + change: undefined, + changeListeners: [], marks: null, dirtyPaths: [], dirtyPathKeys: new Set(), diff --git a/packages/editor/src/engine/interfaces/editor.ts b/packages/editor/src/engine/interfaces/editor.ts index ae357d9f00..5f6e866b2f 100644 --- a/packages/editor/src/engine/interfaces/editor.ts +++ b/packages/editor/src/engine/interfaces/editor.ts @@ -1,4 +1,5 @@ import type {PortableTextEditorEngine} from '../../types/editor-engine' +import type {Change, ChangeListener} from '../core/change' import type {OperationListener} from '../core/operation-channel' import type {DOMEditor} from '../dom/plugin/dom-editor' import type {Location} from './location' @@ -22,6 +23,12 @@ export interface BaseEditor { before: Array after: Array } + /** + * The change recording the current unit of work, `undefined` between + * units. See `engine/core/change.ts`. + */ + change: Change | undefined + changeListeners: Array dirtyPaths: Path[] dirtyPathKeys: Set flushing: boolean diff --git a/packages/editor/src/engine/react/components/editable.tsx b/packages/editor/src/engine/react/components/editable.tsx index 6f46b4feb2..53998b6b3e 100644 --- a/packages/editor/src/engine/react/components/editable.tsx +++ b/packages/editor/src/engine/react/components/editable.tsx @@ -28,7 +28,6 @@ import {getText} from '../../../traversal/get-text' import {isLeafObject} from '../../../traversal/is-leaf-object' import type {PortableTextEditorEngine} from '../../../types/editor-engine' import {collapse} from '../../core/collapse' -import {deselect} from '../../core/deselect' import {move} from '../../core/move' import {subscribeToOperations} from '../../core/operation-channel' import {DOMEditor} from '../../dom/plugin/dom-editor' @@ -270,7 +269,7 @@ export const Editable = forwardRef( if (active) { document.execCommand('indent') } else { - deselect(editor) + deselect(editor, editorActor) } processing.current = false @@ -308,7 +307,7 @@ export const Editable = forwardRef( } if (!domSelection) { - return deselect(editor) + return deselect(editor, editorActor) } const {anchorNode, focusNode} = domSelection @@ -369,11 +368,11 @@ export const Editable = forwardRef( // Deselect the editor if the dom selection is not selectable in readonly mode if (readOnly && (!anchorNodeSelectable || !focusNodeInEditor)) { - deselect(editor) + deselect(editor, editorActor) } } }, 100), - [editor, readOnly, state], + [editor, editorActor, readOnly, state], ) const scheduleOnDOMSelectionChange = useMemo( @@ -2082,3 +2081,19 @@ const handleNativeHistoryEvents = ( return } } + +/** + * Deselects through a `select` Behavior event, like every other DOM + * selection sync, so the deselect is a user action with a change of its own. + */ +function deselect(editor: Editor, editorActor: EditorActor): void { + if (!editor.snapshot.context.selection) { + return + } + + editorActor.send({ + type: 'behavior event', + behaviorEvent: {type: 'select', at: null}, + editor, + }) +} 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/internal-utils/apply-node-properties.test.ts b/packages/editor/src/internal-utils/apply-node-properties.test.ts index 528518ebbb..4a0df7b40a 100644 --- a/packages/editor/src/internal-utils/apply-node-properties.test.ts +++ b/packages/editor/src/internal-utils/apply-node-properties.test.ts @@ -6,6 +6,7 @@ import { import {createTestKeyGenerator} from '@portabletext/test' import {expect, test} from 'vitest' import type {EditorSchema} from '../editor/editor-schema' +import {inTestChange} from '../engine/core/change.test-utils' import {createEditor} from '../engine/create-editor' import type {Editor} from '../engine/interfaces/editor' import {defineContainer, type Container} from '../renderers/renderer.types' @@ -44,11 +45,13 @@ test('throws when the path is structurally unreachable', () => { const editor = createBareEditor(schema, [], value) expect(() => - applyNodeProperties(editor, {_key: newKey}, [ - {_key: blockKey}, - 'markDefs', - {_key: markDefKey}, - ]), + inTestChange(editor, () => + applyNodeProperties(editor, {_key: newKey}, [ + {_key: blockKey}, + 'markDefs', + {_key: markDefKey}, + ]), + ), ).toThrow('Unable to apply properties at structurally unreachable path') }) @@ -70,7 +73,9 @@ test('skips silently when the node is missing', () => { const editor = createBareEditor(schema, [], value) expect(() => - applyNodeProperties(editor, {level: 1}, [{_key: 'nonexistent'}]), + inTestChange(editor, () => + applyNodeProperties(editor, {level: 1}, [{_key: 'nonexistent'}]), + ), ).not.toThrow() expect(editor.snapshot.context.value).toEqual([ { @@ -122,11 +127,13 @@ test('applies the change through a container whose array field is named markDefs ] const editor = createBareEditor(schema, publicContainers, value) - applyNodeProperties(editor, {title: 'baz'}, [ - {_key: containerKey}, - 'markDefs', - {_key: childKey}, - ]) + inTestChange(editor, () => + applyNodeProperties(editor, {title: 'baz'}, [ + {_key: containerKey}, + 'markDefs', + {_key: childKey}, + ]), + ) expect(editor.snapshot.context.value).toEqual([ { diff --git a/packages/editor/src/internal-utils/apply-split-node.test.ts b/packages/editor/src/internal-utils/apply-split-node.test.ts index 806a0a11f3..65612fd149 100644 --- a/packages/editor/src/internal-utils/apply-split-node.test.ts +++ b/packages/editor/src/internal-utils/apply-split-node.test.ts @@ -5,6 +5,7 @@ import { } from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test} from 'vitest' +import {inTestChange} from '../engine/core/change.test-utils' import {subscribeToOperations} from '../engine/core/operation-channel' import {createEditor} from '../engine/create-editor' import {pointRef} from '../engine/editor/point-ref' @@ -32,13 +33,19 @@ describe(applySplitNode.name, () => { ]) const operations: Array = [] - withoutNormalizing(editor, () => { - const unsubscribe = subscribeToOperations(editor, (event) => { - operations.push(event.operation) - }) - applySplitNode(editor, [{_key: blockKey}, 'children', {_key: spanKey}], 3) - unsubscribe() - }) + inTestChange(editor, () => + withoutNormalizing(editor, () => { + const unsubscribe = subscribeToOperations(editor, (event) => { + operations.push(event.operation) + }) + applySplitNode( + editor, + [{_key: blockKey}, 'children', {_key: spanKey}], + 3, + ) + unsubscribe() + }), + ) expect(operations).toEqual([ { @@ -75,13 +82,19 @@ describe(applySplitNode.name, () => { ]) const operations: Array = [] - withoutNormalizing(editor, () => { - const unsubscribe = subscribeToOperations(editor, (event) => { - operations.push(event.operation) - }) - applySplitNode(editor, [{_key: blockKey}, 'children', {_key: spanKey}], 3) - unsubscribe() - }) + inTestChange(editor, () => + withoutNormalizing(editor, () => { + const unsubscribe = subscribeToOperations(editor, (event) => { + operations.push(event.operation) + }) + applySplitNode( + editor, + [{_key: blockKey}, 'children', {_key: spanKey}], + 3, + ) + unsubscribe() + }), + ) expect(operations).toEqual([ { @@ -117,7 +130,7 @@ describe(applySplitNode.name, () => { const fooSpanPath = [{_key: blockKey}, 'children', {_key: fooSpanKey}] const ref = pointRef(editor, {path: fooSpanPath, offset: 3}) - applySplitNode(editor, fooSpanPath, 3) + inTestChange(editor, () => applySplitNode(editor, fooSpanPath, 3)) expect(ref.current).toEqual({ path: [{_key: blockKey}, 'children', {_key: 'k3'}], @@ -149,7 +162,7 @@ describe(applySplitNode.name, () => { focus: {path: barSpanPath, offset: 1}, }) - applySplitNode(editor, fooSpanPath, 3) + inTestChange(editor, () => applySplitNode(editor, fooSpanPath, 3)) expect(ref.current).toEqual({ anchor: {path: fooSpanPath, offset: 1}, @@ -182,7 +195,7 @@ describe(applySplitNode.name, () => { backward: false, } - applySplitNode(editor, fooSpanPath, 3) + inTestChange(editor, () => applySplitNode(editor, fooSpanPath, 3)) expect(editor.snapshot.context.selection).toEqual({ anchor: {path: fooSpanPath, offset: 1}, @@ -216,7 +229,7 @@ describe(applySplitNode.name, () => { backward: true, } - applySplitNode(editor, fooSpanPath, 3) + inTestChange(editor, () => applySplitNode(editor, fooSpanPath, 3)) expect(editor.snapshot.context.selection).toEqual({ anchor: {path: barSpanPath, offset: 1}, @@ -258,7 +271,7 @@ describe(applySplitNode.name, () => { {phase: 'before'}, ) - applySplitNode(editor, spanPath, 3) + inTestChange(editor, () => applySplitNode(editor, spanPath, 3)) expect(editor.snapshot.context.selection).toEqual({ anchor: {path: [{_key: blockKey}, 'children', {_key: 'k2'}], offset: 2}, 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..e36d6dd329 --- /dev/null +++ b/packages/editor/tests/event.change.test.tsx @@ -0,0 +1,1942 @@ +import { + applyAll, + diffMatchPatch, + insert, + set, + setIfMissing, + unset, +} from '@portabletext/patches' +import {defineSchema, type PortableTextBlock} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {useEffect} from 'react' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import type { + ChangeEvent, + Editor, + EditorEmittedEvent, + MutationEvent, +} from '../src' +import {effect, execute} from '../src/behaviors/behavior.types.action' +import {defineBehavior} from '../src/behaviors/behavior.types.behavior' +import {useEditor} from '../src/editor/use-editor' +import {subscribeToChanges, type ChangeCause} from '../src/engine/core/change' +import {useEngineStatic} from '../src/engine/react/hooks/use-engine-static' +import {safeStringify} from '../src/internal-utils/safe-json' +import {BehaviorPlugin} from '../src/plugins/plugin.behavior' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' +import {defineContainer} from '../src/renderers/renderer.types' +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, + inverse: {type: 'unset', path: [{_key: barBlock._key}]}, + }, + { + type: 'unset', + path: [{_key: blockKey}], + inverse: { + type: 'insert', + path: [{_key: barBlock._key}], + position: 'after', + node: block(blockKey, spanKey, ''), + }, + }, + { + type: 'insert', + path: [{_key: barBlock._key}], + position: 'after', + node: bazBlock, + inverse: {type: 'unset', path: [{_key: bazBlock._key}]}, + }, + ], + 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: [], + inverse: { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + }, + }, + { + type: 'insert', + path: spanPath, + position: 'after', + node: pastedSpan, + inverse: {type: 'unset', path: pastedSpanPath}, + }, + ], + 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, + inverse: { + type: 'insert', + path: spanPath, + position: 'after', + node: pastedSpan, + }, + }, + ], + 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, + inverse: { + type: 'insert', + path: fooSpanPath, + position: 'after', + node: { + _type: 'span', + _key: barSpanKey, + text: 'bar', + marks: ['strong'], + }, + }, + }, + ], + 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, + inverse: { + type: 'insert', + path: fooSpanPath, + position: 'after', + node: { + _type: 'span', + _key: barSpanKey, + text: 'bar', + marks: ['strong'], + }, + }, + }, + ], + 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', + inverse: {type: 'set', path: duplicateKeyPath, value: spanKey}, + }, + ], + 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: [], + inverse: { + type: 'set', + path: [{_key: fooBlockKey}, 'markDefs'], + value: [], + }, + }, + { + type: 'set', + path: [...fooSpanPath, 'marks'], + value: [], + inverse: {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: [], + inverse: { + type: 'set', + path: [{_key: bazBlockKey}, 'markDefs'], + value: [], + }, + }, + { + type: 'set', + path: [...bazSpanPath, 'marks'], + value: [], + inverse: {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: [], + inverse: { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + }, + }, + { + type: 'set', + path: [{_key: blockKey}, 'children'], + value: duplicateKeyChildren, + inverse: { + type: 'set', + path: [{_key: blockKey}, 'children'], + value: [{_type: 'span', _key: spanKey, text: 'foo', marks: []}], + }, + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: duplicateKeyPath, + value: 'k5', + inverse: {type: 'set', path: duplicateKeyPath, value: duplicateKey}, + }, + ], + 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(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).toEqual([ + { + type: 'operation', + operation: {type: 'unset', path: [{_key: 'k2'}]}, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'insert', + path: [0], + position: 'before', + node: block(blockKey, spanKey, 'foo'), + }, + origin: 'remote', + }, + {type: 'value changed', value: [block(blockKey, spanKey, 'foo')]}, + {type: 'ready'}, + { + type: 'selection', + selection: { + anchor: {path: spanPath, offset: 3}, + focus: {path: spanPath, offset: 3}, + backward: false, + }, + }, + { + type: 'operation', + operation: {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + origin: 'local', + }, + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: '!'}, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foo!', [...spanPath, 'text']), + origin: 'local', + }, + ], + }, + { + type: 'patch', + patch: { + ...diffMatchPatch('foo', 'foo!', [...spanPath, 'text']), + origin: 'local', + }, + }, + { + type: 'selection', + selection: { + anchor: {path: spanPath, offset: 4}, + focus: {path: spanPath, offset: 4}, + backward: false, + }, + }, + ]) + }) + + 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 loadedBlock = { + ...block(blockKey, duplicateKey, ''), + children: [ + {_type: 'span', _key: duplicateKey, text: 'foo', marks: []}, + {_type: 'span', _key: duplicateKey, text: 'bar', marks: ['strong']}, + ], + } + const events: Array = [] + await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [loadedBlock], + children: ( + { + events.push(event) + }} + /> + ), + }) + const duplicateKeyPath = [{_key: blockKey}, 'children', 1, '_key'] + + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'ready')).toBe(true) + }) + + expect(events).toEqual([ + { + type: 'operation', + operation: {type: 'unset', path: [{_key: 'k2'}]}, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'insert', + path: [0], + position: 'before', + node: loadedBlock, + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + inverse: {type: 'set', path: duplicateKeyPath, value: duplicateKey}, + }, + origin: 'remote', + }, + {type: 'value changed', value: [loadedBlock]}, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + inverse: {type: 'set', path: duplicateKeyPath, value: duplicateKey}, + }, + ], + patches: [{...set('k4', duplicateKeyPath), origin: 'local'}], + correction: true, + }, + {type: 'ready'}, + ]) + }) + + test('Scenario: A container registered with `editor.registerNode` after load that fills existing content emits a `correction` with the fix', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const calloutKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [{name: 'content', type: 'array', of: [{type: 'block'}]}], + }, + ], + }), + initialValue: [ + block(blockKey, spanKey, 'foo'), + {_type: 'callout', _key: calloutKey}, + ], + }) + const changes = collectChanges(editor) + const contentPath = [{_key: calloutKey}, 'content'] + + editor.registerNode({ + node: defineContainer({type: 'callout', arrayField: 'content'}), + }) + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'foo'), + {_type: 'callout', _key: calloutKey, content: [block('k5', 'k6', '')]}, + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: contentPath, + value: [block('k5', 'k6', '')], + inverse: {type: 'unset', path: contentPath}, + }, + ], + patches: [ + {...set([block('k5', 'k6', '')], contentPath), origin: 'local'}, + ], + correction: true, + }, + ]) + }) + + test('Scenario: A container registered with `editor.registerNode` after load that fills an empty array field emits a `correction` with the fix', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const calloutKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [{name: 'content', type: 'array', of: [{type: 'block'}]}], + }, + ], + }), + initialValue: [ + block(blockKey, spanKey, 'foo'), + {_type: 'callout', _key: calloutKey, content: []}, + ], + }) + const changes = collectChanges(editor) + const contentPath = [{_key: calloutKey}, 'content'] + + editor.registerNode({ + node: defineContainer({type: 'callout', arrayField: 'content'}), + }) + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'foo'), + {_type: 'callout', _key: calloutKey, content: [block('k5', 'k6', '')]}, + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'insert', + path: [...contentPath, 0], + position: 'before', + node: block('k5', 'k6', ''), + inverse: {type: 'unset', path: [...contentPath, {_key: 'k5'}]}, + }, + ], + patches: [ + {...setIfMissing([], contentPath), origin: 'local'}, + { + ...insert([block('k5', 'k6', '')], 'before', [...contentPath, 0]), + origin: 'local', + }, + ], + correction: true, + }, + ]) + }) + + test('Scenario: Unregistering a node after load emits a `correction` with the fix the editor makes to existing content', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const secondSpanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [{name: 'content', type: 'array', of: [{type: 'block'}]}], + }, + ], + }), + initialValue: [block(blockKey, spanKey, 'foo')], + }) + const unregister = editor.registerNode({ + node: defineContainer({type: 'callout', arrayField: 'content'}), + }) + const spanPath = [{_key: blockKey}, 'children', {_key: spanKey}] + const secondSpanPath = [{_key: blockKey}, 'children', {_key: secondSpanKey}] + const secondSpan = { + _type: 'span', + _key: secondSpanKey, + text: 'bar', + marks: [], + } + editor.send({ + type: 'patches', + patches: [{...insert([secondSpan], 'after', spanPath), origin: 'remote'}], + snapshot: undefined, + }) + expect(editor.getSnapshot().context.value).toEqual([ + { + ...block(blockKey, spanKey, 'foo'), + children: [ + {_type: 'span', _key: spanKey, text: 'foo', marks: []}, + secondSpan, + ], + }, + ]) + const changes = collectChanges(editor) + + unregister() + + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, spanKey, 'foobar'), + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + {type: 'insert.text', path: spanPath, offset: 3, text: 'bar'}, + { + type: 'unset', + path: secondSpanPath, + inverse: { + type: 'insert', + path: spanPath, + position: 'after', + node: secondSpan, + }, + }, + ], + patches: [ + { + ...diffMatchPatch('foo', 'foobar', [...spanPath, 'text']), + origin: 'local', + }, + {...unset(secondSpanPath), 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) + }) + + test('Scenario: A `change` listener that edits on a remote `change` gets the correction of the remote `change` before its own edit', 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: Array = [] + editor.on('change', (event) => { + changes.push(event) + if (event.origin === 'remote') { + editor.send({type: 'delete.block', at: [{_key: blockKey}]}) + } + }) + const fooSpan = {_type: 'span', _key: spanKey, text: 'foo', marks: []} + const barSpan = { + _type: 'span', + _key: spanKey, + text: 'bar', + marks: ['strong'], + } + const duplicateKeyPath = [{_key: blockKey}, 'children', 1, '_key'] + + editor.send({ + type: 'update value', + value: [{...block(blockKey, spanKey, ''), children: [fooSpan, barSpan]}], + }) + + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [ + { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + inverse: { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + }, + }, + { + type: 'insert', + path: [{_key: blockKey}, 'children', {_key: spanKey}], + position: 'after', + node: barSpan, + }, + ], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + inverse: {type: 'set', path: duplicateKeyPath, value: spanKey}, + }, + ], + patches: [{...set('k4', duplicateKeyPath), origin: 'local'}], + correction: true, + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'unset', + path: [{_key: blockKey}], + inverse: { + type: 'insert', + path: [0], + position: 'before', + node: { + ...block(blockKey, spanKey, ''), + children: [fooSpan, {...barSpan, _key: 'k4'}], + }, + }, + }, + ], + patches: [ + {...unset([{_key: blockKey}]), origin: 'local'}, + {...unset([]), origin: 'local'}, + ], + }, + ]) + }) + + test('Scenario: A first load that streams its blocks across ticks commits one change', async () => { + const keyGenerator = createTestKeyGenerator() + // `updateValue` yields a tick every 10 blocks, so 25 blocks span three. + const initialValue = Array.from({length: 25}, () => + block(keyGenerator(), keyGenerator(), 'foo'), + ) + const committedCauses: Array = [] + function ChangeProbe() { + const engine = useEngineStatic() + useEffect( + () => + subscribeToChanges(engine, (change) => { + committedCauses.push(change.cause) + }), + [engine], + ) + return null + } + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({}), + initialValue, + children: , + }) + + await vi.waitFor(() => { + expect(committedCauses).toEqual([{type: 'value sync', initial: true}]) + }) + expect(editor.getSnapshot().context.value).toEqual(initialValue) + }) + + test('Scenario: A remote patch sent before a streamed first load finishes arrives after the correction of the load', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const duplicateKey = keyGenerator() + const fooSpan = {_type: 'span', _key: duplicateKey, text: 'foo', marks: []} + const barSpan = { + _type: 'span', + _key: duplicateKey, + text: 'bar', + marks: ['strong'], + } + const events: Array = [] + const firstSpanPath = [{_key: blockKey}, 'children', {_key: duplicateKey}] + const duplicateKeyPath = [{_key: blockKey}, 'children', 1, '_key'] + const loadedBlock = { + ...block(blockKey, duplicateKey, ''), + children: [fooSpan, barSpan], + } + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [loadedBlock], + children: ( + <> + { + events.push(event) + }} + /> + + + ), + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + ...block(blockKey, duplicateKey, ''), + children: [{...barSpan, _key: 'k4'}], + }, + ]) + }) + expect(events).toEqual([ + { + type: 'operation', + operation: {type: 'unset', path: [{_key: 'k2'}]}, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'insert', + path: [0], + position: 'before', + node: loadedBlock, + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + inverse: {type: 'set', path: duplicateKeyPath, value: duplicateKey}, + }, + origin: 'remote', + }, + {type: 'value changed', value: [loadedBlock]}, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + inverse: {type: 'set', path: duplicateKeyPath, value: duplicateKey}, + }, + ], + patches: [{...set('k4', duplicateKeyPath), origin: 'local'}], + correction: true, + }, + {type: 'ready'}, + { + type: 'operation', + operation: {type: 'unset', path: firstSpanPath}, + origin: 'remote', + }, + { + type: 'change', + origin: 'remote', + operations: [{type: 'unset', path: firstSpanPath}], + }, + ]) + }) + + test('Scenario: A value sent before a streamed first load finishes syncs after the correction of the load', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const duplicateKey = keyGenerator() + const fooSpan = {_type: 'span', _key: duplicateKey, text: 'foo', marks: []} + const barSpan = { + _type: 'span', + _key: duplicateKey, + text: 'bar', + marks: ['strong'], + } + const duplicateKeyPath = [{_key: blockKey}, 'children', 1, '_key'] + const firstSpanPath = [{_key: blockKey}, 'children', {_key: duplicateKey}] + const events: Array = [] + const loadedBlock = { + ...block(blockKey, duplicateKey, ''), + children: [fooSpan, barSpan], + } + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + initialValue: [loadedBlock], + children: ( + <> + { + events.push(event) + }} + /> + + + ), + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + block(blockKey, duplicateKey, 'baz'), + ]) + }) + expect(events).toEqual([ + { + type: 'operation', + operation: {type: 'unset', path: [{_key: 'k2'}]}, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'insert', + path: [0], + position: 'before', + node: loadedBlock, + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + inverse: {type: 'set', path: duplicateKeyPath, value: duplicateKey}, + }, + origin: 'remote', + }, + {type: 'value changed', value: [loadedBlock]}, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'set', + path: duplicateKeyPath, + value: 'k4', + inverse: {type: 'set', path: duplicateKeyPath, value: duplicateKey}, + }, + ], + patches: [{...set('k4', duplicateKeyPath), origin: 'local'}], + correction: true, + }, + {type: 'ready'}, + { + type: 'operation', + operation: { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + inverse: { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + }, + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'unset', + path: [{_key: blockKey}, 'children', {_key: 'k4'}], + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'set', + path: [...firstSpanPath, 'marks'], + value: [], + inverse: {type: 'set', path: [...firstSpanPath, 'marks'], value: []}, + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'remove.text', + path: firstSpanPath, + offset: 0, + text: 'foo', + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'insert.text', + path: firstSpanPath, + offset: 0, + text: 'baz', + }, + origin: 'remote', + }, + { + type: 'change', + origin: 'remote', + operations: [ + { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + inverse: { + type: 'set', + path: [{_key: blockKey}, 'markDefs'], + value: [], + }, + }, + {type: 'unset', path: [{_key: blockKey}, 'children', {_key: 'k4'}]}, + { + type: 'set', + path: [...firstSpanPath, 'marks'], + value: [], + inverse: { + type: 'set', + path: [...firstSpanPath, 'marks'], + value: [], + }, + }, + {type: 'remove.text', path: firstSpanPath, offset: 0, text: 'foo'}, + {type: 'insert.text', path: firstSpanPath, offset: 0, text: 'baz'}, + ], + }, + {type: 'value changed', value: [block(blockKey, duplicateKey, 'baz')]}, + ]) + }) + + test('Scenario: `update value` sent by a Behavior effect emits its own remote `change` and keeps the value in sync', async () => { + const keyGenerator = createTestKeyGenerator() + const fooBlockKey = keyGenerator() + const fooSpanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + children: ( + [ + effect(() => { + editor.send({ + type: 'update value', + value: [block(fooBlockKey, fooSpanKey, 'foo')], + }) + }), + ], + ], + }), + ]} + /> + ), + }) + const fooSpanPath = [{_key: fooBlockKey}, 'children', {_key: fooSpanKey}] + const changes = collectChanges(editor) + + editor.send({type: 'custom.sync'}) + + expect(editor.getSnapshot().context.value).toEqual([ + block(fooBlockKey, fooSpanKey, 'foo'), + ]) + + editor.send({ + type: 'update value', + value: [block(fooBlockKey, fooSpanKey, 'bar')], + }) + + expect(editor.getSnapshot().context.value).toEqual([ + block(fooBlockKey, fooSpanKey, 'bar'), + ]) + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [ + {type: 'unset', path: [{_key: 'k2'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: block(fooBlockKey, fooSpanKey, 'foo'), + }, + ], + }, + { + type: 'change', + origin: 'remote', + operations: [ + { + type: 'set', + path: [{_key: fooBlockKey}, 'markDefs'], + value: [], + inverse: { + type: 'set', + path: [{_key: fooBlockKey}, 'markDefs'], + value: [], + }, + }, + { + type: 'set', + path: [...fooSpanPath, 'marks'], + value: [], + inverse: {type: 'set', path: [...fooSpanPath, 'marks'], value: []}, + }, + {type: 'remove.text', path: fooSpanPath, offset: 0, text: 'foo'}, + {type: 'insert.text', path: fooSpanPath, offset: 0, text: 'bar'}, + ], + }, + ]) + }) + + test('Scenario: `update value` sent by a Behavior effect after an `await` emits its own remote `change`', async () => { + const keyGenerator = createTestKeyGenerator() + const fooBlockKey = keyGenerator() + const fooSpanKey = keyGenerator() + const {editor} = await createTestEditor({ + keyGenerator, + children: ( + [ + effect(async () => { + await Promise.resolve() + editor.send({ + type: 'update value', + value: [block(fooBlockKey, fooSpanKey, 'foo')], + }) + }), + ], + ], + }), + ]} + /> + ), + }) + const changes = collectChanges(editor) + + editor.send({type: 'custom.sync'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + block(fooBlockKey, fooSpanKey, 'foo'), + ]) + }) + expect(changes).toEqual([ + { + type: 'change', + origin: 'remote', + operations: [ + {type: 'unset', path: [{_key: 'k2'}]}, + { + type: 'insert', + path: [0], + position: 'before', + node: block(fooBlockKey, fooSpanKey, 'foo'), + }, + ], + }, + ]) + }) +}) + +function collectChanges(editor: Editor): Array { + const changes: Array = [] + editor.on('change', (event) => { + changes.push(event) + }) + return changes +} + +function SendOnMount({event}: {event: Parameters[0]}) { + const editor = useEditor() + useEffect(() => { + editor.send(event) + }, [editor, event]) + return null +} + +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/selection-readonly-sync.test.tsx b/packages/editor/tests/selection-readonly-sync.test.tsx index 3e3ad1cbd3..02f776940a 100644 --- a/packages/editor/tests/selection-readonly-sync.test.tsx +++ b/packages/editor/tests/selection-readonly-sync.test.tsx @@ -1,7 +1,15 @@ import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test, vi} from 'vitest' +import {effect, forward} from '../src/behaviors/behavior.types.action' +import {defineBehavior} from '../src/behaviors/behavior.types.behavior' +import type {EditorEmittedEvent} from '../src/editor/relay' +import type {Editor} from '../src/engine/interfaces/editor' +import {useEngineStatic} from '../src/engine/react/hooks/use-engine-static' +import {BehaviorPlugin} from '../src/plugins/plugin.behavior' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {createTestEditor} from '../src/test/vitest' +import type {EditorSelection} from '../src/types/editor' /** * Selection must keep syncing in read-only editors. The `selectionchange` @@ -108,6 +116,112 @@ describe('Feature: Read-Only Selection Sync', () => { }) }) }) + + test('Scenario: Moving the DOM selection out of a read-only editor deselects through a `select` event', async () => { + const selectEvents: Array<{type: 'select'; at: EditorSelection}> = [] + let engine: Editor | undefined + const emittedEvents: Array = [] + const {editor} = await createTestEditor({ + keyGenerator: createTestKeyGenerator(), + schemaDefinition: defineSchema({}), + initialValue, + children: ( + <> + { + engine = editorEngine + }} + /> + { + emittedEvents.push(event) + }} + /> + [forward(event)], + ({event}) => [ + effect(() => { + selectEvents.push(event) + }), + ], + ], + }), + ]} + /> + + ), + }) + + editor.send({type: 'update readOnly', readOnly: true}) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.readOnly).toBe(true) + }) + + selectDomText('first', 1, 'first', 3) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).not.toEqual(null) + }) + + const outside = document.createElement('p') + outside.textContent = 'outside' + document.body.appendChild(outside) + selectDomText('outside', 0, 'outside', 7) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual(null) + }) + const firstSelection = { + anchor: { + path: [{_key: 'blockA'}, 'children', {_key: 'spanA'}], + offset: 1, + }, + focus: {path: [{_key: 'blockA'}, 'children', {_key: 'spanA'}], offset: 3}, + backward: false, + } + expect(selectEvents).toEqual([ + {type: 'select', at: firstSelection}, + {type: 'select', at: null}, + ]) + expect(emittedEvents).toEqual([ + { + type: 'operation', + operation: {type: 'unset', path: [{_key: 'k0'}]}, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'insert', + path: [0], + position: 'before', + node: initialValue[0], + }, + origin: 'remote', + }, + { + type: 'operation', + operation: { + type: 'insert', + path: [1], + position: 'before', + node: initialValue[1], + }, + origin: 'remote', + }, + {type: 'value changed', value: initialValue}, + {type: 'ready'}, + {type: 'read only'}, + {type: 'selection', selection: firstSelection}, + {type: 'selection', selection: null}, + ]) + expect(engine?.focused).toEqual(false) + + outside.remove() + }) }) function selectDomText( @@ -132,3 +246,8 @@ function findTextNode(text: string): Text { } throw new Error(`No text node with text "${text}"`) } + +function EngineProbe({onEngine}: {onEngine: (engine: Editor) => void}) { + onEngine(useEngineStatic()) + return null +} diff --git a/packages/editor/tests/tables.test.tsx b/packages/editor/tests/tables.test.tsx index fd2f1cb90e..2c6273c0cc 100644 --- a/packages/editor/tests/tables.test.tsx +++ b/packages/editor/tests/tables.test.tsx @@ -4,6 +4,7 @@ import {createTestKeyGenerator} from '@portabletext/test' import React from 'react' import {describe, expect, test, vi} from 'vitest' import {withoutPatching} from '../src/engine-plugins/engine-plugin.without-patching' +import {withChange} from '../src/engine/core/change' import {normalize} from '../src/engine/editor/normalize' import {InternalEditorEngineRefPlugin} from '../src/plugins/plugin.internal.editor-engine-ref' import {defineContainer, type Container} from '../src/renderers/renderer.types' @@ -464,8 +465,10 @@ describe('tables', () => { buildPublicContainers(_resolvedContainers) // Force normalization with containers set - withoutPatching(editorEngineRef.current!, () => { - normalize(editorEngineRef.current!, {force: true}) + withChange(editorEngineRef.current!, {type: 'registration'}, () => { + withoutPatching(editorEngineRef.current!, () => { + normalize(editorEngineRef.current!, {force: true}) + }) }) editorEngineRef.current!.onChange() @@ -532,8 +535,10 @@ describe('tables', () => { editorEngineRef.current!.snapshot.context.containers = buildPublicContainers(_resolvedContainers) - withoutPatching(editorEngineRef.current!, () => { - normalize(editorEngineRef.current!, {force: true}) + withChange(editorEngineRef.current!, {type: 'registration'}, () => { + withoutPatching(editorEngineRef.current!, () => { + normalize(editorEngineRef.current!, {force: true}) + }) }) editorEngineRef.current!.onChange() @@ -600,8 +605,10 @@ describe('tables', () => { editorEngineRef.current!.snapshot.context.containers = buildPublicContainers(_resolvedContainers) - withoutPatching(editorEngineRef.current!, () => { - normalize(editorEngineRef.current!, {force: true}) + withChange(editorEngineRef.current!, {type: 'registration'}, () => { + withoutPatching(editorEngineRef.current!, () => { + normalize(editorEngineRef.current!, {force: true}) + }) }) editorEngineRef.current!.onChange() @@ -686,8 +693,10 @@ describe('tables', () => { editorEngineRef.current!.snapshot.context.containers = buildPublicContainers(_resolvedContainers) - withoutPatching(editorEngineRef.current!, () => { - normalize(editorEngineRef.current!, {force: true}) + withChange(editorEngineRef.current!, {type: 'registration'}, () => { + withoutPatching(editorEngineRef.current!, () => { + normalize(editorEngineRef.current!, {force: true}) + }) }) editorEngineRef.current!.onChange() @@ -762,8 +771,10 @@ describe('tables', () => { editorEngineRef.current!.snapshot.context.containers = buildPublicContainers(_resolvedContainers) - withoutPatching(editorEngineRef.current!, () => { - normalize(editorEngineRef.current!, {force: true}) + withChange(editorEngineRef.current!, {type: 'registration'}, () => { + withoutPatching(editorEngineRef.current!, () => { + normalize(editorEngineRef.current!, {force: true}) + }) }) editorEngineRef.current!.onChange() @@ -825,8 +836,10 @@ describe('tables', () => { editorEngineRef.current!.snapshot.context.containers = buildPublicContainers(_resolvedContainers) - withoutPatching(editorEngineRef.current!, () => { - normalize(editorEngineRef.current!, {force: true}) + withChange(editorEngineRef.current!, {type: 'registration'}, () => { + withoutPatching(editorEngineRef.current!, () => { + normalize(editorEngineRef.current!, {force: true}) + }) }) editorEngineRef.current!.onChange() 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: {