Skip to content
Open
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
22 changes: 22 additions & 0 deletions .changeset/emit-change-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@portabletext/editor': minor
---

feat: emit `change` events for local and remote changes, with corrections marked

The editor now emits a `change` event, synchronously, every time its content changes. Each event carries the `operations` the editor applied to its content (the public `Operation` type), never the input it received:

```ts
editor.on('change', (event) => {
if (event.origin === 'local') {
// `event.patches` is the change as Content Lake patches.
save(event.patches)
}
})
```

- `origin: 'local'`: what a user action did, including everything the action's Behaviors raised. Undo and redo are one action each. Local changes arrive in application order, and one user action can produce several when the editor fixes something mid-action. `patches` holds the change as Content Lake patches, the same patches its `mutation` carries, available as soon as the action finishes instead of on the `mutation` event's debounced schedule.
- `origin: 'local'` with `correction: true`: the editor's own fixes to the content, such as merging adjacent spans that share marks after a paste, or replacing a duplicate `_key`. A correction follows the change that caused it, with its own operations and patches. Fixes to content the editor received through `patches` or `update value` are corrections too. A host that saves every local change's `patches` saves corrections as well.
- `origin: 'remote'`: one event per `patches` message and per `update value` that changed the content. Remote changes carry no `patches`.

Loading the initial value emits no `change`, except a `correction` when the editor fixes the loaded value, and a `change` never has an empty `operations` array. `ChangeEvent` is exported as a beta type. `EditorEmittedEvent` gains the `change` member, so exhaustive switches over `event.type` gain a case.
141 changes: 141 additions & 0 deletions packages/editor/src/editor/change-collector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
compileSchema,
defineSchema,
type PortableTextBlock,
} from '@portabletext/schema'
import {describe, expect, test} from 'vitest'
import {subscribeToOperations} from '../engine/core/operation-channel'
import {createEditor} from '../engine/create-editor'
import type {PortableTextEditorEngine} from '../types/editor-engine'
import {createChangeCollector} from './change-collector'
import type {ChangeEvent} from './relay'

describe(createChangeCollector.name, () => {
test('an apply whose `before` listener throws does not stop later changes', () => {
const editor = createBareEngine([block('b1', 's1', 'foo')])
const changes: Array<ChangeEvent> = []
const collector = createChangeCollector({
editor,
emit: (event) => {
changes.push(event)
},
})
collector.subscribe()
let throwOnce = true
subscribeToOperations(
editor,
() => {
if (throwOnce) {
throwOnce = false
throw new Error('boom')
}
},
{phase: 'before'},
)
const spanPath = [{_key: 'b1'}, 'children', {_key: 's1'}]

expect(() =>
editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'}),
).toThrow('boom')
editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '?'})

expect(changes).toEqual([
{
type: 'change',
origin: 'local',
operations: [
{type: 'insert.text', path: spanPath, offset: 3, text: '?'},
],
patches: [],
},
])
})

test('an apply made while a change is emitted goes into a new change', () => {
const editor = createBareEngine([block('b1', 's1', 'foo')])
const changes: Array<ChangeEvent> = []
const spanPath = [{_key: 'b1'}, 'children', {_key: 's1'}]
let reentered = false
const collector = createChangeCollector({
editor,
emit: (event) => {
changes.push(event)
if (!reentered) {
reentered = true
editor.apply({
type: 'insert.text',
path: spanPath,
offset: 4,
text: '?',
})
}
},
})
collector.subscribe()

collector.withUnit(() => {
editor.apply({type: 'insert.text', path: spanPath, offset: 3, text: '!'})
})

expect(changes).toEqual([
{
type: 'change',
origin: 'local',
operations: [
{type: 'insert.text', path: spanPath, offset: 3, text: '!'},
],
patches: [],
},
{
type: 'change',
origin: 'local',
operations: [
{type: 'insert.text', path: spanPath, offset: 4, text: '?'},
],
patches: [],
},
])
})
})

function createBareEngine(
value: Array<PortableTextBlock>,
): PortableTextEditorEngine {
const editor = createEditor() as PortableTextEditorEngine

editor.containers = new Map()
editor.blockIndexMap = new Map()
editor.verifiedUniqueChildGroups = new Set()
editor.isPatching = true
// The bare engine lacks the fields that `withDOM` and `createEditorEngine`
// assign; `apply` reads only these snapshot fields.
editor.snapshot = {
blockIndexMap: editor.blockIndexMap,
context: {
containers: new Map(),
converters: [],
keyGenerator: () => 'generated-key',
readOnly: false,
schema: compileSchema(defineSchema({})),
selection: null,
value,
},
decoratorState: {},
} as unknown as PortableTextEditorEngine['snapshot']

return editor
}

function block(
blockKey: string,
spanKey: string,
text: string,
): PortableTextBlock {
return {
_type: 'block',
_key: blockKey,
style: 'normal',
markDefs: [],
children: [{_type: 'span', _key: spanKey, text, marks: []}],
}
}
Loading
Loading