@portabletext/editor 1.49.6 → 1.49.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,6 @@ import {
14
14
  } from 'react'
15
15
  import {Subject} from 'rxjs'
16
16
  import {Slate} from 'slate-react'
17
- import {useEffectEvent} from 'use-effect-event'
18
17
  import {createActor} from 'xstate'
19
18
  import {createCoreConverters} from '../converters/converters.core'
20
19
  import {debugWithName} from '../internal-utils/debug'
@@ -29,7 +28,6 @@ import type {
29
28
  PatchObservable,
30
29
  PortableTextMemberSchemaTypes,
31
30
  } from '../types/editor'
32
- import {Synchronizer} from './components/Synchronizer'
33
31
  import {createInternalEditor, type InternalEditor} from './create-editor'
34
32
  import {EditorActorContext} from './editor-actor-context'
35
33
  import {editorMachine, type EditorActor} from './editor-machine'
@@ -38,6 +36,7 @@ import {PortableTextEditorContext} from './hooks/usePortableTextEditor'
38
36
  import {PortableTextEditorSelectionProvider} from './hooks/usePortableTextEditorSelection'
39
37
  import {defaultKeyGenerator} from './key-generator'
40
38
  import {createLegacySchema} from './legacy-schema'
39
+ import {eventToChange} from './route-events-to-changes'
41
40
 
42
41
  const debug = debugWithName('component:PortableTextEditor')
43
42
 
@@ -164,6 +163,16 @@ export class PortableTextEditor extends Component<
164
163
  })
165
164
  editorActor.start()
166
165
 
166
+ editorActor.on('*', (event) => {
167
+ const change = eventToChange(event)
168
+
169
+ if (change) {
170
+ props.onChange(change)
171
+
172
+ this.change$.next(change)
173
+ }
174
+ })
175
+
167
176
  this.editor = createInternalEditor(editorActor)
168
177
  this.schemaTypes = legacySchema
169
178
  }
@@ -209,7 +218,7 @@ export class PortableTextEditor extends Component<
209
218
  }
210
219
 
211
220
  if (this.props.value !== prevProps.value) {
212
- this.editor._internal.editorActor.send({
221
+ this.editor.send({
213
222
  type: 'update value',
214
223
  value: this.props.value,
215
224
  })
@@ -244,23 +253,6 @@ export class PortableTextEditor extends Component<
244
253
  patches$={legacyPatches}
245
254
  />
246
255
  ) : null}
247
- <RouteEventsToChanges
248
- editorActor={this.editor._internal.editorActor}
249
- onChange={(change) => {
250
- if (!this.props.editor) {
251
- this.props.onChange(change)
252
- }
253
- /**
254
- * For backwards compatibility, we relay all changes to the
255
- * `change$` Subject as well.
256
- */
257
- this.change$.next(change)
258
- }}
259
- />
260
- <Synchronizer
261
- editorActor={this.editor._internal.editorActor}
262
- slateEditor={this.editor._internal.slateEditor.instance}
263
- />
264
256
  <EditorActorContext.Provider value={this.editor._internal.editorActor}>
265
257
  <Slate
266
258
  editor={this.editor._internal.slateEditor.instance}
@@ -775,84 +767,3 @@ function RoutePatchesObservableToEditorActor(props: {
775
767
 
776
768
  return null
777
769
  }
778
-
779
- export function RouteEventsToChanges(props: {
780
- editorActor: EditorActor
781
- onChange: (change: EditorChange) => void
782
- }) {
783
- // We want to ensure that _when_ `props.onChange` is called, it uses the current value.
784
- // But we don't want to have the `useEffect` run setup + teardown + setup every time the prop might change, as that's unnecessary.
785
- // So we use our own polyfill that lets us use an upcoming React hook that solves this exact problem.
786
- // https://19.react.dev/learn/separating-events-from-effects#declaring-an-effect-event
787
- const handleChange = useEffectEvent((change: EditorChange) =>
788
- props.onChange(change),
789
- )
790
-
791
- useEffect(() => {
792
- debug('Subscribing to editor changes')
793
- const sub = props.editorActor.on('*', (event) => {
794
- switch (event.type) {
795
- case 'blurred': {
796
- handleChange({type: 'blur', event: event.event})
797
- break
798
- }
799
- case 'patch':
800
- handleChange(event)
801
- break
802
- case 'loading': {
803
- handleChange({type: 'loading', isLoading: true})
804
- break
805
- }
806
- case 'done loading': {
807
- handleChange({type: 'loading', isLoading: false})
808
- break
809
- }
810
- case 'focused': {
811
- handleChange({type: 'focus', event: event.event})
812
- break
813
- }
814
- case 'value changed': {
815
- handleChange({type: 'value', value: event.value})
816
- break
817
- }
818
- case 'invalid value': {
819
- handleChange({
820
- type: 'invalidValue',
821
- resolution: event.resolution,
822
- value: event.value,
823
- })
824
- break
825
- }
826
- case 'error': {
827
- handleChange({
828
- ...event,
829
- level: 'warning',
830
- })
831
- break
832
- }
833
- case 'mutation': {
834
- handleChange(event)
835
- break
836
- }
837
- case 'ready': {
838
- handleChange(event)
839
- break
840
- }
841
- case 'selection': {
842
- handleChange(event)
843
- break
844
- }
845
- case 'unset': {
846
- handleChange(event)
847
- break
848
- }
849
- }
850
- })
851
- return () => {
852
- debug('Unsubscribing to changes')
853
- sub.unsubscribe()
854
- }
855
- }, [props.editorActor])
856
-
857
- return null
858
- }
@@ -1,9 +1,13 @@
1
+ import {createActor, type ActorRefFrom} from 'xstate'
1
2
  import {createCoreConverters} from '../converters/converters.core'
2
3
  import type {Editor, EditorConfig} from '../editor'
4
+ import {debugWithName} from '../internal-utils/debug'
3
5
  import {compileType} from '../internal-utils/schema'
6
+ import {fromSlateValue} from '../internal-utils/values'
7
+ import {KEY_TO_VALUE_ELEMENT} from '../internal-utils/weakMaps'
4
8
  import {corePriority} from '../priority/priority.core'
5
9
  import {createEditorPriority} from '../priority/priority.types'
6
- import type {EditableAPI} from '../types/editor'
10
+ import type {EditableAPI, PortableTextSlateEditor} from '../types/editor'
7
11
  import {createSlateEditor, type SlateEditor} from './create-slate-editor'
8
12
  import type {EditorActor} from './editor-machine'
9
13
  import {
@@ -13,7 +17,11 @@ import {
13
17
  import {getEditorSnapshot} from './editor-selector'
14
18
  import {defaultKeyGenerator} from './key-generator'
15
19
  import {createLegacySchema} from './legacy-schema'
20
+ import {mutationMachine} from './mutation-machine'
16
21
  import {createEditableAPI} from './plugins/createWithEditableAPI'
22
+ import {syncMachine} from './sync-machine'
23
+
24
+ const debug = debugWithName('createInternalEditor')
17
25
 
18
26
  export type InternalEditor = Editor & {
19
27
  _internal: {
@@ -56,6 +64,13 @@ export function editorConfigToMachineInput(config: EditorConfig) {
56
64
  export function createInternalEditor(editorActor: EditorActor): InternalEditor {
57
65
  const slateEditor = createSlateEditor({editorActor})
58
66
  const editable = createEditableAPI(slateEditor.instance, editorActor)
67
+ const {mutationActor, syncActor} = createActors({
68
+ editorActor,
69
+ slateEditor: slateEditor.instance,
70
+ })
71
+
72
+ mutationActor.start()
73
+ syncActor.start()
59
74
 
60
75
  return {
61
76
  getSnapshot: () =>
@@ -90,10 +105,13 @@ export function createInternalEditor(editorActor: EditorActor): InternalEditor {
90
105
  },
91
106
  send: (event) => {
92
107
  switch (event.type) {
108
+ case 'update value':
109
+ syncActor.send(event)
110
+ break
111
+
93
112
  case 'update key generator':
94
113
  case 'update readOnly':
95
114
  case 'patches':
96
- case 'update value':
97
115
  case 'update schema':
98
116
  case 'update maxBlocks':
99
117
  editorActor.send(event)
@@ -166,3 +184,115 @@ export function createInternalEditor(editorActor: EditorActor): InternalEditor {
166
184
  },
167
185
  }
168
186
  }
187
+
188
+ const actors = new WeakMap<
189
+ EditorActor,
190
+ {
191
+ syncActor: ActorRefFrom<typeof syncMachine>
192
+ mutationActor: ActorRefFrom<typeof mutationMachine>
193
+ }
194
+ >()
195
+
196
+ function createActors(config: {
197
+ editorActor: EditorActor
198
+ slateEditor: PortableTextSlateEditor
199
+ }): {
200
+ syncActor: ActorRefFrom<typeof syncMachine>
201
+ mutationActor: ActorRefFrom<typeof mutationMachine>
202
+ } {
203
+ const existingActor = actors.get(config.editorActor)
204
+
205
+ if (existingActor) {
206
+ debug('Reusing existing actors')
207
+ return existingActor
208
+ }
209
+
210
+ debug('Creating new actors')
211
+
212
+ const mutationActor = createActor(mutationMachine, {
213
+ input: {
214
+ schema: config.editorActor.getSnapshot().context.schema,
215
+ slateEditor: config.slateEditor,
216
+ },
217
+ })
218
+
219
+ const syncActor = createActor(syncMachine, {
220
+ input: {
221
+ initialValue: config.editorActor.getSnapshot().context.initialValue,
222
+ keyGenerator: config.editorActor.getSnapshot().context.keyGenerator,
223
+ readOnly: config.editorActor
224
+ .getSnapshot()
225
+ .matches({'edit mode': 'read only'}),
226
+ schema: config.editorActor.getSnapshot().context.schema,
227
+ slateEditor: config.slateEditor,
228
+ },
229
+ })
230
+
231
+ mutationActor.on('*', (event) => {
232
+ if (event.type === 'has pending patches') {
233
+ syncActor.send({type: 'has pending patches'})
234
+ }
235
+ if (event.type === 'mutation') {
236
+ syncActor.send({type: 'mutation'})
237
+ config.editorActor.send({
238
+ type: 'mutation',
239
+ patches: event.patches,
240
+ snapshot: event.snapshot,
241
+ value: event.snapshot,
242
+ })
243
+ }
244
+ })
245
+
246
+ syncActor.on('*', (event) => {
247
+ switch (event.type) {
248
+ case 'invalid value':
249
+ config.editorActor.send({
250
+ ...event,
251
+ type: 'notify.invalid value',
252
+ })
253
+ break
254
+ case 'value changed':
255
+ config.editorActor.send({
256
+ ...event,
257
+ type: 'notify.value changed',
258
+ })
259
+ break
260
+ case 'patch':
261
+ config.editorActor.send({
262
+ ...event,
263
+ type: 'internal.patch',
264
+ value: fromSlateValue(
265
+ config.slateEditor.children,
266
+ config.editorActor.getSnapshot().context.schema.block.name,
267
+ KEY_TO_VALUE_ELEMENT.get(config.slateEditor),
268
+ ),
269
+ })
270
+ break
271
+
272
+ default:
273
+ config.editorActor.send(event)
274
+ }
275
+ })
276
+
277
+ config.editorActor.on('*', (event) => {
278
+ if (event.type === 'read only') {
279
+ syncActor.send({type: 'update readOnly', readOnly: true})
280
+ }
281
+ if (event.type === 'editable') {
282
+ syncActor.send({type: 'update readOnly', readOnly: false})
283
+ }
284
+ if (event.type === 'internal.patch') {
285
+ mutationActor.send({...event, type: 'patch'})
286
+ }
287
+ })
288
+
289
+ actors.set(config.editorActor, {
290
+ syncActor,
291
+ mutationActor,
292
+ })
293
+
294
+ return {
295
+ syncActor,
296
+ mutationActor,
297
+ }
298
+ }
@@ -71,10 +71,6 @@ export type ExternalEditorEvent =
71
71
  type: 'update key generator'
72
72
  keyGenerator: () => string
73
73
  }
74
- | {
75
- type: 'update value'
76
- value: Array<PortableTextBlock> | undefined
77
- }
78
74
  | {
79
75
  type: 'update maxBlocks'
80
76
  maxBlocks: number | undefined
@@ -227,7 +223,7 @@ export const editorMachine = setup({
227
223
  initialReadOnly: boolean
228
224
  maxBlocks: number | undefined
229
225
  selection: EditorSelection
230
- incomingValue: Array<PortableTextBlock> | undefined
226
+ initialValue: Array<PortableTextBlock> | undefined
231
227
  internalDrag?: {
232
228
  ghost?: HTMLElement
233
229
  origin: Pick<EventPosition, 'selection'>
@@ -402,7 +398,7 @@ export const editorMachine = setup({
402
398
  selection: null,
403
399
  initialReadOnly: input.readOnly ?? false,
404
400
  maxBlocks: input.maxBlocks,
405
- incomingValue: input.initialValue,
401
+ initialValue: input.initialValue,
406
402
  }),
407
403
  on: {
408
404
  'notify.blurred': {
@@ -434,9 +430,6 @@ export const editorMachine = setup({
434
430
  actions: assign({keyGenerator: ({event}) => event.keyGenerator}),
435
431
  },
436
432
  'update schema': {actions: 'assign schema'},
437
- 'update value': {
438
- actions: assign({incomingValue: ({event}) => event.value}),
439
- },
440
433
  'update maxBlocks': {
441
434
  actions: assign({maxBlocks: ({event}) => event.maxBlocks}),
442
435
  },
@@ -3,7 +3,6 @@ import type React from 'react'
3
3
  import {useMemo} from 'react'
4
4
  import {Slate} from 'slate-react'
5
5
  import type {EditorConfig} from '../editor'
6
- import {Synchronizer} from './components/Synchronizer'
7
6
  import {createInternalEditor, editorConfigToMachineInput} from './create-editor'
8
7
  import {EditorActorContext} from './editor-actor-context'
9
8
  import {EditorContext} from './editor-context'
@@ -12,9 +11,9 @@ import {PortableTextEditorContext} from './hooks/usePortableTextEditor'
12
11
  import {PortableTextEditorSelectionProvider} from './hooks/usePortableTextEditorSelection'
13
12
  import {
14
13
  PortableTextEditor,
15
- RouteEventsToChanges,
16
14
  type PortableTextEditorProps,
17
15
  } from './PortableTextEditor'
16
+ import {RouteEventsToChanges} from './route-events-to-changes'
18
17
 
19
18
  /**
20
19
  * @public
@@ -66,10 +65,6 @@ export function EditorProvider(props: EditorProviderProps) {
66
65
  portableTextEditor.change$.next(change)
67
66
  }}
68
67
  />
69
- <Synchronizer
70
- editorActor={editorActor}
71
- slateEditor={internalEditor._internal.slateEditor.instance}
72
- />
73
68
  <EditorActorContext.Provider value={editorActor}>
74
69
  <Slate
75
70
  editor={internalEditor._internal.slateEditor.instance}
@@ -0,0 +1,81 @@
1
+ import {useEffect} from 'react'
2
+ import {useEffectEvent} from 'use-effect-event'
3
+ import type {EditorChange} from '../types/editor'
4
+ import type {EditorActor, InternalEditorEmittedEvent} from './editor-machine'
5
+
6
+ export function RouteEventsToChanges(props: {
7
+ editorActor: EditorActor
8
+ onChange: (change: EditorChange) => void
9
+ }) {
10
+ // We want to ensure that _when_ `props.onChange` is called, it uses the current value.
11
+ // But we don't want to have the `useEffect` run setup + teardown + setup every time the prop might change, as that's unnecessary.
12
+ // So we use our own polyfill that lets us use an upcoming React hook that solves this exact problem.
13
+ // https://19.react.dev/learn/separating-events-from-effects#declaring-an-effect-event
14
+ const handleChange = useEffectEvent((change: EditorChange) =>
15
+ props.onChange(change),
16
+ )
17
+
18
+ useEffect(() => {
19
+ const sub = props.editorActor.on('*', (event) => {
20
+ const change = eventToChange(event)
21
+
22
+ if (change) {
23
+ handleChange(change)
24
+ }
25
+ })
26
+ return () => {
27
+ sub.unsubscribe()
28
+ }
29
+ }, [props.editorActor])
30
+
31
+ return null
32
+ }
33
+
34
+ export function eventToChange(
35
+ event: InternalEditorEmittedEvent,
36
+ ): EditorChange | undefined {
37
+ switch (event.type) {
38
+ case 'blurred': {
39
+ return {type: 'blur', event: event.event}
40
+ }
41
+ case 'patch':
42
+ return event
43
+ case 'loading': {
44
+ return {type: 'loading', isLoading: true}
45
+ }
46
+ case 'done loading': {
47
+ return {type: 'loading', isLoading: false}
48
+ }
49
+ case 'focused': {
50
+ return {type: 'focus', event: event.event}
51
+ }
52
+ case 'value changed': {
53
+ return {type: 'value', value: event.value}
54
+ }
55
+ case 'invalid value': {
56
+ return {
57
+ type: 'invalidValue',
58
+ resolution: event.resolution,
59
+ value: event.value,
60
+ }
61
+ }
62
+ case 'error': {
63
+ return {
64
+ ...event,
65
+ level: 'warning',
66
+ }
67
+ }
68
+ case 'mutation': {
69
+ return event
70
+ }
71
+ case 'ready': {
72
+ return event
73
+ }
74
+ case 'selection': {
75
+ return event
76
+ }
77
+ case 'unset': {
78
+ return event
79
+ }
80
+ }
81
+ }
@@ -9,6 +9,7 @@ import {
9
9
  emit,
10
10
  fromCallback,
11
11
  not,
12
+ raise,
12
13
  setup,
13
14
  type AnyEventObject,
14
15
  type CallbackLogicFunction,
@@ -91,6 +92,7 @@ const syncValueLogic = fromCallback(syncValueCallback)
91
92
  export const syncMachine = setup({
92
93
  types: {
93
94
  context: {} as {
95
+ initialValue: Array<PortableTextBlock> | undefined
94
96
  initialValueSynced: boolean
95
97
  isProcessingLocalChanges: boolean
96
98
  keyGenerator: () => string
@@ -101,6 +103,7 @@ export const syncMachine = setup({
101
103
  previousValue: Array<PortableTextBlock> | undefined
102
104
  },
103
105
  input: {} as {
106
+ initialValue: Array<PortableTextBlock> | undefined
104
107
  keyGenerator: () => string
105
108
  schema: EditorSchema
106
109
  readOnly: boolean
@@ -175,6 +178,16 @@ export const syncMachine = setup({
175
178
 
176
179
  return isBusy
177
180
  },
181
+ 'is empty value': ({event}) => {
182
+ return event.type === 'update value' && event.value === undefined
183
+ },
184
+ 'is empty array': ({event}) => {
185
+ return (
186
+ event.type === 'update value' &&
187
+ Array.isArray(event.value) &&
188
+ event.value.length === 0
189
+ )
190
+ },
178
191
  'is new value': ({context, event}) => {
179
192
  return (
180
193
  event.type === 'update value' && context.previousValue !== event.value
@@ -194,6 +207,7 @@ export const syncMachine = setup({
194
207
  }).createMachine({
195
208
  id: 'sync',
196
209
  context: ({input}) => ({
210
+ initialValue: input.initialValue,
197
211
  initialValueSynced: false,
198
212
  isProcessingLocalChanges: false,
199
213
  keyGenerator: input.keyGenerator,
@@ -203,6 +217,11 @@ export const syncMachine = setup({
203
217
  pendingValue: undefined,
204
218
  previousValue: undefined,
205
219
  }),
220
+ entry: [
221
+ raise(({context}) => {
222
+ return {type: 'update value', value: context.initialValue}
223
+ }),
224
+ ],
206
225
  on: {
207
226
  'has pending patches': {
208
227
  actions: assign({
@@ -233,6 +252,18 @@ export const syncMachine = setup({
233
252
  ],
234
253
  on: {
235
254
  'update value': [
255
+ {
256
+ guard: and(['is empty value', not('initial value synced')]),
257
+ actions: ['assign initial value synced', 'emit done syncing value'],
258
+ },
259
+ {
260
+ guard: and(['is empty array', not('initial value synced')]),
261
+ actions: [
262
+ 'assign initial value synced',
263
+ emit({type: 'value changed', value: []}),
264
+ 'emit done syncing value',
265
+ ],
266
+ },
236
267
  {
237
268
  guard: and(['is busy', 'is new value']),
238
269
  target: 'busy',
@@ -383,6 +414,7 @@ async function updateValue({
383
414
  streamBlocks: boolean
384
415
  value: PortableTextBlock[] | undefined
385
416
  }) {
417
+ let doneSyncing = false
386
418
  let isChanged = false
387
419
  let isValid = true
388
420
 
@@ -394,6 +426,10 @@ async function updateValue({
394
426
  Editor.withoutNormalizing(slateEditor, () => {
395
427
  withoutSaving(slateEditor, () => {
396
428
  withoutPatching(slateEditor, () => {
429
+ if (doneSyncing) {
430
+ return
431
+ }
432
+
397
433
  if (hadSelection) {
398
434
  Transforms.deselect(slateEditor)
399
435
  }
@@ -428,6 +464,11 @@ async function updateValue({
428
464
  Editor.withoutNormalizing(slateEditor, () => {
429
465
  withRemoteChanges(slateEditor, () => {
430
466
  withoutPatching(slateEditor, () => {
467
+ if (doneSyncing) {
468
+ resolve()
469
+ return
470
+ }
471
+
431
472
  isChanged = removeExtraBlocks({
432
473
  slateEditor,
433
474
  slateValueFromProps,
@@ -465,6 +506,10 @@ async function updateValue({
465
506
  Editor.withoutNormalizing(slateEditor, () => {
466
507
  withRemoteChanges(slateEditor, () => {
467
508
  withoutPatching(slateEditor, () => {
509
+ if (doneSyncing) {
510
+ return
511
+ }
512
+
468
513
  isChanged = removeExtraBlocks({
469
514
  slateEditor,
470
515
  slateValueFromProps,
@@ -494,6 +539,7 @@ async function updateValue({
494
539
 
495
540
  if (!isValid) {
496
541
  debug('Invalid value, returning')
542
+ doneSyncing = true
497
543
  sendBack({type: 'done syncing', value})
498
544
  return
499
545
  }
@@ -509,6 +555,7 @@ async function updateValue({
509
555
  resolution: null,
510
556
  value,
511
557
  })
558
+ doneSyncing = true
512
559
  sendBack({type: 'done syncing', value})
513
560
  return
514
561
  }
@@ -524,6 +571,7 @@ async function updateValue({
524
571
  debug('Server value and editor value is equal, no need to sync.')
525
572
  }
526
573
 
574
+ doneSyncing = true
527
575
  sendBack({type: 'done syncing', value})
528
576
  }
529
577
 
package/src/editor.ts CHANGED
@@ -41,7 +41,13 @@ export type EditorConfig = {
41
41
  /**
42
42
  * @public
43
43
  */
44
- export type EditorEvent = ExternalEditorEvent | ExternalBehaviorEvent
44
+ export type EditorEvent =
45
+ | ExternalEditorEvent
46
+ | ExternalBehaviorEvent
47
+ | {
48
+ type: 'update value'
49
+ value: Array<PortableTextBlock> | undefined
50
+ }
45
51
 
46
52
  /**
47
53
  * @public
@@ -98,7 +98,9 @@ export function getTextSelection(
98
98
  }
99
99
 
100
100
  if (!anchor || !focus) {
101
- throw new Error(`Unable to find selection for text "${text}"`)
101
+ throw new Error(
102
+ `Unable to find selection for text "${text}" in value "${JSON.stringify(value)}"`,
103
+ )
102
104
  }
103
105
 
104
106
  return {