@portabletext/editor 1.50.2 → 1.50.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.
Files changed (33) hide show
  1. package/lib/behaviors/index.d.cts +1 -0
  2. package/lib/behaviors/index.d.ts +1 -0
  3. package/lib/index.cjs +577 -286
  4. package/lib/index.cjs.map +1 -1
  5. package/lib/index.d.cts +15 -2
  6. package/lib/index.d.ts +15 -2
  7. package/lib/index.js +584 -292
  8. package/lib/index.js.map +1 -1
  9. package/lib/plugins/index.d.cts +7 -0
  10. package/lib/plugins/index.d.ts +7 -0
  11. package/lib/selectors/index.d.cts +1 -0
  12. package/lib/selectors/index.d.ts +1 -0
  13. package/lib/utils/index.d.cts +1 -0
  14. package/lib/utils/index.d.ts +1 -0
  15. package/package.json +14 -13
  16. package/src/editor/PortableTextEditor.tsx +22 -22
  17. package/src/editor/create-slate-editor.tsx +9 -1
  18. package/src/editor/editor-selector.ts +1 -5
  19. package/src/editor/editor-snapshot.ts +1 -3
  20. package/src/editor/plugins/createWithPatches.ts +37 -75
  21. package/src/editor/plugins/slate-plugin.update-value.ts +30 -0
  22. package/src/editor/plugins/with-plugins.ts +8 -4
  23. package/src/editor/relay-machine.ts +9 -0
  24. package/src/internal-utils/apply-operation-to-portable-text.test.ts +175 -0
  25. package/src/internal-utils/apply-operation-to-portable-text.ts +435 -0
  26. package/src/internal-utils/create-placeholder-block.ts +20 -0
  27. package/src/internal-utils/{__tests__/operationToPatches.test.ts → operation-to-patches.test.ts} +44 -39
  28. package/src/internal-utils/operation-to-patches.ts +467 -0
  29. package/src/internal-utils/portable-text-node.ts +209 -0
  30. package/src/types/editor.ts +8 -2
  31. package/src/internal-utils/__tests__/patchToOperations.test.ts +0 -312
  32. package/src/internal-utils/operationToPatches.ts +0 -489
  33. package/src/internal-utils/slate-children-to-blocks.ts +0 -49
@@ -0,0 +1,175 @@
1
+ import {describe, expect, test} from 'vitest'
2
+ import {compileSchemaDefinition, defineSchema} from '../editor/editor-schema'
3
+ import {applyOperationToPortableText} from './apply-operation-to-portable-text'
4
+ import {createTestKeyGenerator} from './test-key-generator'
5
+
6
+ function createContext() {
7
+ const keyGenerator = createTestKeyGenerator()
8
+ const schema = compileSchemaDefinition(defineSchema({}))
9
+
10
+ return {
11
+ keyGenerator,
12
+ schema,
13
+ }
14
+ }
15
+
16
+ describe(applyOperationToPortableText.name, () => {
17
+ test('setting block object properties', () => {
18
+ expect(
19
+ applyOperationToPortableText(
20
+ createContext(),
21
+ [
22
+ {
23
+ _type: 'image',
24
+ _key: 'k0',
25
+ },
26
+ ],
27
+ {
28
+ type: 'set_node',
29
+ path: [0],
30
+ properties: {},
31
+ newProperties: {
32
+ value: {src: 'https://example.com/image.jpg'},
33
+ },
34
+ },
35
+ ),
36
+ ).toEqual([
37
+ {
38
+ _type: 'image',
39
+ _key: 'k0',
40
+ src: 'https://example.com/image.jpg',
41
+ },
42
+ ])
43
+ })
44
+
45
+ test('updating block object properties', () => {
46
+ expect(
47
+ applyOperationToPortableText(
48
+ createContext(),
49
+ [
50
+ {
51
+ _type: 'image',
52
+ _key: 'k0',
53
+ src: 'https://example.com/image.jpg',
54
+ },
55
+ ],
56
+ {
57
+ type: 'set_node',
58
+ path: [0],
59
+ properties: {
60
+ value: {src: 'https://example.com/image.jpg'},
61
+ },
62
+ newProperties: {
63
+ value: {
64
+ src: 'https://example.com/image.jpg',
65
+ alt: 'An image',
66
+ },
67
+ },
68
+ },
69
+ ),
70
+ ).toEqual([
71
+ {
72
+ _type: 'image',
73
+ _key: 'k0',
74
+ src: 'https://example.com/image.jpg',
75
+ alt: 'An image',
76
+ },
77
+ ])
78
+ })
79
+
80
+ test('removing block object properties', () => {
81
+ expect(
82
+ applyOperationToPortableText(
83
+ createContext(),
84
+ [{_type: 'image', _key: 'k0', alt: 'An image'}],
85
+ {
86
+ type: 'set_node',
87
+ path: [0],
88
+ properties: {
89
+ value: {
90
+ alt: 'An image',
91
+ },
92
+ },
93
+ newProperties: {value: {}},
94
+ },
95
+ ),
96
+ ).toEqual([{_type: 'image', _key: 'k0'}])
97
+ })
98
+
99
+ test('updating block object _key', () => {
100
+ expect(
101
+ applyOperationToPortableText(
102
+ createContext(),
103
+ [
104
+ {
105
+ _type: 'image',
106
+ _key: 'k0',
107
+ src: 'https://example.com/image.jpg',
108
+ },
109
+ ],
110
+ {
111
+ type: 'set_node',
112
+ path: [0],
113
+ properties: {_key: 'k0'},
114
+ newProperties: {_key: 'k1'},
115
+ },
116
+ ),
117
+ ).toEqual([
118
+ {
119
+ _type: 'image',
120
+ _key: 'k1',
121
+ src: 'https://example.com/image.jpg',
122
+ },
123
+ ])
124
+ })
125
+
126
+ test('updating inline object properties', () => {
127
+ expect(
128
+ applyOperationToPortableText(
129
+ createContext(),
130
+ [
131
+ {
132
+ _key: 'k0',
133
+ _type: 'block',
134
+ children: [
135
+ {
136
+ _key: 'k1',
137
+ _type: 'span',
138
+ text: '',
139
+ },
140
+ {
141
+ _key: 'k2',
142
+ _type: 'stock ticker',
143
+ },
144
+ {
145
+ _key: 'k3',
146
+ _type: 'span',
147
+ text: '',
148
+ },
149
+ ],
150
+ },
151
+ ],
152
+ {
153
+ type: 'set_node',
154
+ path: [0, 1],
155
+ properties: {},
156
+ newProperties: {
157
+ value: {
158
+ symbol: 'AAPL',
159
+ },
160
+ },
161
+ },
162
+ ),
163
+ ).toEqual([
164
+ {
165
+ _type: 'block',
166
+ _key: 'k0',
167
+ children: [
168
+ {_type: 'span', _key: 'k1', text: ''},
169
+ {_type: 'stock ticker', _key: 'k2', symbol: 'AAPL'},
170
+ {_type: 'span', _key: 'k3', text: ''},
171
+ ],
172
+ },
173
+ ])
174
+ })
175
+ })
@@ -0,0 +1,435 @@
1
+ import type {PortableTextBlock} from '@sanity/types'
2
+ import {createDraft, finishDraft, type WritableDraft} from 'immer'
3
+ import {Element, Path, type Node, type Operation} from 'slate'
4
+ import type {EditorSchema} from '../editor/editor-schema'
5
+ import type {EditorContext} from '../editor/editor-snapshot'
6
+ import type {OmitFromUnion} from '../type-utils'
7
+ import {
8
+ getBlock,
9
+ getNode,
10
+ getParent,
11
+ getSpan,
12
+ isEditorNode,
13
+ isObjectNode,
14
+ isPartialSpanNode,
15
+ isSpanNode,
16
+ isTextBlockNode,
17
+ type PortableTextNode,
18
+ type SpanNode,
19
+ type TextBlockNode,
20
+ } from './portable-text-node'
21
+
22
+ export function applyOperationToPortableText(
23
+ context: Pick<EditorContext, 'keyGenerator' | 'schema'>,
24
+ value: Array<PortableTextBlock>,
25
+ operation: OmitFromUnion<Operation, 'type', 'set_selection'>,
26
+ ) {
27
+ const draft = createDraft({children: value})
28
+
29
+ try {
30
+ applyOperationToPortableTextDraft(context, draft, operation)
31
+ } catch (e) {
32
+ console.error(e)
33
+ }
34
+
35
+ return finishDraft(draft).children
36
+ }
37
+
38
+ function applyOperationToPortableTextDraft(
39
+ context: Pick<EditorContext, 'keyGenerator' | 'schema'>,
40
+ root: WritableDraft<{
41
+ children: Array<PortableTextBlock>
42
+ }>,
43
+ operation: OmitFromUnion<Operation, 'type', 'set_selection'>,
44
+ ) {
45
+ switch (operation.type) {
46
+ case 'insert_node': {
47
+ const {path, node: insertedNode} = operation
48
+ const parent = getParent(context, root, path)
49
+ const index = path[path.length - 1]
50
+
51
+ if (!parent) {
52
+ break
53
+ }
54
+
55
+ if (index > parent.children.length) {
56
+ break
57
+ }
58
+
59
+ if (path.length === 1) {
60
+ // Inserting block at the root
61
+
62
+ if (isTextBlockNode(context, insertedNode)) {
63
+ // Text blocks can be inserted as is
64
+
65
+ parent.children.splice(index, 0, {
66
+ ...insertedNode,
67
+ children: insertedNode.children.map((child) => {
68
+ if ('__inline' in child) {
69
+ // Except for inline object children which need to have their
70
+ // `value` spread onto the block
71
+ return {
72
+ _key: child._key,
73
+ _type: child._type,
74
+ ...('value' in child && typeof child.value === 'object'
75
+ ? child.value
76
+ : {}),
77
+ }
78
+ }
79
+
80
+ return child
81
+ }),
82
+ })
83
+
84
+ break
85
+ }
86
+
87
+ if (Element.isElement(insertedNode) && !('__inline' in insertedNode)) {
88
+ // Void blocks have to have their `value` spread onto the block
89
+
90
+ parent.children.splice(index, 0, {
91
+ _key: insertedNode._key,
92
+ _type: insertedNode._type,
93
+ ...('value' in insertedNode &&
94
+ typeof insertedNode.value === 'object'
95
+ ? insertedNode.value
96
+ : {}),
97
+ })
98
+ break
99
+ }
100
+ }
101
+
102
+ if (path.length === 2) {
103
+ // Inserting children into blocks
104
+
105
+ if (!isTextBlockNode(context, parent)) {
106
+ // Only text blocks can have children
107
+ break
108
+ }
109
+
110
+ if (isPartialSpanNode(insertedNode)) {
111
+ // Text nodes can be inserted as is
112
+
113
+ parent.children.splice(index, 0, insertedNode)
114
+ break
115
+ }
116
+
117
+ if ('__inline' in insertedNode) {
118
+ // Void children have to have their `value` spread onto the block
119
+
120
+ parent.children.splice(index, 0, {
121
+ _key: insertedNode._key,
122
+ _type: insertedNode._type,
123
+ ...('value' in insertedNode &&
124
+ typeof insertedNode.value === 'object'
125
+ ? insertedNode.value
126
+ : {}),
127
+ })
128
+ break
129
+ }
130
+ }
131
+
132
+ break
133
+ }
134
+
135
+ case 'insert_text': {
136
+ const {path, offset, text} = operation
137
+ if (text.length === 0) break
138
+ const span = getSpan(context, root, path)
139
+
140
+ if (!span) {
141
+ break
142
+ }
143
+
144
+ const before = span.text.slice(0, offset)
145
+ const after = span.text.slice(offset)
146
+ span.text = before + text + after
147
+
148
+ break
149
+ }
150
+
151
+ case 'merge_node': {
152
+ const {path} = operation
153
+ const node = getNode(context, root, path)
154
+ const prevPath = Path.previous(path)
155
+ const prev = getNode(context, root, prevPath)
156
+ const parent = getParent(context, root, path)
157
+
158
+ if (!node || !prev || !parent) {
159
+ break
160
+ }
161
+
162
+ const index = path[path.length - 1]
163
+
164
+ if (isPartialSpanNode(node) && isPartialSpanNode(prev)) {
165
+ prev.text += node.text
166
+ } else if (
167
+ isTextBlockNode(context, node) &&
168
+ isTextBlockNode(context, prev)
169
+ ) {
170
+ prev.children.push(...node.children)
171
+ } else {
172
+ break
173
+ }
174
+
175
+ parent.children.splice(index, 1)
176
+
177
+ break
178
+ }
179
+
180
+ case 'move_node': {
181
+ const {path, newPath} = operation
182
+
183
+ if (Path.isAncestor(path, newPath)) {
184
+ break
185
+ }
186
+
187
+ const node = getNode(context, root, path)
188
+ const parent = getParent(context, root, path)
189
+ const index = path[path.length - 1]
190
+
191
+ if (!node || !parent) {
192
+ break
193
+ }
194
+
195
+ // This is tricky, but since the `path` and `newPath` both refer to
196
+ // the same snapshot in time, there's a mismatch. After either
197
+ // removing the original position, the second step's path can be out
198
+ // of date. So instead of using the `op.newPath` directly, we
199
+ // transform `op.path` to ascertain what the `newPath` would be after
200
+ // the operation was applied.
201
+ parent.children.splice(index, 1)
202
+ const truePath = Path.transform(path, operation)!
203
+ const newParent = getNode(context, root, Path.parent(truePath))
204
+ const newIndex = truePath[truePath.length - 1]
205
+
206
+ if (!newParent) {
207
+ break
208
+ }
209
+
210
+ if (!('children' in newParent)) {
211
+ break
212
+ }
213
+
214
+ if (!Array.isArray(newParent.children)) {
215
+ break
216
+ }
217
+
218
+ newParent.children.splice(newIndex, 0, node)
219
+
220
+ break
221
+ }
222
+
223
+ case 'remove_node': {
224
+ const {path} = operation
225
+ const index = path[path.length - 1]
226
+ const parent = getParent(context, root, path)
227
+ parent?.children.splice(index, 1)
228
+
229
+ break
230
+ }
231
+
232
+ case 'remove_text': {
233
+ const {path, offset, text} = operation
234
+
235
+ if (text.length === 0) {
236
+ break
237
+ }
238
+
239
+ const span = getSpan(context, root, path)
240
+
241
+ if (!span) {
242
+ break
243
+ }
244
+
245
+ const before = span.text.slice(0, offset)
246
+ const after = span.text.slice(offset + text.length)
247
+ span.text = before + after
248
+
249
+ break
250
+ }
251
+
252
+ case 'set_node': {
253
+ const {path, properties, newProperties} = operation
254
+
255
+ const node = getNode(context, root, path)
256
+
257
+ if (!node) {
258
+ break
259
+ }
260
+
261
+ if (isEditorNode(node)) {
262
+ break
263
+ }
264
+
265
+ if (isObjectNode(context, node)) {
266
+ const valueBefore = (
267
+ 'value' in properties && typeof properties.value === 'object'
268
+ ? properties.value
269
+ : {}
270
+ ) as Partial<Node>
271
+ const valueAfter = (
272
+ 'value' in newProperties && typeof newProperties.value === 'object'
273
+ ? newProperties.value
274
+ : {}
275
+ ) as Partial<Node>
276
+
277
+ for (const key in newProperties) {
278
+ if (key === 'value') {
279
+ continue
280
+ }
281
+
282
+ const value = newProperties[key as keyof Partial<Node>]
283
+
284
+ if (value == null) {
285
+ delete node[<keyof PortableTextNode<EditorSchema>>key]
286
+ } else {
287
+ node[<keyof PortableTextNode<EditorSchema>>key] = value
288
+ }
289
+ }
290
+
291
+ for (const key in properties) {
292
+ if (key === 'value') {
293
+ continue
294
+ }
295
+
296
+ if (!newProperties.hasOwnProperty(key)) {
297
+ delete node[<keyof PortableTextNode<EditorSchema>>key]
298
+ }
299
+ }
300
+
301
+ for (const key in valueAfter) {
302
+ const value = valueAfter[key as keyof Partial<Node>]
303
+
304
+ if (value == null) {
305
+ delete node[<keyof PortableTextNode<EditorSchema>>key]
306
+ } else {
307
+ node[<keyof PortableTextNode<EditorSchema>>key] = value
308
+ }
309
+ }
310
+
311
+ for (const key in valueBefore) {
312
+ if (!valueAfter.hasOwnProperty(key)) {
313
+ delete node[<keyof PortableTextNode<EditorSchema>>key]
314
+ }
315
+ }
316
+
317
+ break
318
+ }
319
+
320
+ if (isTextBlockNode(context, node)) {
321
+ for (const key in newProperties) {
322
+ if (key === 'children' || key === 'text') {
323
+ break
324
+ }
325
+
326
+ const value = newProperties[key as keyof Partial<Node>]
327
+
328
+ if (value == null) {
329
+ delete node[<keyof Partial<Node>>key]
330
+ } else {
331
+ node[<keyof Partial<Node>>key] = value
332
+ }
333
+ }
334
+
335
+ // properties that were previously defined, but are now missing, must be deleted
336
+ for (const key in properties) {
337
+ if (!newProperties.hasOwnProperty(key)) {
338
+ delete node[<keyof Partial<Node>>key]
339
+ }
340
+ }
341
+
342
+ break
343
+ }
344
+
345
+ if (isPartialSpanNode(node)) {
346
+ for (const key in newProperties) {
347
+ if (key === 'text') {
348
+ break
349
+ }
350
+
351
+ const value = newProperties[key as keyof Partial<Node>]
352
+
353
+ if (value == null) {
354
+ delete node[<keyof PortableTextNode<EditorSchema>>key]
355
+ } else {
356
+ node[<keyof PortableTextNode<EditorSchema>>key] = value
357
+ }
358
+ }
359
+
360
+ // properties that were previously defined, but are now missing, must be deleted
361
+ for (const key in properties) {
362
+ if (!newProperties.hasOwnProperty(key)) {
363
+ delete node[<keyof PortableTextNode<EditorSchema>>key]
364
+ }
365
+ }
366
+
367
+ break
368
+ }
369
+
370
+ break
371
+ }
372
+
373
+ case 'split_node': {
374
+ const {path, position, properties} = operation
375
+
376
+ if (path.length === 0) {
377
+ break
378
+ }
379
+
380
+ const parent = getParent(context, root, path)
381
+ const index = path[path.length - 1]
382
+
383
+ if (!parent) {
384
+ break
385
+ }
386
+
387
+ if (isEditorNode(parent)) {
388
+ const block = getBlock(root, path)
389
+
390
+ if (!block || !isTextBlockNode(context, block)) {
391
+ break
392
+ }
393
+
394
+ const before = block.children.slice(0, position)
395
+ const after = block.children.slice(position)
396
+ block.children = before
397
+
398
+ // _key is deliberately left out
399
+ const newTextBlockNode = {
400
+ ...properties,
401
+ children: after,
402
+ _type: context.schema.block.name,
403
+ } as unknown as TextBlockNode<EditorSchema>
404
+
405
+ parent.children.splice(index + 1, 0, newTextBlockNode)
406
+
407
+ break
408
+ }
409
+
410
+ if (isTextBlockNode(context, parent)) {
411
+ const node = getNode(context, root, path)
412
+
413
+ if (!node || !isSpanNode(context, node)) {
414
+ break
415
+ }
416
+
417
+ const before = node.text.slice(0, position)
418
+ const after = node.text.slice(position)
419
+ node.text = before
420
+
421
+ // _key is deliberately left out
422
+ const newSpanNode = {
423
+ ...properties,
424
+ text: after,
425
+ } as unknown as SpanNode<EditorSchema>
426
+
427
+ parent.children.splice(index + 1, 0, newSpanNode)
428
+ }
429
+
430
+ break
431
+ }
432
+ }
433
+
434
+ return root
435
+ }
@@ -0,0 +1,20 @@
1
+ import type {EditorContext} from '../editor/editor-snapshot'
2
+
3
+ export function createPlaceholderBlock(
4
+ context: Pick<EditorContext, 'keyGenerator' | 'schema'>,
5
+ ) {
6
+ return {
7
+ _type: context.schema.block.name,
8
+ _key: context.keyGenerator(),
9
+ style: context.schema.styles[0].name ?? 'normal',
10
+ markDefs: [],
11
+ children: [
12
+ {
13
+ _type: context.schema.span.name,
14
+ _key: context.keyGenerator(),
15
+ text: '',
16
+ marks: [],
17
+ },
18
+ ],
19
+ }
20
+ }