@portabletext/editor 1.49.7 → 1.49.9

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.
@@ -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,10 +9,12 @@ import {
9
9
  emit,
10
10
  fromCallback,
11
11
  not,
12
+ raise,
12
13
  setup,
13
14
  type AnyEventObject,
14
15
  type CallbackLogicFunction,
15
16
  } from 'xstate'
17
+ import type {ActorRefFrom} from 'xstate'
16
18
  import {debugWithName} from '../internal-utils/debug'
17
19
  import {validateValue} from '../internal-utils/validateValue'
18
20
  import {toSlateValue, VOID_CHILD_KEY} from '../internal-utils/values'
@@ -76,6 +78,8 @@ const syncValueCallback: CallbackLogicFunction<
76
78
 
77
79
  const syncValueLogic = fromCallback(syncValueCallback)
78
80
 
81
+ export type SyncActor = ActorRefFrom<typeof syncMachine>
82
+
79
83
  /**
80
84
  * Sync value with the editor state
81
85
  *
@@ -91,6 +95,7 @@ const syncValueLogic = fromCallback(syncValueCallback)
91
95
  export const syncMachine = setup({
92
96
  types: {
93
97
  context: {} as {
98
+ initialValue: Array<PortableTextBlock> | undefined
94
99
  initialValueSynced: boolean
95
100
  isProcessingLocalChanges: boolean
96
101
  keyGenerator: () => string
@@ -101,6 +106,7 @@ export const syncMachine = setup({
101
106
  previousValue: Array<PortableTextBlock> | undefined
102
107
  },
103
108
  input: {} as {
109
+ initialValue: Array<PortableTextBlock> | undefined
104
110
  keyGenerator: () => string
105
111
  schema: EditorSchema
106
112
  readOnly: boolean
@@ -175,6 +181,16 @@ export const syncMachine = setup({
175
181
 
176
182
  return isBusy
177
183
  },
184
+ 'is empty value': ({event}) => {
185
+ return event.type === 'update value' && event.value === undefined
186
+ },
187
+ 'is empty array': ({event}) => {
188
+ return (
189
+ event.type === 'update value' &&
190
+ Array.isArray(event.value) &&
191
+ event.value.length === 0
192
+ )
193
+ },
178
194
  'is new value': ({context, event}) => {
179
195
  return (
180
196
  event.type === 'update value' && context.previousValue !== event.value
@@ -194,6 +210,7 @@ export const syncMachine = setup({
194
210
  }).createMachine({
195
211
  id: 'sync',
196
212
  context: ({input}) => ({
213
+ initialValue: input.initialValue,
197
214
  initialValueSynced: false,
198
215
  isProcessingLocalChanges: false,
199
216
  keyGenerator: input.keyGenerator,
@@ -203,6 +220,11 @@ export const syncMachine = setup({
203
220
  pendingValue: undefined,
204
221
  previousValue: undefined,
205
222
  }),
223
+ entry: [
224
+ raise(({context}) => {
225
+ return {type: 'update value', value: context.initialValue}
226
+ }),
227
+ ],
206
228
  on: {
207
229
  'has pending patches': {
208
230
  actions: assign({
@@ -233,6 +255,18 @@ export const syncMachine = setup({
233
255
  ],
234
256
  on: {
235
257
  'update value': [
258
+ {
259
+ guard: and(['is empty value', not('initial value synced')]),
260
+ actions: ['assign initial value synced', 'emit done syncing value'],
261
+ },
262
+ {
263
+ guard: and(['is empty array', not('initial value synced')]),
264
+ actions: [
265
+ 'assign initial value synced',
266
+ emit({type: 'value changed', value: []}),
267
+ 'emit done syncing value',
268
+ ],
269
+ },
236
270
  {
237
271
  guard: and(['is busy', 'is new value']),
238
272
  target: 'busy',
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
@@ -24,16 +24,16 @@ import type {
24
24
  } from '@sanity/types'
25
25
  import {
26
26
  Element,
27
+ Node,
27
28
  Text,
28
29
  Transforms,
29
30
  type Descendant,
30
- type Node,
31
31
  type Path as SlatePath,
32
32
  } from 'slate'
33
33
  import type {EditorSchema} from '../editor/editor-schema'
34
34
  import type {PortableTextSlateEditor} from '../types/editor'
35
35
  import {debugWithName} from './debug'
36
- import {toSlateValue} from './values'
36
+ import {isEqualToEmptyEditor, toSlateValue} from './values'
37
37
  import {KEY_TO_SLATE_ELEMENT} from './weakMaps'
38
38
 
39
39
  const debug = debugWithName('applyPatches')
@@ -173,9 +173,20 @@ function insertPatch(
173
173
  const targetBlockIndex = targetBlockPath[0]
174
174
  const normalizedIdx =
175
175
  position === 'after' ? targetBlockIndex + 1 : targetBlockIndex
176
+
176
177
  debug(`Inserting blocks at path [${normalizedIdx}]`)
177
178
  debugState(editor, 'before')
179
+
180
+ const editorWasEmptyBefore = isEqualToEmptyEditor(editor.children, schema)
181
+
178
182
  Transforms.insertNodes(editor, blocksToInsert, {at: [normalizedIdx]})
183
+
184
+ if (editorWasEmptyBefore) {
185
+ Transforms.removeNodes(editor, {
186
+ at: [position === 'after' ? targetBlockIndex + 1 : targetBlockIndex],
187
+ })
188
+ }
189
+
179
190
  debugState(editor, 'after')
180
191
  return true
181
192
  }
@@ -319,9 +330,15 @@ function unsetPatch(editor: PortableTextSlateEditor, patch: UnsetPatch) {
319
330
  debugState(editor, 'before')
320
331
  const previousSelection = editor.selection
321
332
  Transforms.deselect(editor)
322
- editor.children.forEach((_child, i) => {
323
- Transforms.removeNodes(editor, {at: [i]})
333
+
334
+ const children = Node.children(editor, [], {
335
+ reverse: true,
324
336
  })
337
+
338
+ for (const [_, path] of children) {
339
+ Transforms.removeNodes(editor, {at: path})
340
+ }
341
+
325
342
  Transforms.insertNodes(editor, editor.pteCreateTextBlock({decorators: []}))
326
343
  if (previousSelection) {
327
344
  Transforms.select(editor, {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Copy/pasted from https://github.com/statelyai/xstate/blob/main/packages/xstate-react/src/stopRootWithRehydration.ts
3
+ * and renamed to `stopActor`
4
+ */
5
+
6
+ import type {AnyActorRef, Snapshot} from 'xstate'
7
+
8
+ const forEachActor = (
9
+ actorRef: AnyActorRef,
10
+ callback: (ref: AnyActorRef) => void,
11
+ ) => {
12
+ callback(actorRef)
13
+ const children = actorRef.getSnapshot().children
14
+ if (children) {
15
+ Object.values(children).forEach((child) => {
16
+ forEachActor(child as AnyActorRef, callback)
17
+ })
18
+ }
19
+ }
20
+
21
+ export function stopActor(actorRef: AnyActorRef) {
22
+ // persist snapshot here in a custom way allows us to persist inline actors and to preserve actor references
23
+ // we do it to avoid setState in useEffect when the effect gets "reconnected"
24
+ // this currently only happens in Strict Effects but it simulates the Offscreen aka Activity API
25
+ // it also just allows us to end up with a somewhat more predictable behavior for the users
26
+ const persistedSnapshots: Array<[AnyActorRef, Snapshot<unknown>]> = []
27
+ forEachActor(actorRef, (ref) => {
28
+ persistedSnapshots.push([ref, ref.getSnapshot()])
29
+ // muting observers allow us to avoid `useSelector` from being notified about the stopped snapshot
30
+ // React reconnects its subscribers (from the useSyncExternalStore) on its own
31
+ // and userland subscibers should basically always do the same anyway
32
+ // as each subscription should have its own cleanup logic and that should be called each such reconnect
33
+ ;(ref as any).observers = new Set()
34
+ })
35
+ const systemSnapshot = actorRef.system.getSnapshot?.()
36
+
37
+ actorRef.stop()
38
+ ;(actorRef.system as any)._snapshot = systemSnapshot
39
+ persistedSnapshots.forEach(([ref, snapshot]) => {
40
+ ;(ref as any)._processingStatus = 0
41
+ ;(ref as any)._snapshot = snapshot
42
+ })
43
+ }
@@ -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 {
@@ -0,0 +1,15 @@
1
+ import React from 'react'
2
+
3
+ type ConstantRef<TConstant> = {constant: TConstant}
4
+
5
+ export default function useConstant<TConstant>(
6
+ factory: () => TConstant,
7
+ ): TConstant {
8
+ const ref = React.useRef<ConstantRef<TConstant>>(null)
9
+
10
+ if (!ref.current) {
11
+ ref.current = {constant: factory()}
12
+ }
13
+
14
+ return ref.current.constant
15
+ }
@@ -1,364 +0,0 @@
1
- import type {PortableTextBlock} from '@sanity/types'
2
- import {render, waitFor} from '@testing-library/react'
3
- import {createRef, type RefObject} from 'react'
4
- import {describe, expect, it, vi} from 'vitest'
5
- import {createTestKeyGenerator} from '../../internal-utils/test-key-generator'
6
- import {PortableTextEditor} from '../PortableTextEditor'
7
- import {PortableTextEditorTester, schemaType} from './PortableTextEditorTester'
8
-
9
- describe('when PTE would display warnings, instead it self solves', () => {
10
- it('when child at index is missing required _key in block with _key', async () => {
11
- const editorRef: RefObject<PortableTextEditor | null> = createRef()
12
- const initialValue = [
13
- {
14
- _key: 'abc',
15
- _type: 'myTestBlockType',
16
- children: [
17
- {
18
- _type: 'span',
19
- marks: [],
20
- text: 'Hello with a new key',
21
- },
22
- ],
23
- markDefs: [],
24
- style: 'normal',
25
- },
26
- ]
27
-
28
- const onChange = vi.fn()
29
- render(
30
- <PortableTextEditorTester
31
- keyGenerator={createTestKeyGenerator()}
32
- onChange={onChange}
33
- ref={editorRef}
34
- schemaType={schemaType}
35
- value={initialValue}
36
- />,
37
- )
38
- await waitFor(() => {
39
- expect(onChange).toHaveBeenCalledWith({
40
- type: 'value',
41
- value: initialValue,
42
- })
43
- expect(onChange).toHaveBeenCalledWith({type: 'ready'})
44
- })
45
- await waitFor(() => {
46
- if (editorRef.current) {
47
- expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
48
- {
49
- _key: 'abc',
50
- _type: 'myTestBlockType',
51
- children: [
52
- {
53
- _key: 'k3',
54
- _type: 'span',
55
- text: 'Hello with a new key',
56
- marks: [],
57
- },
58
- ],
59
- markDefs: [],
60
- style: 'normal',
61
- },
62
- ])
63
- }
64
- })
65
- })
66
-
67
- it('self-solves missing .markDefs', async () => {
68
- const editorRef: RefObject<PortableTextEditor | null> = createRef()
69
- const initialValue = [
70
- {
71
- _key: 'abc',
72
- _type: 'myTestBlockType',
73
- children: [
74
- {
75
- _key: 'def',
76
- _type: 'span',
77
- marks: [],
78
- text: 'No markDefs',
79
- },
80
- ],
81
- style: 'normal',
82
- },
83
- ]
84
-
85
- const onChange = vi.fn()
86
- render(
87
- <PortableTextEditorTester
88
- keyGenerator={createTestKeyGenerator()}
89
- onChange={onChange}
90
- ref={editorRef}
91
- schemaType={schemaType}
92
- value={initialValue}
93
- />,
94
- )
95
- await waitFor(() => {
96
- expect(onChange).toHaveBeenCalledWith({
97
- type: 'value',
98
- value: initialValue,
99
- })
100
- expect(onChange).toHaveBeenCalledWith({type: 'ready'})
101
- })
102
- await waitFor(() => {
103
- if (editorRef.current) {
104
- PortableTextEditor.focus(editorRef.current)
105
- expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
106
- {
107
- _key: 'abc',
108
- _type: 'myTestBlockType',
109
- children: [
110
- {
111
- _key: 'def',
112
- _type: 'span',
113
- text: 'No markDefs',
114
- marks: [],
115
- },
116
- ],
117
- markDefs: [],
118
- style: 'normal',
119
- },
120
- ])
121
- }
122
- })
123
- })
124
-
125
- it('adds missing .children', async () => {
126
- const editorRef: RefObject<PortableTextEditor | null> = createRef()
127
- const initialValue = [
128
- {
129
- _key: 'abc',
130
- _type: 'myTestBlockType',
131
- style: 'normal',
132
- markDefs: [],
133
- },
134
- {
135
- _key: 'def',
136
- _type: 'myTestBlockType',
137
- style: 'normal',
138
- children: [],
139
- markDefs: [],
140
- },
141
- ]
142
-
143
- const onChange = vi.fn()
144
- render(
145
- <PortableTextEditorTester
146
- keyGenerator={createTestKeyGenerator()}
147
- onChange={onChange}
148
- ref={editorRef}
149
- schemaType={schemaType}
150
- value={initialValue}
151
- />,
152
- )
153
- await waitFor(() => {
154
- expect(onChange).toHaveBeenCalledWith({
155
- type: 'value',
156
- value: initialValue,
157
- })
158
- expect(onChange).toHaveBeenCalledWith({type: 'ready'})
159
- })
160
- await waitFor(() => {
161
- if (editorRef.current) {
162
- PortableTextEditor.focus(editorRef.current)
163
- expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
164
- {
165
- _key: 'abc',
166
- _type: 'myTestBlockType',
167
- children: [
168
- {
169
- _key: 'k3',
170
- _type: 'span',
171
- text: '',
172
- marks: [],
173
- },
174
- ],
175
- markDefs: [],
176
- style: 'normal',
177
- },
178
- {
179
- _key: 'def',
180
- _type: 'myTestBlockType',
181
- children: [
182
- {
183
- _key: 'k5',
184
- _type: 'span',
185
- text: '',
186
- marks: [],
187
- },
188
- ],
189
- markDefs: [],
190
- style: 'normal',
191
- },
192
- ])
193
- }
194
- })
195
- })
196
-
197
- it('removes orphaned marks', async () => {
198
- const editorRef: RefObject<PortableTextEditor | null> = createRef()
199
- const initialValue = [
200
- {
201
- _key: 'abc',
202
- _type: 'myTestBlockType',
203
- style: 'normal',
204
- markDefs: [],
205
- children: [
206
- {
207
- _key: 'def',
208
- _type: 'span',
209
- marks: ['ghi'],
210
- text: 'Hello',
211
- },
212
- ],
213
- },
214
- ]
215
-
216
- const onChange = vi.fn()
217
- render(
218
- <PortableTextEditorTester
219
- keyGenerator={createTestKeyGenerator()}
220
- onChange={onChange}
221
- ref={editorRef}
222
- schemaType={schemaType}
223
- value={initialValue}
224
- />,
225
- )
226
- await waitFor(() => {
227
- expect(onChange).toHaveBeenCalledWith({
228
- type: 'value',
229
- value: initialValue,
230
- })
231
- expect(onChange).toHaveBeenCalledWith({type: 'ready'})
232
- })
233
- await waitFor(() => {
234
- if (editorRef.current) {
235
- PortableTextEditor.focus(editorRef.current)
236
- expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
237
- {
238
- _key: 'abc',
239
- _type: 'myTestBlockType',
240
- children: [
241
- {
242
- _key: 'def',
243
- _type: 'span',
244
- text: 'Hello',
245
- marks: [],
246
- },
247
- ],
248
- markDefs: [],
249
- style: 'normal',
250
- },
251
- ])
252
- }
253
- })
254
- })
255
-
256
- it('removes orphaned marksDefs', async () => {
257
- const editorRef: RefObject<PortableTextEditor | null> = createRef()
258
- const initialValue = [
259
- {
260
- _key: 'abc',
261
- _type: 'myTestBlockType',
262
- style: 'normal',
263
- markDefs: [
264
- {
265
- _key: 'ghi',
266
- _type: 'link',
267
- href: 'https://sanity.io',
268
- },
269
- ],
270
- children: [
271
- {
272
- _key: 'def',
273
- _type: 'span',
274
- marks: [],
275
- text: 'Hello',
276
- },
277
- ],
278
- },
279
- ]
280
-
281
- const onChange = vi.fn()
282
- render(
283
- <PortableTextEditorTester
284
- keyGenerator={createTestKeyGenerator()}
285
- onChange={onChange}
286
- ref={editorRef}
287
- schemaType={schemaType}
288
- value={initialValue}
289
- />,
290
- )
291
- await waitFor(() => {
292
- expect(onChange).toHaveBeenCalledWith({
293
- type: 'value',
294
- value: initialValue,
295
- })
296
- expect(onChange).toHaveBeenCalledWith({type: 'ready'})
297
- })
298
- await waitFor(() => {
299
- if (editorRef.current) {
300
- PortableTextEditor.focus(editorRef.current)
301
- expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
302
- {
303
- _key: 'abc',
304
- _type: 'myTestBlockType',
305
- children: [
306
- {
307
- _key: 'def',
308
- _type: 'span',
309
- text: 'Hello',
310
- marks: [],
311
- },
312
- ],
313
- markDefs: [],
314
- style: 'normal',
315
- },
316
- ])
317
- }
318
- })
319
- })
320
-
321
- it('allows empty array of blocks', async () => {
322
- const editorRef: RefObject<PortableTextEditor | null> = createRef()
323
- const initialValue = [] as PortableTextBlock[]
324
-
325
- const onChange = vi.fn()
326
- render(
327
- <PortableTextEditorTester
328
- keyGenerator={createTestKeyGenerator()}
329
- onChange={onChange}
330
- ref={editorRef}
331
- schemaType={schemaType}
332
- value={initialValue}
333
- />,
334
- )
335
- await waitFor(() => {
336
- expect(onChange).toHaveBeenCalledWith({
337
- type: 'value',
338
- value: initialValue,
339
- })
340
- expect(onChange).toHaveBeenCalledWith({type: 'ready'})
341
- })
342
- await waitFor(() => {
343
- if (editorRef.current) {
344
- PortableTextEditor.focus(editorRef.current)
345
- expect(PortableTextEditor.getValue(editorRef.current)).toEqual([
346
- {
347
- _key: 'k2',
348
- _type: 'myTestBlockType',
349
- children: [{_key: 'k3', _type: 'span', marks: [], text: ''}],
350
- markDefs: [],
351
- style: 'normal',
352
- },
353
- ])
354
- }
355
- })
356
- await waitFor(() => {
357
- expect(onChange).toHaveBeenCalledWith({
358
- type: 'value',
359
- value: initialValue,
360
- })
361
- expect(onChange).toHaveBeenCalledWith({type: 'ready'})
362
- })
363
- })
364
- })