@portabletext/editor 2.21.3 → 2.21.5

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": "2.21.3",
3
+ "version": "2.21.5",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -75,8 +75,8 @@
75
75
  "xstate": "^5.24.0",
76
76
  "@portabletext/block-tools": "^4.0.2",
77
77
  "@portabletext/keyboard-shortcuts": "^2.1.0",
78
- "@portabletext/patches": "^2.0.0",
79
- "@portabletext/schema": "^2.0.0"
78
+ "@portabletext/schema": "^2.0.0",
79
+ "@portabletext/patches": "^2.0.0"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@sanity/diff-match-patch": "^3.2.0",
@@ -757,8 +757,10 @@ function syncBlock({
757
757
  }
758
758
 
759
759
  if (validation.valid || validation.resolution?.autoResolve) {
760
- if (oldBlock._key === block._key) {
761
- if (debug.enabled) debug('Updating block', oldBlock, block)
760
+ if (oldBlock._key === block._key && oldBlock._type === block._type) {
761
+ if (debug.enabled) {
762
+ debug('Updating block', oldBlock, block)
763
+ }
762
764
 
763
765
  Editor.withoutNormalizing(slateEditor, () => {
764
766
  withRemoteChanges(slateEditor, () => {
@@ -908,8 +910,11 @@ function updateBlock({
908
910
  const path = [index, currentBlockChildIndex]
909
911
 
910
912
  if (isChildChanged) {
911
- // Update if this is the same child
912
- if (currentBlockChild._key === oldBlockChild?._key) {
913
+ // Update if this is the same child (same key and type)
914
+ if (
915
+ currentBlockChild._key === oldBlockChild?._key &&
916
+ currentBlockChild._type === oldBlockChild?._type
917
+ ) {
913
918
  debug('Updating changed child', currentBlockChild, oldBlockChild)
914
919
 
915
920
  Transforms.setNodes(slateEditor, currentBlockChild as Partial<Node>, {
@@ -949,7 +954,6 @@ function updateBlock({
949
954
  )
950
955
  }
951
956
  } else if (oldBlockChild) {
952
- // Replace the child if _key's are different
953
957
  debug('Replacing child', currentBlockChild)
954
958
 
955
959
  Transforms.removeNodes(slateEditor, {
@@ -172,4 +172,95 @@ describe(applyOperationToPortableText.name, () => {
172
172
  },
173
173
  ])
174
174
  })
175
+
176
+ test('updating block object with a field named "text"', () => {
177
+ const keyGenerator = createTestKeyGenerator()
178
+ const k0 = keyGenerator()
179
+
180
+ expect(
181
+ applyOperationToPortableText(
182
+ createContext(),
183
+ [
184
+ {
185
+ _type: 'quote',
186
+ _key: k0,
187
+ text: 'h',
188
+ },
189
+ ],
190
+ {
191
+ type: 'set_node',
192
+ path: [0],
193
+ properties: {
194
+ value: {text: 'h'},
195
+ },
196
+ newProperties: {
197
+ value: {text: 'hello'},
198
+ },
199
+ },
200
+ ),
201
+ ).toEqual([
202
+ {
203
+ _type: 'quote',
204
+ _key: k0,
205
+ text: 'hello',
206
+ },
207
+ ])
208
+ })
209
+
210
+ test('updating inline object with a field named "text"', () => {
211
+ const keyGenerator = createTestKeyGenerator()
212
+ const k0 = keyGenerator()
213
+ const k1 = keyGenerator()
214
+ const k2 = keyGenerator()
215
+ const k3 = keyGenerator()
216
+
217
+ expect(
218
+ applyOperationToPortableText(
219
+ createContext(),
220
+ [
221
+ {
222
+ _key: k0,
223
+ _type: 'block',
224
+ children: [
225
+ {
226
+ _key: k1,
227
+ _type: 'span',
228
+ text: '',
229
+ },
230
+ {
231
+ _key: k2,
232
+ _type: 'mention',
233
+ text: 'J',
234
+ },
235
+ {
236
+ _key: k3,
237
+ _type: 'span',
238
+ text: '',
239
+ },
240
+ ],
241
+ },
242
+ ],
243
+ {
244
+ type: 'set_node',
245
+ path: [0, 1],
246
+ properties: {
247
+ value: {text: 'J'},
248
+ },
249
+ newProperties: {
250
+ value: {text: 'John Doe'},
251
+ },
252
+ },
253
+ ),
254
+ ).toEqual([
255
+ {
256
+ _type: 'block',
257
+ _key: k0,
258
+ children: [
259
+ {_type: 'span', _key: k1, text: ''},
260
+ {_type: 'mention', _key: k2, text: 'John Doe'},
261
+ {_type: 'span', _key: k3, text: ''},
262
+ ],
263
+ },
264
+ ])
265
+ })
175
266
  })
@@ -107,7 +107,7 @@ function applyOperationToPortableTextDraft(
107
107
  break
108
108
  }
109
109
 
110
- if (isPartialSpanNode(insertedNode)) {
110
+ if (isPartialSpanNode(context, insertedNode)) {
111
111
  // Text nodes can be inserted as is
112
112
 
113
113
  parent.children.splice(index, 0, insertedNode)
@@ -161,7 +161,10 @@ function applyOperationToPortableTextDraft(
161
161
 
162
162
  const index = path[path.length - 1]
163
163
 
164
- if (isPartialSpanNode(node) && isPartialSpanNode(prev)) {
164
+ if (
165
+ isPartialSpanNode(context, node) &&
166
+ isPartialSpanNode(context, prev)
167
+ ) {
165
168
  prev.text += node.text
166
169
  } else if (
167
170
  isTextBlockNode(context, node) &&
@@ -342,7 +345,7 @@ function applyOperationToPortableTextDraft(
342
345
  break
343
346
  }
344
347
 
345
- if (isPartialSpanNode(node)) {
348
+ if (isPartialSpanNode(context, node)) {
346
349
  for (const key in newProperties) {
347
350
  if (key === 'text') {
348
351
  break
@@ -141,6 +141,16 @@ describe('operationToPatches', () => {
141
141
  ),
142
142
  ).toMatchInlineSnapshot(`
143
143
  [
144
+ {
145
+ "path": [
146
+ {
147
+ "_key": "1f2e64b47787",
148
+ },
149
+ "children",
150
+ ],
151
+ "type": "setIfMissing",
152
+ "value": [],
153
+ },
144
154
  {
145
155
  "items": [
146
156
  {
@@ -285,6 +295,16 @@ describe('operationToPatches', () => {
285
295
  ),
286
296
  ).toMatchInlineSnapshot(`
287
297
  [
298
+ {
299
+ "path": [
300
+ {
301
+ "_key": "1f2e64b47787",
302
+ },
303
+ "children",
304
+ ],
305
+ "type": "setIfMissing",
306
+ "value": [],
307
+ },
288
308
  {
289
309
  "items": [
290
310
  {
@@ -314,6 +314,9 @@ export function insertNodePatch(
314
314
  node._type = 'span'
315
315
  node.marks = []
316
316
  }
317
+
318
+ // Defensive setIfMissing to ensure children array exists before inserting
319
+ const setIfMissingPatch = setIfMissing([], [{_key: block._key}, 'children'])
317
320
  const blk = fromSlateValue(
318
321
  [
319
322
  {
@@ -326,6 +329,7 @@ export function insertNodePatch(
326
329
  )[0] as PortableTextTextBlock
327
330
  const child = blk.children[0]
328
331
  return [
332
+ setIfMissingPatch,
329
333
  insert([child], position, [
330
334
  {_key: block._key},
331
335
  'children',
@@ -389,6 +393,8 @@ export function splitNodePatch(
389
393
  )[0] as PortableTextTextBlock
390
394
  ).children
391
395
 
396
+ // Defensive setIfMissing to ensure children array exists before inserting
397
+ patches.push(setIfMissing([], [{_key: splitBlock._key}, 'children']))
392
398
  patches.push(
393
399
  insert(targetSpans, 'after', [
394
400
  {_key: splitBlock._key},
@@ -563,6 +569,8 @@ export function moveNodePatch(
563
569
  fromSlateValue([block], schema.block.name)[0] as PortableTextTextBlock
564
570
  ).children[operation.path[1]]
565
571
  patches.push(unset([{_key: block._key}, 'children', {_key: child._key}]))
572
+ // Defensive setIfMissing to ensure children array exists before inserting
573
+ patches.push(setIfMissing([], [{_key: targetBlock._key}, 'children']))
566
574
  patches.push(
567
575
  insert([childToInsert], position, [
568
576
  {_key: targetBlock._key},
@@ -0,0 +1,132 @@
1
+ import {compileSchema, defineSchema} from '@portabletext/schema'
2
+ import {describe, expect, test} from 'vitest'
3
+ import {isObjectNode, isPartialSpanNode, isSpanNode} from './portable-text-node'
4
+
5
+ const schema = compileSchema(defineSchema({}))
6
+
7
+ describe(isPartialSpanNode.name, () => {
8
+ test('object with only text property', () => {
9
+ expect(isPartialSpanNode({schema}, {text: 'Hello'})).toBe(true)
10
+ })
11
+
12
+ test('non-objects', () => {
13
+ expect(isPartialSpanNode({schema}, null)).toBe(false)
14
+ expect(isPartialSpanNode({schema}, undefined)).toBe(false)
15
+ expect(isPartialSpanNode({schema}, 'text')).toBe(false)
16
+ expect(isPartialSpanNode({schema}, 123)).toBe(false)
17
+ })
18
+
19
+ test('text is not a string', () => {
20
+ expect(isPartialSpanNode({schema}, {text: 123})).toBe(false)
21
+ expect(isPartialSpanNode({schema}, {text: null})).toBe(false)
22
+ expect(isPartialSpanNode({schema}, {text: undefined})).toBe(false)
23
+ })
24
+
25
+ test('inline object with text field and _type', () => {
26
+ expect(
27
+ isPartialSpanNode(
28
+ {schema},
29
+ {_type: 'mention', _key: 'abc123', text: 'John Doe'},
30
+ ),
31
+ ).toBe(false)
32
+ })
33
+
34
+ test('block object with text field and _type', () => {
35
+ expect(
36
+ isPartialSpanNode(
37
+ {schema},
38
+ {
39
+ _type: 'quote',
40
+ _key: 'abc123',
41
+ text: 'Hello world',
42
+ source: 'Anonymous',
43
+ },
44
+ ),
45
+ ).toBe(false)
46
+ })
47
+ })
48
+
49
+ describe(isSpanNode.name, () => {
50
+ test('span with _type="span"', () => {
51
+ expect(isSpanNode({schema}, {_type: 'span', text: 'Hello'})).toBe(true)
52
+ })
53
+
54
+ test('partial span (no _type, has text)', () => {
55
+ expect(isSpanNode({schema}, {text: 'Hello'})).toBe(true)
56
+ })
57
+
58
+ test('object with children', () => {
59
+ expect(
60
+ isSpanNode({schema}, {_type: 'span', text: 'Hello', children: []}),
61
+ ).toBe(false)
62
+ })
63
+
64
+ test('object with different _type', () => {
65
+ expect(isSpanNode({schema}, {_type: 'mention', text: 'Hello'})).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe(isObjectNode.name, () => {
70
+ test('inline object', () => {
71
+ expect(
72
+ isObjectNode(
73
+ {schema},
74
+ {_type: 'stock-ticker', _key: 'abc', symbol: 'AAPL'},
75
+ ),
76
+ ).toBe(true)
77
+ })
78
+
79
+ test('block object', () => {
80
+ expect(
81
+ isObjectNode(
82
+ {schema},
83
+ {_type: 'image', _key: 'abc', src: 'https://example.com'},
84
+ ),
85
+ ).toBe(true)
86
+ })
87
+
88
+ test('inline object with text field', () => {
89
+ expect(
90
+ isObjectNode(
91
+ {schema},
92
+ {_type: 'mention', _key: 'abc123', text: 'John Doe'},
93
+ ),
94
+ ).toBe(true)
95
+ })
96
+
97
+ test('block object with text field', () => {
98
+ expect(
99
+ isObjectNode(
100
+ {schema},
101
+ {
102
+ _type: 'quote',
103
+ _key: 'abc123',
104
+ text: 'Hello world',
105
+ source: 'Anonymous',
106
+ },
107
+ ),
108
+ ).toBe(true)
109
+ })
110
+
111
+ test('span', () => {
112
+ expect(
113
+ isObjectNode(
114
+ {schema},
115
+ {_type: 'span', _key: 'abc', text: 'Hello', marks: []},
116
+ ),
117
+ ).toBe(false)
118
+ })
119
+
120
+ test('text block', () => {
121
+ expect(
122
+ isObjectNode(
123
+ {schema},
124
+ {
125
+ _type: 'block',
126
+ _key: 'abc',
127
+ children: [{_type: 'span', text: 'Hello'}],
128
+ },
129
+ ),
130
+ ).toBe(false)
131
+ })
132
+ })
@@ -79,13 +79,23 @@ export type PartialSpanNode = {
79
79
  [other: string]: unknown
80
80
  }
81
81
 
82
- export function isPartialSpanNode(node: unknown): node is PartialSpanNode {
83
- return (
84
- typeof node === 'object' &&
85
- node !== null &&
86
- 'text' in node &&
87
- typeof node.text === 'string'
88
- )
82
+ export function isPartialSpanNode(
83
+ context: {schema: EditorSchema},
84
+ node: unknown,
85
+ ): node is PartialSpanNode {
86
+ if (typeof node !== 'object' || node === null) {
87
+ return false
88
+ }
89
+
90
+ if (!('text' in node) || typeof node.text !== 'string') {
91
+ return false
92
+ }
93
+
94
+ if ('_type' in node && node._type !== context.schema.span.name) {
95
+ return false
96
+ }
97
+
98
+ return true
89
99
  }
90
100
 
91
101
  //////////
@@ -104,7 +114,7 @@ export function isObjectNode(
104
114
  !isEditorNode(node) &&
105
115
  !isTextBlockNode(context, node) &&
106
116
  !isSpanNode(context, node) &&
107
- !isPartialSpanNode(node)
117
+ !isPartialSpanNode(context, node)
108
118
  )
109
119
  }
110
120