Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/emit-change-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@portabletext/editor': minor
---

feat: emit `change` events with the applied operations for local and remote changes

The editor now emits a `change` event for every edit applied to the document. Each event carries the applied operations and an `origin` that tells you whether the edit was made in this editor (`'local'`) or arrived from the outside (`'remote'`). Use it to keep derived state (indexes, anchors, external copies of the value) in sync without diffing the value yourself. Previously only local edits were observable, through the `mutation` event.

```tsx
import {EventListenerPlugin} from '@portabletext/editor/plugins'

<EventListenerPlugin
on={(event) => {
if (event.type === 'change' && event.origin === 'remote') {
for (const operation of event.operations) {
invalidateBlock(operation.path[0])
}
}
}}
/>
```

Notes:

- `operations` uses the same operation types as the `operation` event: `insert`, `insert.text`, `remove.text`, `set`, and `unset`.
- Local events arrive at the same cadence as `mutation` events. Undo and redo count as local.
- Remote updates emit one event per applied block, in application order. Apply them in that order.
- An `update value` that changes nothing emits nothing.
- The initial value sync also emits a `change`, taking the editor's empty seed document to your configured initial value. If you maintain a copy of the value loaded from storage, start applying events at the `ready` event; otherwise you would re-apply content your copy already has.
- The `operations` array is a fresh copy per event, but the operation objects in it are shared with the editor: treat them as read-only and copy anything you keep around.

New exports: `ChangeEvent`, and the `'change'` member on `EditorEmittedEvent`. Exhaustive switches over emitted event types gain a case.
1 change: 1 addition & 0 deletions packages/editor/src/editor/create-editor-engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function createEditorEngine(
editor.isPatching = true
editor.isPerformingBehaviorOperation = false
editor.withHistory = true
editor.onRemoteChange = () => {}

const editorEngine = plugins(withDOM(editor), {
editorActor: config.editorActor,
Expand Down
78 changes: 78 additions & 0 deletions packages/editor/src/editor/create-editor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {defineSchema} from '@portabletext/schema'
import {createTestKeyGenerator} from '@portabletext/test'
import {describe, expect, test, vi} from 'vitest'
import {stopActor} from '../internal-utils/stop-actor'
import {createInternalEditor} from './create-editor'
import type {EditorEmittedEvent} from './relay'

/**
* Pins the `mutation`-`change` pairing for edits applied while the editor
* actor is idle: `editorEngine.apply` is the same direct-engine call
* `Editable.tsx`'s focus handler uses, bypassing `editorActor.send` and
* with it the actor's mailbox deferral.
*/
describe('createInternalEditor: local `change` joins a `mutation` applied outside actor processing', () => {
test('a `set` operation applied directly on the engine still reports a `change`', async () => {
const internalEditor = createInternalEditor({
keyGenerator: createTestKeyGenerator(),
schemaDefinition: defineSchema({}),
initialValue: [
{
_type: 'block',
_key: 'b1',
style: 'normal',
markDefs: [],
children: [{_type: 'span', _key: 'b1-span', text: 'foo', marks: []}],
},
],
})

const unsubscribers = internalEditor.subscriptions.map((subscribe) =>
subscribe(),
)
internalEditor.actors.editorActor.start()
internalEditor.actors.editorActor.send({
type: 'add editor engine',
editor: internalEditor.editorEngine,
})
internalEditor.relay.start()
internalEditor.actors.syncActor.start()

const events: Array<EditorEmittedEvent> = []
internalEditor.editor.on('*', (event) => {
events.push(event)
})

// The initial value reaches the engine through the sync machine's own
// (async) reconciliation: `apply` must wait for it, or it targets the
// placeholder block the engine starts with instead of `b1`.
await vi.waitFor(() => {
expect(internalEditor.editorEngine.snapshot.context.value).toEqual([
expect.objectContaining({_key: 'b1'}),
])
})

internalEditor.editorEngine.apply({
type: 'set',
path: [{_key: 'b1'}, 'style'],
value: 'h1',
})

await vi.waitFor(() => {
expect(events.some((event) => event.type === 'mutation')).toBe(true)
})

expect(
events.some(
(event) => event.type === 'change' && event.origin === 'local',
),
).toBe(true)

for (const unsubscribe of unsubscribers) {
unsubscribe()
}
stopActor(internalEditor.actors.editorActor)
internalEditor.relay.stop()
stopActor(internalEditor.actors.syncActor)
})
})
49 changes: 24 additions & 25 deletions packages/editor/src/editor/create-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import {createActor} from 'xstate'
import {coreConverters} from '../converters/converters.core'
import type {Editor, EditorConfig} from '../editor'
import {subscribeToOperations} from '../engine/core/operation-channel'
import type {EngineOperation} from '../engine/interfaces/operation'
import {debug} from '../internal-utils/debug'
import {corePriority} from '../priority/priority.core'
import {createEditorPriority} from '../priority/priority.types'
import type {EditableAPI} from '../types/editor'
import type {PortableTextEditorEngine} from '../types/editor-engine'
import type {Operation} from '../types/operation'
import {isPublicOperation} from '../types/operation'
import {defaultKeyGenerator} from '../utils/key-generator'
import {createEditableAPI} from './create-editable-api'
import {createEditorEngine} from './create-editor-engine'
Expand Down Expand Up @@ -142,6 +141,7 @@ export function createInternalEditor(config: EditorConfig): {
return relay.on(type, (event) => {
switch (event.type) {
case 'blurred':
case 'change':
case 'editable':
case 'focused':
case 'invalid value':
Expand Down Expand Up @@ -196,28 +196,6 @@ function editorConfigToMachineInput(config: EditorConfig) {
} as const
}

/**
* The public operation types. The `Record` keying makes completeness
* compile-checked: adding a variant to the public `Operation` union in
* `types/operation.ts` (which carries the tripwire that fires when the
* engine vocabulary grows) errors here until the allowlist catches up.
*/
const publicOperationTypeRecord: Record<Operation['type'], true> = {
'insert': true,
'insert.text': true,
'remove.text': true,
'set': true,
'unset': true,
}

const publicOperationTypes: ReadonlySet<string> = new Set(
Object.keys(publicOperationTypeRecord),
)

function isPublicOperation(operation: EngineOperation): operation is Operation {
return publicOperationTypes.has(operation.type)
}

function createActors(config: {
editorActor: EditorActor
relay: Relay
Expand All @@ -234,6 +212,12 @@ function createActors(config: {
relay: config.relay,
})

// `withRemoteChanges` brackets every remote application; this is its
// only path to the relay.
config.editorEngine.onRemoteChange = (operations) => {
config.relay.send({type: 'change', operations, origin: 'remote'})
}

const syncActor = createActor(syncMachine, {
input: {
initialValue: config.editorActor.getSnapshot().context.initialValue,
Expand Down Expand Up @@ -312,8 +296,23 @@ function createActors(config: {
config.subscriptions.push(() => {
const subscription = config.editorActor.on('*', (event) => {
switch (event.type) {
case 'mutation': {
// Internal fields stripped: they never widen the public
// `MutationEvent`.
const {operations, ...mutationEvent} = event
config.relay.send(mutationEvent)
// A flush with no operations (a repair-only or auto-resolution
// `mutation`) emits no `change`.
if (operations.length > 0) {
config.relay.send({
type: 'change',
operations: [...operations],
origin: 'local',
})
}
Comment thread
cursor[bot] marked this conversation as resolved.
break
}
case 'editable':
case 'mutation':
case 'ready':
case 'read only':
case 'selection':
Expand Down
17 changes: 14 additions & 3 deletions packages/editor/src/editor/editor-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {pathContains} from '../traversal/path-contains'
import type {NamespaceEvent, OmitFromUnion} from '../type-utils'
import type {EditorSelection} from '../types/editor'
import type {PortableTextEditorEngine} from '../types/editor-engine'
import type {Operation} from '../types/operation'
import type {EditorSchema} from './editor-schema'
import {
registerNodeOnEngine,
Expand Down Expand Up @@ -58,6 +59,15 @@ export type ExternalEditorEvent =
type InternalPatchEvent = NamespaceEvent<PatchEvent, 'internal'> & {
operationId?: string
value: Array<PortableTextBlock>
// Set on an operation's first patch only; the batcher bulks one entry
// per applied operation.
operation?: Operation
}

// Internal-only: the flushed bulk's operations ride to the relay bridge,
// which strips them before the `mutation` reaches consumers.
type InternalMutationEvent = MutationEvent & {
operations: Array<Operation>
}

/**
Expand Down Expand Up @@ -102,7 +112,7 @@ type InternalEditorEvent =
editor: PortableTextEditorEngine
nativeEvent?: {preventDefault: () => void}
}
| MutationEvent
| InternalMutationEvent
| InternalPatchEvent
| {
type: 'set drag ghost'
Expand All @@ -129,8 +139,9 @@ type InternalEditorEvent =
* @internal
*/
type InternalEditorEmittedEvent =
| OmitFromUnion<EditorEmittedEvent, 'type', 'patch'>
| OmitFromUnion<EditorEmittedEvent, 'type', 'patch' | 'mutation'>
| InternalPatchEvent
| InternalMutationEvent
| PatchesEvent

export function rerouteExternalBehaviorEvent({
Expand Down Expand Up @@ -186,7 +197,7 @@ export const editorMachine = setup({
behaviorsSorted: boolean
initialConverters: Array<Converter>
keyGenerator: () => string
pendingEvents: Array<InternalPatchEvent | MutationEvent>
pendingEvents: Array<InternalPatchEvent | InternalMutationEvent>
pendingIncomingPatchesEvents: Array<PatchesEvent>
pendingRegistrations: Array<RegistrableNode>
schema: EditorSchema
Expand Down
8 changes: 8 additions & 0 deletions packages/editor/src/editor/mutation-batcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import type {PortableTextBlock} from '@portabletext/schema'
import {subscribeToOperations} from '../engine/core/operation-channel'
import {isNormalizing} from '../engine/editor/is-normalizing'
import type {PortableTextEditorEngine} from '../types/editor-engine'
import type {Operation} from '../types/operation'
import type {EditorActor} from './editor-machine'
import type {Relay} from './relay'

type PendingMutation = {
operationId?: string
value: Array<PortableTextBlock> | undefined
patches: Array<Patch>
operations: Array<Operation>
}

const TYPE_DEBOUNCE = 250
Expand Down Expand Up @@ -67,6 +69,7 @@ export function createMutationBatcher({
patch: Patch
operationId?: string
value: Array<PortableTextBlock>
operation?: Operation
}) {
editorEngine.isDeferringMutations = true

Expand All @@ -81,11 +84,15 @@ export function createMutationBatcher({
if (lastBulk && lastBulk.operationId === event.operationId) {
lastBulk.value = event.value
lastBulk.patches.push(event.patch)
if (event.operation !== undefined) {
lastBulk.operations.push(event.operation)
}
} else {
pendingMutations.push({
operationId: event.operationId,
value: event.value,
patches: [event.patch],
operations: event.operation !== undefined ? [event.operation] : [],
})
}

Expand Down Expand Up @@ -128,6 +135,7 @@ export function createMutationBatcher({
type: 'mutation',
patches: bulk.patches,
value: bulk.value,
operations: bulk.operations,
})
}
}
Expand Down
56 changes: 56 additions & 0 deletions packages/editor/src/editor/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type EditorEmittedEvent =
type: 'blurred'
event: FocusEvent<HTMLDivElement, Element>
}
| ChangeEvent
| {
type: 'editable'
}
Expand Down Expand Up @@ -73,6 +74,61 @@ export type EditorEmittedEvent =
value: Array<PortableTextBlock> | undefined
}

/**
* @beta
* The document's change ledger: what was applied, from any origin, in
* order. The `mutation` event is the outbox (local patches to persist);
* `change` is the ledger, so a local edit appears in both, each serving
* its own consumers. Subscribe to `mutation` to persist, to `change` to
* track what happened to the document.
*
* `operations` are the same {@link Operation} vocabulary the `operation`
* event carries (`set.selection` excluded), at full available fidelity:
* no patch conversion, no `diffMatchPatch` round trip. They are what was
* applied to the document, never the received inputs: a local edit's
* operations are the engine's own local edit operations; a remote
* change's operations are whatever the engine actually applied to reach
* the fed patches, the `update value` reconciliation, or the initial
* value sync, not the fed patches or value themselves. Never emitted
* with an empty `operations` array.
*
* The `operations` array is the consumer's own copy, safe to hold onto
* past the listener call. The operation objects inside it are the
* engine's own, passed by reference, the same as on the `operation`
* event: treat them as read-only and copy any object you retain.
*
* A remote update emits one or more `change` events, in application
* order: the sync machine applies a changed value block by block, and
* each applied block's operations arrive as their own event. Fold the
* events in delivery order to reproduce the full set of applied changes;
* never coalesce them yourself, the streamed sync path can interleave a
* local flush's own `change` between two remote ones, and coalescing
* would misorder that interleaving.
*
* The initial value sync emits its own remote `change`, taking the
* editor's seed document to the configured initial value: a consumer
* folding stored positions onto live changes starts at the `ready`
* event (skip everything before it) to avoid applying that sync as a
* spurious delta.
*
* A local bulk's `operations` holds the operations whose application
* produced an outgoing patch, matching the outbox: an applied operation
* whose patch conversion yields nothing is absent. A remote bulk's
* `operations` holds every applied public operation. Editor-structural
* bookkeeping is neither patched nor reported: the placeholder block the
* engine inserts when the document empties is uninhabitable by any
* position, and its creation appears on no channel. An operation that
* later removes or replaces that placeholder (real content arriving) does
* appear, as part of the update that applied it: it folds as a no-op
* against a stored value, an `unset`/remove of a key the stored value
* never had.
*/
export type ChangeEvent = {
type: 'change'
operations: Array<Operation>
origin: 'local' | 'remote'
}

/**
* @public
*/
Expand Down
Loading
Loading