@portabletext/editor 1.35.2 → 1.35.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "1.35.2",
3
+ "version": "1.35.4",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -73,7 +73,7 @@
73
73
  "get-random-values-esm": "^1.0.2",
74
74
  "lodash": "^4.17.21",
75
75
  "lodash.startcase": "^4.4.0",
76
- "react-compiler-runtime": "19.0.0-beta-21e868a-20250216",
76
+ "react-compiler-runtime": "19.0.0-beta-e1e972c-20250221",
77
77
  "slate": "0.112.0",
78
78
  "slate-dom": "^0.112.2",
79
79
  "slate-react": "0.112.1",
@@ -100,9 +100,9 @@
100
100
  "@vitejs/plugin-react": "^4.3.4",
101
101
  "@vitest/browser": "^3.0.5",
102
102
  "@vitest/coverage-istanbul": "^3.0.5",
103
- "babel-plugin-react-compiler": "19.0.0-beta-21e868a-20250216",
103
+ "babel-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221",
104
104
  "eslint": "8.57.1",
105
- "eslint-plugin-react-compiler": "19.0.0-beta-21e868a-20250216",
105
+ "eslint-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221",
106
106
  "eslint-plugin-react-hooks": "experimental",
107
107
  "jsdom": "^26.0.0",
108
108
  "react": "^19.0.0",
@@ -147,7 +147,39 @@ describe('Feature: Self-solving', () => {
147
147
  })
148
148
  expect(onChange).toHaveBeenNthCalledWith(8, {
149
149
  type: 'mutation',
150
- patches: [spanPatch, blockPatch, strongPatch],
150
+ patches: [spanPatch, blockPatch],
151
+ snapshot: [
152
+ block({
153
+ _key: 'b1',
154
+ children: [
155
+ span({
156
+ _key: 's1',
157
+ text: 'foo',
158
+ marks: [],
159
+ }),
160
+ ],
161
+ style: 'normal',
162
+ markDefs: [],
163
+ }),
164
+ ],
165
+ value: [
166
+ block({
167
+ _key: 'b1',
168
+ children: [
169
+ span({
170
+ _key: 's1',
171
+ text: 'foo',
172
+ marks: [],
173
+ }),
174
+ ],
175
+ style: 'normal',
176
+ markDefs: [],
177
+ }),
178
+ ],
179
+ })
180
+ expect(onChange).toHaveBeenNthCalledWith(9, {
181
+ type: 'mutation',
182
+ patches: [strongPatch],
151
183
  snapshot: [
152
184
  block({
153
185
  _key: 'b1',
@@ -1,6 +1,8 @@
1
1
  import {useActorRef, useSelector} from '@xstate/react'
2
2
  import {useEffect} from 'react'
3
3
  import {debugWithName} from '../../internal-utils/debug'
4
+ import {fromSlateValue} from '../../internal-utils/values'
5
+ import {KEY_TO_VALUE_ELEMENT} from '../../internal-utils/weakMaps'
4
6
  import type {PortableTextSlateEditor} from '../../types/editor'
5
7
  import type {EditorActor} from '../editor-machine'
6
8
  import {mutationMachine} from '../mutation-machine'
@@ -80,6 +82,18 @@ export function Synchronizer(props: SynchronizerProps) {
80
82
  type: 'notify.value changed',
81
83
  })
82
84
  break
85
+ case 'patch':
86
+ props.editorActor.send({
87
+ ...event,
88
+ type: 'internal.patch',
89
+ value: fromSlateValue(
90
+ slateEditor.children,
91
+ props.editorActor.getSnapshot().context.schema.block.name,
92
+ KEY_TO_VALUE_ELEMENT.get(slateEditor),
93
+ ),
94
+ })
95
+ break
96
+
83
97
  default:
84
98
  props.editorActor.send(event)
85
99
  }
@@ -88,7 +102,7 @@ export function Synchronizer(props: SynchronizerProps) {
88
102
  return () => {
89
103
  subscription.unsubscribe()
90
104
  }
91
- }, [props.editorActor, syncActorRef])
105
+ }, [props.editorActor, slateEditor, syncActorRef])
92
106
 
93
107
  useEffect(() => {
94
108
  syncActorRef.send({type: 'update readOnly', readOnly})
@@ -102,8 +116,8 @@ export function Synchronizer(props: SynchronizerProps) {
102
116
  // Subscribe to, and handle changes from the editor
103
117
  useEffect(() => {
104
118
  debug('Subscribing to patch events')
105
- const sub = editorActor.on('patch', (event) => {
106
- mutationActorRef.send(event)
119
+ const sub = editorActor.on('internal.patch', (event) => {
120
+ mutationActorRef.send({...event, type: 'patch'})
107
121
  })
108
122
  return () => {
109
123
  debug('Unsubscribing to patch events')
@@ -151,6 +151,11 @@ type PatchEvent = {
151
151
  patch: Patch
152
152
  }
153
153
 
154
+ type InternalPatchEvent = NamespaceEvent<PatchEvent, 'internal'> & {
155
+ actionId?: string
156
+ value: Array<PortableTextBlock>
157
+ }
158
+
154
159
  type UnsetEvent = {
155
160
  type: 'unset'
156
161
  previousValue: Array<PortableTextBlock>
@@ -191,9 +196,9 @@ export type InternalEditorEvent =
191
196
  | CustomBehaviorEvent
192
197
  | ExternalEditorEvent
193
198
  | MutationEvent
199
+ | InternalPatchEvent
194
200
  | NamespaceEvent<EditorEmittedEvent, 'notify'>
195
201
  | NamespaceEvent<UnsetEvent, 'notify'>
196
- | PatchEvent
197
202
  | SyntheticBehaviorEvent
198
203
  | {type: 'dragstart'}
199
204
  | {type: 'dragend'}
@@ -204,6 +209,7 @@ export type InternalEditorEvent =
204
209
  */
205
210
  export type InternalEditorEmittedEvent =
206
211
  | EditorEmittedEvent
212
+ | InternalPatchEvent
207
213
  | PatchesEvent
208
214
  | UnsetEvent
209
215
  | {
@@ -221,7 +227,7 @@ export const editorMachine = setup({
221
227
  behaviors: Set<Behavior>
222
228
  converters: Set<Converter>
223
229
  keyGenerator: () => string
224
- pendingEvents: Array<PatchEvent | MutationEvent>
230
+ pendingEvents: Array<InternalPatchEvent | MutationEvent>
225
231
  schema: EditorSchema
226
232
  initialReadOnly: boolean
227
233
  maxBlocks: number | undefined
@@ -270,9 +276,11 @@ export const editorMachine = setup({
270
276
  return event.schema
271
277
  },
272
278
  }),
273
- 'emit patch event': emit(({event}) => {
274
- assertEvent(event, 'patch')
275
- return event
279
+ 'emit patch event': enqueueActions(({event, enqueue}) => {
280
+ assertEvent(event, 'internal.patch')
281
+
282
+ enqueue.emit(event)
283
+ enqueue.emit({type: 'patch', patch: event.patch})
276
284
  }),
277
285
  'emit mutation event': emit(({event}) => {
278
286
  assertEvent(event, 'mutation')
@@ -282,13 +290,18 @@ export const editorMachine = setup({
282
290
  'emit editable': emit({type: 'editable'}),
283
291
  'defer event': assign({
284
292
  pendingEvents: ({context, event}) => {
285
- assertEvent(event, ['patch', 'mutation'])
293
+ assertEvent(event, ['internal.patch', 'mutation'])
286
294
  return [...context.pendingEvents, event]
287
295
  },
288
296
  }),
289
297
  'emit pending events': enqueueActions(({context, enqueue}) => {
290
298
  for (const event of context.pendingEvents) {
291
- enqueue(emit(event))
299
+ if (event.type === 'internal.patch') {
300
+ enqueue.emit(event)
301
+ enqueue.emit({type: 'patch', patch: event.patch})
302
+ } else {
303
+ enqueue.emit(event)
304
+ }
292
305
  }
293
306
  }),
294
307
  'emit ready': emit({type: 'ready'}),
@@ -664,7 +677,7 @@ export const editorMachine = setup({
664
677
  'setting up': {
665
678
  exit: ['emit ready'],
666
679
  on: {
667
- 'patch': {
680
+ 'internal.patch': {
668
681
  actions: 'defer event',
669
682
  },
670
683
  'mutation': {
@@ -680,14 +693,14 @@ export const editorMachine = setup({
680
693
  states: {
681
694
  idle: {
682
695
  on: {
683
- normalizing: {
696
+ 'normalizing': {
684
697
  target: 'normalizing',
685
698
  },
686
- patch: {
699
+ 'internal.patch': {
687
700
  actions: 'defer event',
688
701
  target: '#editor.setup.dirty',
689
702
  },
690
- mutation: {
703
+ 'mutation': {
691
704
  actions: 'defer event',
692
705
  target: '#editor.setup.dirty',
693
706
  },
@@ -698,7 +711,7 @@ export const editorMachine = setup({
698
711
  'done normalizing': {
699
712
  target: 'idle',
700
713
  },
701
- 'patch': {
714
+ 'internal.patch': {
702
715
  actions: 'defer event',
703
716
  },
704
717
  'mutation': {
@@ -711,10 +724,10 @@ export const editorMachine = setup({
711
724
  'dirty': {
712
725
  entry: ['emit pending events', 'clear pending events'],
713
726
  on: {
714
- patch: {
727
+ 'internal.patch': {
715
728
  actions: 'emit patch event',
716
729
  },
717
- mutation: {
730
+ 'mutation': {
718
731
  actions: 'emit mutation event',
719
732
  },
720
733
  },
@@ -1,25 +1,48 @@
1
1
  import type {Patch} from '@portabletext/patches'
2
2
  import type {PortableTextBlock} from '@sanity/types'
3
3
  import {Editor} from 'slate'
4
- import {assign, emit, setup} from 'xstate'
5
- import {fromSlateValue} from '../internal-utils/values'
6
- import {KEY_TO_VALUE_ELEMENT} from '../internal-utils/weakMaps'
4
+ import {
5
+ and,
6
+ assertEvent,
7
+ assign,
8
+ emit,
9
+ enqueueActions,
10
+ fromCallback,
11
+ not,
12
+ setup,
13
+ stateIn,
14
+ type AnyEventObject,
15
+ } from 'xstate'
7
16
  import type {PortableTextSlateEditor} from '../types/editor'
8
17
  import type {EditorSchema} from './define-schema'
9
18
 
10
- const FLUSH_PATCHES_THROTTLED_MS = process.env.NODE_ENV === 'test' ? 500 : 1000
11
-
12
19
  /**
13
20
  * Makes sure editor mutation events are debounced
14
21
  */
15
22
  export const mutationMachine = setup({
16
23
  types: {
17
24
  context: {} as {
18
- pendingPatches: Array<Patch>
25
+ pendingMutations: Array<{
26
+ actionId?: string
27
+ value: Array<PortableTextBlock> | undefined
28
+ patches: Array<Patch>
29
+ }>
19
30
  schema: EditorSchema
20
31
  slateEditor: PortableTextSlateEditor
21
32
  },
22
- events: {} as {type: 'patch'; patch: Patch},
33
+ events: {} as
34
+ | {
35
+ type: 'patch'
36
+ patch: Patch
37
+ actionId?: string
38
+ value: Array<PortableTextBlock>
39
+ }
40
+ | {
41
+ type: 'typing'
42
+ }
43
+ | {
44
+ type: 'not typing'
45
+ },
23
46
  input: {} as {
24
47
  schema: EditorSchema
25
48
  slateEditor: PortableTextSlateEditor
@@ -36,65 +59,156 @@ export const mutationMachine = setup({
36
59
  },
37
60
  actions: {
38
61
  'emit has pending patches': emit({type: 'has pending patches'}),
39
- 'emit mutation': emit(({context}) => ({
40
- type: 'mutation' as const,
41
- patches: context.pendingPatches,
42
- snapshot: fromSlateValue(
43
- context.slateEditor.children,
44
- context.schema.block.name,
45
- KEY_TO_VALUE_ELEMENT.get(context.slateEditor),
46
- ),
47
- })),
48
- 'clear pending patches': assign({
49
- pendingPatches: [],
62
+ 'emit mutations': enqueueActions(({context, enqueue}) => {
63
+ for (const bulk of context.pendingMutations) {
64
+ enqueue.emit({
65
+ type: 'mutation',
66
+ patches: bulk.patches,
67
+ snapshot: bulk.value,
68
+ })
69
+ }
70
+ }),
71
+ 'clear pending mutations': assign({
72
+ pendingMutations: [],
50
73
  }),
51
74
  'defer patch': assign({
52
- pendingPatches: ({context, event}) => [
53
- ...context.pendingPatches,
54
- event.patch,
55
- ],
75
+ pendingMutations: ({context, event}) => {
76
+ assertEvent(event, 'patch')
77
+
78
+ if (context.pendingMutations.length === 0) {
79
+ return [
80
+ {
81
+ actionId: event.actionId,
82
+ value: event.value,
83
+ patches: [event.patch],
84
+ },
85
+ ]
86
+ }
87
+
88
+ const lastBulk = context.pendingMutations.at(-1)
89
+
90
+ if (lastBulk && lastBulk.actionId === event.actionId) {
91
+ return context.pendingMutations.slice(0, -1).concat({
92
+ value: event.value,
93
+ actionId: lastBulk.actionId,
94
+ patches: [...lastBulk.patches, event.patch],
95
+ })
96
+ }
97
+
98
+ return context.pendingMutations.concat({
99
+ value: event.value,
100
+ actionId: event.actionId,
101
+ patches: [event.patch],
102
+ })
103
+ },
104
+ }),
105
+ },
106
+ actors: {
107
+ 'type listener': fromCallback<
108
+ AnyEventObject,
109
+ {slateEditor: PortableTextSlateEditor},
110
+ {type: 'typing'} | {type: 'not typing'}
111
+ >(({input, sendBack}) => {
112
+ const originalApply = input.slateEditor.apply
113
+
114
+ input.slateEditor.apply = (op) => {
115
+ if (op.type === 'insert_text' || op.type === 'remove_text') {
116
+ sendBack({type: 'typing'})
117
+ } else {
118
+ sendBack({type: 'not typing'})
119
+ }
120
+ originalApply(op)
121
+ }
122
+
123
+ return () => {
124
+ input.slateEditor.apply = originalApply
125
+ }
56
126
  }),
57
127
  },
58
128
  guards: {
129
+ 'is typing': stateIn({typing: 'typing'}),
130
+ 'no pending mutations': ({context}) =>
131
+ context.pendingMutations.length === 0,
59
132
  'slate is normalizing': ({context}) =>
60
133
  Editor.isNormalizing(context.slateEditor),
61
134
  },
135
+ delays: {
136
+ 'mutation debounce': process.env.NODE_ENV === 'test' ? 250 : 0,
137
+ 'type debounce': process.env.NODE_ENV === 'test' ? 0 : 250,
138
+ },
62
139
  }).createMachine({
63
140
  id: 'mutation',
64
141
  context: ({input}) => ({
65
- pendingPatches: [],
142
+ pendingMutations: [],
66
143
  schema: input.schema,
67
144
  slateEditor: input.slateEditor,
68
145
  }),
69
- initial: 'idle',
146
+ type: 'parallel',
70
147
  states: {
71
- 'idle': {
72
- on: {
73
- patch: {
74
- actions: ['defer patch', 'emit has pending patches'],
75
- target: 'has pending patches',
148
+ typing: {
149
+ initial: 'idle',
150
+ invoke: {
151
+ src: 'type listener',
152
+ input: ({context}) => ({slateEditor: context.slateEditor}),
153
+ },
154
+ states: {
155
+ idle: {
156
+ on: {
157
+ typing: {
158
+ target: 'typing',
159
+ },
160
+ },
161
+ },
162
+ typing: {
163
+ after: {
164
+ 'type debounce': {
165
+ target: 'idle',
166
+ },
167
+ },
168
+ on: {
169
+ 'not typing': {
170
+ target: 'idle',
171
+ },
172
+ 'typing': {
173
+ target: 'typing',
174
+ reenter: true,
175
+ },
176
+ },
76
177
  },
77
178
  },
78
179
  },
79
- 'has pending patches': {
80
- after: {
81
- [FLUSH_PATCHES_THROTTLED_MS]: [
82
- {
83
- guard: 'slate is normalizing',
84
- target: 'idle',
85
- actions: ['emit mutation', 'clear pending patches'],
180
+ mutations: {
181
+ initial: 'idle',
182
+ states: {
183
+ 'idle': {
184
+ on: {
185
+ patch: {
186
+ actions: ['defer patch', 'emit has pending patches'],
187
+ target: 'emitting mutations',
188
+ },
86
189
  },
87
- {
88
- target: 'has pending patches',
89
- reenter: true,
190
+ },
191
+ 'emitting mutations': {
192
+ after: {
193
+ 'mutation debounce': [
194
+ {
195
+ guard: and([not('is typing'), 'slate is normalizing']),
196
+ target: 'idle',
197
+ actions: ['emit mutations', 'clear pending mutations'],
198
+ },
199
+ {
200
+ target: 'emitting mutations',
201
+ reenter: true,
202
+ },
203
+ ],
204
+ },
205
+ on: {
206
+ patch: {
207
+ target: 'emitting mutations',
208
+ actions: ['defer patch'],
209
+ reenter: true,
210
+ },
90
211
  },
91
- ],
92
- },
93
- on: {
94
- patch: {
95
- target: 'has pending patches',
96
- actions: ['defer patch'],
97
- reenter: true,
98
212
  },
99
213
  },
100
214
  },
@@ -29,6 +29,7 @@ export function createWithEventListeners(
29
29
  case 'loading':
30
30
  case 'mutation':
31
31
  case 'patch':
32
+ case 'internal.patch':
32
33
  case 'patches':
33
34
  case 'read only':
34
35
  case 'ready':
@@ -30,6 +30,7 @@ import type {
30
30
  PortableTextSlateEditor,
31
31
  } from '../../types/editor'
32
32
  import type {EditorActor} from '../editor-machine'
33
+ import {getCurrentActionId} from '../with-applying-behavior-actions'
33
34
  import {withoutSaving} from './createWithUndoRedo'
34
35
 
35
36
  const debug = debugWithName('plugin:withPatches')
@@ -287,12 +288,18 @@ export function createWithPatches({
287
288
 
288
289
  // Emit all patches
289
290
  if (patches.length > 0) {
290
- patches.forEach((patch) => {
291
+ for (const patch of patches) {
291
292
  editorActor.send({
292
- type: 'patch',
293
+ type: 'internal.patch',
293
294
  patch: {...patch, origin: 'local'},
295
+ actionId: getCurrentActionId(editor),
296
+ value: fromSlateValue(
297
+ editor.children,
298
+ schemaTypes.block.name,
299
+ KEY_TO_VALUE_ELEMENT.get(editor),
300
+ ),
294
301
  })
295
- })
302
+ }
296
303
  }
297
304
  return editor
298
305
  }
@@ -1,18 +1,20 @@
1
1
  import {Editor} from 'slate'
2
2
  import {defaultKeyGenerator} from './key-generator'
3
3
 
4
- const IS_APPLYING_BEHAVIOR_ACTIONS: WeakMap<Editor, boolean | undefined> =
5
- new WeakMap()
4
+ const CURRENT_ACTION_ID: WeakMap<Editor, string | undefined> = new WeakMap()
6
5
 
7
6
  export function withApplyingBehaviorActions(editor: Editor, fn: () => void) {
8
- const prev = IS_APPLYING_BEHAVIOR_ACTIONS.get(editor)
9
- IS_APPLYING_BEHAVIOR_ACTIONS.set(editor, true)
7
+ CURRENT_ACTION_ID.set(editor, defaultKeyGenerator())
10
8
  Editor.withoutNormalizing(editor, fn)
11
- IS_APPLYING_BEHAVIOR_ACTIONS.set(editor, prev)
9
+ CURRENT_ACTION_ID.set(editor, undefined)
10
+ }
11
+
12
+ export function getCurrentActionId(editor: Editor) {
13
+ return CURRENT_ACTION_ID.get(editor)
12
14
  }
13
15
 
14
16
  export function isApplyingBehaviorActions(editor: Editor) {
15
- return IS_APPLYING_BEHAVIOR_ACTIONS.get(editor) ?? false
17
+ return getCurrentActionId(editor) !== undefined
16
18
  }
17
19
 
18
20
  ////////