@portabletext/editor 3.0.7 → 3.0.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.
@@ -115,7 +115,7 @@ declare function getTextBlockText(block: PortableTextTextBlock): string;
115
115
  /**
116
116
  * @public
117
117
  */
118
- declare function isEmptyTextBlock(context: Pick<EditorContext, 'schema'>, block: PortableTextBlock): boolean;
118
+ declare function isEmptyTextBlock(context: Pick<EditorContext, 'schema'>, block: PortableTextBlock | unknown): boolean;
119
119
  /**
120
120
  * @public
121
121
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "3.0.7",
3
+ "version": "3.0.9",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -73,7 +73,7 @@
73
73
  "slate-dom": "^0.119.0",
74
74
  "slate-react": "0.119.0",
75
75
  "xstate": "^5.24.0",
76
- "@portabletext/block-tools": "^4.1.2",
76
+ "@portabletext/block-tools": "^4.1.3",
77
77
  "@portabletext/keyboard-shortcuts": "^2.1.0",
78
78
  "@portabletext/patches": "^2.0.0",
79
79
  "@portabletext/schema": "^2.0.0"
@@ -81,8 +81,8 @@
81
81
  "devDependencies": {
82
82
  "@sanity/diff-match-patch": "^3.2.0",
83
83
  "@sanity/pkg-utils": "^9.0.3",
84
- "@sanity/schema": "^4.18.0",
85
- "@sanity/types": "^4.18.0",
84
+ "@sanity/schema": "^4.19.0",
85
+ "@sanity/types": "^4.19.0",
86
86
  "@types/debug": "^4.1.12",
87
87
  "@types/lodash": "^4.17.20",
88
88
  "@types/lodash.startcase": "^4.4.9",
@@ -106,14 +106,14 @@
106
106
  "vite": "^7.1.12",
107
107
  "vitest": "^4.0.9",
108
108
  "vitest-browser-react": "^2.0.2",
109
- "@portabletext/sanity-bridge": "1.2.5",
109
+ "@portabletext/sanity-bridge": "1.2.6",
110
110
  "@portabletext/test": "^1.0.0",
111
111
  "racejar": "2.0.0"
112
112
  },
113
113
  "peerDependencies": {
114
- "@portabletext/sanity-bridge": "^1.2.5",
115
- "@sanity/schema": "^4.18.0",
116
- "@sanity/types": "^4.18.0",
114
+ "@portabletext/sanity-bridge": "^1.2.6",
115
+ "@sanity/schema": "^4.19.0",
116
+ "@sanity/types": "^4.19.0",
117
117
  "react": "^18.3 || ^19",
118
118
  "rxjs": "^7.8.2"
119
119
  },
@@ -81,7 +81,6 @@ describe(converterPortableText.deserialize, () => {
81
81
  marks: [],
82
82
  },
83
83
  ],
84
- markDefs: [],
85
84
  },
86
85
  ],
87
86
  })
@@ -117,7 +116,6 @@ describe(converterPortableText.deserialize, () => {
117
116
  marks: [],
118
117
  },
119
118
  ],
120
- markDefs: [],
121
119
  },
122
120
  ],
123
121
  })
@@ -263,8 +261,6 @@ describe(converterPortableText.deserialize, () => {
263
261
  marks: [],
264
262
  },
265
263
  ],
266
- markDefs: [],
267
- style: 'normal',
268
264
  },
269
265
  ])
270
266
  })
@@ -299,8 +295,6 @@ describe(converterPortableText.deserialize, () => {
299
295
  marks: [],
300
296
  },
301
297
  ],
302
- markDefs: [],
303
- style: 'normal',
304
298
  },
305
299
  ])
306
300
  })
@@ -336,8 +330,6 @@ describe(converterPortableText.deserialize, () => {
336
330
  marks: [],
337
331
  },
338
332
  ],
339
- markDefs: [],
340
- style: 'normal',
341
333
  },
342
334
  ])
343
335
  })
@@ -377,7 +369,6 @@ describe(converterPortableText.deserialize, () => {
377
369
  marks: [],
378
370
  },
379
371
  ],
380
- markDefs: [],
381
372
  style: 'h1',
382
373
  },
383
374
  ])
@@ -415,9 +406,7 @@ describe(converterPortableText.deserialize, () => {
415
406
  marks: [],
416
407
  },
417
408
  ],
418
- markDefs: [],
419
409
  level: 1,
420
- style: 'normal',
421
410
  },
422
411
  ])
423
412
  })
@@ -458,10 +447,8 @@ describe(converterPortableText.deserialize, () => {
458
447
  marks: [],
459
448
  },
460
449
  ],
461
- markDefs: [],
462
450
  listItem: 'bullet',
463
451
  level: 1,
464
- style: 'normal',
465
452
  },
466
453
  ])
467
454
  })
@@ -25,7 +25,6 @@ import {getEventPosition} from '../internal-utils/event-position'
25
25
  import {normalizeSelection} from '../internal-utils/selection'
26
26
  import {slateRangeToSelection} from '../internal-utils/slate-utils'
27
27
  import {toSlateRange} from '../internal-utils/to-slate-range'
28
- import {isEqualToEmptyEditor} from '../internal-utils/values'
29
28
  import type {
30
29
  EditorSelection,
31
30
  OnCopyFn,
@@ -41,6 +40,7 @@ import type {
41
40
  ScrollSelectionIntoViewFunction,
42
41
  } from '../types/editor'
43
42
  import type {HotkeyOptions} from '../types/options'
43
+ import {isEmptyTextBlock} from '../utils'
44
44
  import {parseBlocks} from '../utils/parse-blocks'
45
45
  import {RenderElement} from './components/render-element'
46
46
  import {RenderLeaf} from './components/render-leaf'
@@ -526,9 +526,10 @@ export const PortableTextEditable = forwardRef<
526
526
 
527
527
  if (
528
528
  !slateEditor.selection &&
529
- isEqualToEmptyEditor(
530
- slateEditor.children,
531
- editorActor.getSnapshot().context.schema,
529
+ slateEditor.children.length === 1 &&
530
+ isEmptyTextBlock(
531
+ editorActor.getSnapshot().context,
532
+ slateEditor.value.at(0),
532
533
  )
533
534
  ) {
534
535
  Transforms.select(slateEditor, Editor.start(slateEditor, []))
@@ -30,6 +30,9 @@ export function createWithPortableTextMarkModel(
30
30
  const decorators = editorActor
31
31
  .getSnapshot()
32
32
  .context.schema.decorators.map((t) => t.name)
33
+ const defaultStyle = editorActor
34
+ .getSnapshot()
35
+ .context.schema.styles.at(0)?.name
33
36
 
34
37
  // Extend Slate's default normalization. Merge spans with same set of .marks when doing merge_node operations, and clean up markDefs / marks
35
38
  editor.normalizeNode = (nodeEntry) => {
@@ -74,6 +77,22 @@ export function createWithPortableTextMarkModel(
74
77
  return
75
78
  }
76
79
 
80
+ /**
81
+ * Add missing .style to block nodes
82
+ */
83
+ if (
84
+ defaultStyle &&
85
+ editor.isTextBlock(node) &&
86
+ typeof node.style === 'undefined'
87
+ ) {
88
+ debug('Adding .style to block node')
89
+
90
+ withNormalizeNode(editor, () => {
91
+ Transforms.setNodes(editor, {style: defaultStyle}, {at: path})
92
+ })
93
+ return
94
+ }
95
+
77
96
  /**
78
97
  * Add missing .marks to span nodes
79
98
  */
@@ -51,7 +51,6 @@ describe(toSlateBlock.name, () => {
51
51
  text: '123',
52
52
  },
53
53
  ],
54
- style: 'normal',
55
54
  })
56
55
  })
57
56
 
@@ -106,7 +105,6 @@ describe(toSlateBlock.name, () => {
106
105
  },
107
106
  },
108
107
  ],
109
- style: 'normal',
110
108
  })
111
109
  })
112
110
  })
@@ -32,7 +32,6 @@ describe(toSlateBlock.name, () => {
32
32
  text: 'foo',
33
33
  },
34
34
  ],
35
- style: 'normal',
36
35
  })
37
36
  })
38
37
 
@@ -62,7 +61,6 @@ describe(toSlateBlock.name, () => {
62
61
  text: 'foo',
63
62
  },
64
63
  ],
65
- style: 'normal',
66
64
  })
67
65
  })
68
66
 
@@ -101,7 +99,6 @@ describe(toSlateBlock.name, () => {
101
99
  __inline: true,
102
100
  },
103
101
  ],
104
- style: 'normal',
105
102
  })
106
103
  })
107
104
  })
@@ -148,7 +145,6 @@ describe(toSlateBlock.name, () => {
148
145
  },
149
146
  },
150
147
  ],
151
- style: 'normal',
152
148
  })
153
149
  })
154
150
 
@@ -191,7 +187,6 @@ describe(toSlateBlock.name, () => {
191
187
  },
192
188
  },
193
189
  ],
194
- style: 'normal',
195
190
  })
196
191
  })
197
192
  })
@@ -238,7 +233,6 @@ describe(toSlateBlock.name, () => {
238
233
  },
239
234
  },
240
235
  ],
241
- style: 'normal',
242
236
  })
243
237
  })
244
238
 
@@ -281,7 +275,6 @@ describe(toSlateBlock.name, () => {
281
275
  },
282
276
  },
283
277
  ],
284
- style: 'normal',
285
278
  })
286
279
  })
287
280
  })
@@ -35,7 +35,6 @@ export function toSlateBlock(
35
35
  if (isPortableText) {
36
36
  const textBlock = block as PortableTextTextBlock
37
37
  let hasInlines = false
38
- const hasMissingStyle = typeof textBlock.style === 'undefined'
39
38
  const hasMissingMarkDefs = typeof textBlock.markDefs === 'undefined'
40
39
  const hasMissingChildren = typeof textBlock.children === 'undefined'
41
40
 
@@ -86,7 +85,6 @@ export function toSlateBlock(
86
85
 
87
86
  // Return original block
88
87
  if (
89
- !hasMissingStyle &&
90
88
  !hasMissingMarkDefs &&
91
89
  !hasMissingChildren &&
92
90
  !hasInlines &&
@@ -96,11 +94,6 @@ export function toSlateBlock(
96
94
  return block
97
95
  }
98
96
 
99
- // TODO: remove this when we have a better way to handle missing style
100
- if (hasMissingStyle) {
101
- rest.style = schemaTypes.styles[0].name
102
- }
103
-
104
97
  return keepObjectEquality(
105
98
  {_type, _key, ...rest, children},
106
99
  keyMap,
@@ -176,25 +169,76 @@ export function fromSlateBlock(
176
169
  }
177
170
 
178
171
  export function isEqualToEmptyEditor(
179
- children: Descendant[] | PortableTextBlock[],
172
+ blocks: Array<Descendant> | Array<PortableTextBlock>,
180
173
  schemaTypes: EditorSchema,
181
174
  ): boolean {
182
- return (
183
- children === undefined ||
184
- (children && Array.isArray(children) && children.length === 0) ||
185
- (children &&
186
- Array.isArray(children) &&
187
- children.length === 1 &&
188
- Element.isElement(children[0]) &&
189
- children[0]._type === schemaTypes.block.name &&
190
- 'style' in children[0] &&
191
- children[0].style === schemaTypes.styles[0].name &&
192
- !('listItem' in children[0]) &&
193
- Array.isArray(children[0].children) &&
194
- children[0].children.length === 1 &&
195
- Text.isText(children[0].children[0]) &&
196
- children[0].children[0]._type === 'span' &&
197
- !children[0].children[0].marks?.join('') &&
198
- children[0].children[0].text === '')
199
- )
175
+ // Must have exactly one block
176
+ if (blocks.length !== 1) {
177
+ return false
178
+ }
179
+
180
+ const firstBlock = blocks.at(0)
181
+
182
+ if (!firstBlock) {
183
+ return true
184
+ }
185
+
186
+ if (!Element.isElement(firstBlock)) {
187
+ return false
188
+ }
189
+
190
+ // Must be a text block
191
+ if (firstBlock._type !== schemaTypes.block.name) {
192
+ return false
193
+ }
194
+
195
+ // Must not be a list item
196
+ if ('listItem' in firstBlock) {
197
+ return false
198
+ }
199
+
200
+ // Style must exist and be the default style
201
+ if (
202
+ !('style' in firstBlock) ||
203
+ firstBlock.style !== schemaTypes.styles.at(0)?.name
204
+ ) {
205
+ return false
206
+ }
207
+
208
+ // Must have children array
209
+ if (!Array.isArray(firstBlock.children)) {
210
+ return false
211
+ }
212
+
213
+ // Must have exactly one child
214
+ if (firstBlock.children.length !== 1) {
215
+ return false
216
+ }
217
+
218
+ const firstChild = firstBlock.children.at(0)
219
+
220
+ if (!firstChild) {
221
+ return false
222
+ }
223
+
224
+ if (!Text.isText(firstChild)) {
225
+ return false
226
+ }
227
+
228
+ // Must be a span type
229
+ if (!('_type' in firstChild) || firstChild._type !== schemaTypes.span.name) {
230
+ return false
231
+ }
232
+
233
+ // Must have empty text
234
+ if (firstChild.text !== '') {
235
+ return false
236
+ }
237
+
238
+ // Must have no marks (marks can be undefined or empty array)
239
+ if (firstChild.marks?.join('')) {
240
+ return false
241
+ }
242
+
243
+ return true
200
244
  }
@@ -1,6 +1,7 @@
1
- import {Transforms, type Element as SlateElement} from 'slate'
2
- import {toSlateBlock} from '../internal-utils/values'
3
- import {parseBlock} from '../utils/parse-blocks'
1
+ import {applyAll, set} from '@portabletext/patches'
2
+ import {isTextBlock} from '@portabletext/schema'
3
+ import {Transforms, type Node} from 'slate'
4
+ import {parseMarkDefs} from '../utils/parse-blocks'
4
5
  import type {BehaviorOperationImplementation} from './behavior.operations'
5
6
 
6
7
  export const blockSetOperationImplementation: BehaviorOperationImplementation<
@@ -14,36 +15,90 @@ export const blockSetOperationImplementation: BehaviorOperationImplementation<
14
15
  )
15
16
  }
16
17
 
17
- const block = operation.editor.value.at(blockIndex)
18
+ const slateBlock = operation.editor.children.at(blockIndex)
18
19
 
19
- if (!block) {
20
+ if (!slateBlock) {
20
21
  throw new Error(`Unable to find block at ${JSON.stringify(operation.at)}`)
21
22
  }
22
23
 
23
- const {_type, ...filteredProps} = operation.props
24
+ if (isTextBlock(context, slateBlock)) {
25
+ const filteredProps: Record<string, unknown> = {}
24
26
 
25
- const updatedBlock = {
26
- ...block,
27
- ...filteredProps,
28
- }
27
+ for (const key of Object.keys(operation.props)) {
28
+ if (key === '_type' || key === 'children') {
29
+ continue
30
+ }
29
31
 
30
- const parsedBlock = parseBlock({
31
- context,
32
- block: updatedBlock,
33
- options: {
34
- normalize: false,
35
- removeUnusedMarkDefs: false,
36
- validateFields: true,
37
- },
38
- })
39
-
40
- if (!parsedBlock) {
41
- throw new Error(`Unable to update block at ${JSON.stringify(operation.at)}`)
42
- }
32
+ if (key === 'style') {
33
+ if (
34
+ context.schema.styles.some(
35
+ (style) => style.name === operation.props[key],
36
+ )
37
+ ) {
38
+ filteredProps[key] = operation.props[key]
39
+ }
40
+ continue
41
+ }
42
+
43
+ if (key === 'listItem') {
44
+ if (
45
+ context.schema.lists.some(
46
+ (list) => list.name === operation.props[key],
47
+ )
48
+ ) {
49
+ filteredProps[key] = operation.props[key]
50
+ }
51
+ continue
52
+ }
53
+
54
+ if (key === 'level') {
55
+ filteredProps[key] = operation.props[key]
56
+ continue
57
+ }
58
+
59
+ if (key === 'markDefs') {
60
+ const {markDefs} = parseMarkDefs({
61
+ context,
62
+ markDefs: operation.props[key],
63
+ options: {validateFields: true},
64
+ })
65
+ filteredProps[key] = markDefs
66
+ continue
67
+ }
68
+
69
+ if (context.schema.block.fields?.some((field) => field.name === key)) {
70
+ filteredProps[key] = operation.props[key]
71
+ }
72
+ }
73
+
74
+ Transforms.setNodes(operation.editor, filteredProps, {at: [blockIndex]})
75
+ } else {
76
+ const schemaDefinition = context.schema.blockObjects.find(
77
+ (definition) => definition.name === slateBlock._type,
78
+ )
79
+ const filteredProps: Record<string, unknown> = {}
80
+
81
+ for (const key of Object.keys(operation.props)) {
82
+ if (key === '_type') {
83
+ continue
84
+ }
43
85
 
44
- const slateBlock = toSlateBlock(parsedBlock, {
45
- schemaTypes: context.schema,
46
- }) as SlateElement
86
+ if (key === '_key') {
87
+ filteredProps[key] = operation.props[key]
88
+ continue
89
+ }
47
90
 
48
- Transforms.setNodes(operation.editor, slateBlock, {at: [blockIndex]})
91
+ if (schemaDefinition?.fields.some((field) => field.name === key)) {
92
+ filteredProps[key] = operation.props[key]
93
+ }
94
+ }
95
+
96
+ const patches = Object.entries(filteredProps).map(([key, value]) =>
97
+ key === '_key' ? set(value, ['_key']) : set(value, ['value', key]),
98
+ )
99
+
100
+ const updatedSlateBlock = applyAll(slateBlock, patches) as Partial<Node>
101
+
102
+ Transforms.setNodes(operation.editor, updatedSlateBlock, {at: [blockIndex]})
103
+ }
49
104
  }
@@ -1,7 +1,6 @@
1
+ import {applyAll, set, unset} from '@portabletext/patches'
1
2
  import {isTextBlock} from '@portabletext/schema'
2
- import {omit} from 'lodash'
3
- import {Transforms} from 'slate'
4
- import {parseBlock} from '../utils/parse-blocks'
3
+ import {Transforms, type Node} from 'slate'
5
4
  import type {BehaviorOperationImplementation} from './behavior.operations'
6
5
 
7
6
  export const blockUnsetOperationImplementation: BehaviorOperationImplementation<
@@ -14,73 +13,42 @@ export const blockUnsetOperationImplementation: BehaviorOperationImplementation<
14
13
  throw new Error(`Unable to find block index for block key ${blockKey}`)
15
14
  }
16
15
 
17
- const block =
18
- blockIndex !== undefined ? operation.editor.value.at(blockIndex) : undefined
16
+ const slateBlock =
17
+ blockIndex !== undefined
18
+ ? operation.editor.children.at(blockIndex)
19
+ : undefined
19
20
 
20
- if (!block) {
21
+ if (!slateBlock) {
21
22
  throw new Error(`Unable to find block at ${JSON.stringify(operation.at)}`)
22
23
  }
23
24
 
24
- if (isTextBlock(context, block)) {
25
- const propsToRemove = operation.props.filter((prop) => prop !== '_type')
25
+ if (isTextBlock(context, slateBlock)) {
26
+ const propsToRemove = operation.props.filter(
27
+ (prop) => prop !== '_type' && prop !== '_key' && prop !== 'children',
28
+ )
26
29
 
27
- const updatedTextBlock = parseBlock({
28
- context,
29
- block: omit(block, propsToRemove),
30
- options: {
31
- normalize: false,
32
- removeUnusedMarkDefs: true,
33
- validateFields: true,
34
- },
35
- })
30
+ Transforms.unsetNodes(operation.editor, propsToRemove, {at: [blockIndex]})
36
31
 
37
- if (!updatedTextBlock) {
38
- throw new Error(
39
- `Unable to update block at ${JSON.stringify(operation.at)}`,
32
+ if (operation.props.includes('_key')) {
33
+ Transforms.setNodes(
34
+ operation.editor,
35
+ {_key: context.keyGenerator()},
36
+ {at: [blockIndex]},
40
37
  )
41
38
  }
42
39
 
43
- const propsToSet: Record<string, unknown> = {}
44
-
45
- for (const prop of propsToRemove) {
46
- if (!(prop in updatedTextBlock)) {
47
- propsToSet[prop] = undefined
48
- } else {
49
- propsToSet[prop] = (updatedTextBlock as Record<string, unknown>)[prop]
50
- }
51
- }
52
-
53
- Transforms.setNodes(operation.editor, propsToSet, {at: [blockIndex]})
54
-
55
40
  return
56
41
  }
57
42
 
58
- const updatedBlockObject = parseBlock({
59
- context,
60
- block: omit(
61
- block,
62
- operation.props.filter((prop) => prop !== '_type'),
63
- ),
64
- options: {
65
- normalize: false,
66
- removeUnusedMarkDefs: true,
67
- validateFields: true,
68
- },
69
- })
70
-
71
- if (!updatedBlockObject) {
72
- throw new Error(`Unable to update block at ${JSON.stringify(operation.at)}`)
73
- }
43
+ const patches = operation.props.flatMap((key) =>
44
+ key === '_type'
45
+ ? []
46
+ : key === '_key'
47
+ ? set(context.keyGenerator(), ['_key'])
48
+ : unset(['value', key]),
49
+ )
74
50
 
75
- const {_type, _key, ...props} = updatedBlockObject
51
+ const updatedSlateBlock = applyAll(slateBlock, patches) as Partial<Node>
76
52
 
77
- Transforms.setNodes(
78
- operation.editor,
79
- {
80
- _type,
81
- _key,
82
- value: props,
83
- },
84
- {at: [blockIndex]},
85
- )
53
+ Transforms.setNodes(operation.editor, updatedSlateBlock, {at: [blockIndex]})
86
54
  }
@@ -12,7 +12,7 @@ import {
12
12
  import {DOMEditor} from 'slate-dom'
13
13
  import {getFocusBlock, getFocusChild} from '../internal-utils/slate-utils'
14
14
  import {toSlateRange} from '../internal-utils/to-slate-range'
15
- import {isEqualToEmptyEditor, toSlateBlock} from '../internal-utils/values'
15
+ import {toSlateBlock} from '../internal-utils/values'
16
16
  import type {EditorSelection, PortableTextSlateEditor} from '../types/editor'
17
17
  import {parseBlock} from '../utils/parse-blocks'
18
18
  import {isEmptyTextBlock} from '../utils/util.is-empty-text-block'
@@ -121,7 +121,7 @@ export function insertBlock(options: {
121
121
  } else {
122
122
  // placement === 'auto'
123
123
 
124
- if (endBlock && isEqualToEmptyEditor([endBlock], context.schema)) {
124
+ if (isEmptyTextBlock(context, endBlock)) {
125
125
  Transforms.insertNodes(editor, [block], {
126
126
  at: endBlockPath,
127
127
  select: false,
@@ -234,7 +234,7 @@ export function insertBlock(options: {
234
234
  Transforms.select(editor, atAfterInsert)
235
235
  }
236
236
 
237
- if (focusBlock && isEqualToEmptyEditor([focusBlock], context.schema)) {
237
+ if (isEmptyTextBlock(context, focusBlock)) {
238
238
  Transforms.removeNodes(editor, {at: focusBlockPath})
239
239
  }
240
240
 
@@ -244,7 +244,7 @@ export function insertBlock(options: {
244
244
  if (editor.isTextBlock(endBlock) && editor.isTextBlock(block)) {
245
245
  const selectionStartPoint = Range.start(at)
246
246
 
247
- if (isEqualToEmptyEditor([endBlock], context.schema)) {
247
+ if (isEmptyTextBlock(context, endBlock)) {
248
248
  Transforms.insertNodes(editor, [block], {
249
249
  at: endBlockPath,
250
250
  select: false,