@portabletext/editor 1.55.16 → 1.56.0

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.55.16",
3
+ "version": "1.56.0",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -80,15 +80,15 @@
80
80
  "slate-react": "0.117.3",
81
81
  "use-effect-event": "^1.0.2",
82
82
  "xstate": "^5.20.0",
83
- "@portabletext/block-tools": "1.1.34",
83
+ "@portabletext/block-tools": "1.1.35",
84
84
  "@portabletext/patches": "1.1.5"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@portabletext/toolkit": "^2.0.17",
88
88
  "@sanity/diff-match-patch": "^3.2.0",
89
89
  "@sanity/pkg-utils": "^7.9.0",
90
- "@sanity/schema": "^3.96.0",
91
- "@sanity/types": "^3.96.0",
90
+ "@sanity/schema": "^3.97.1",
91
+ "@sanity/types": "^3.97.1",
92
92
  "@testing-library/react": "^16.3.0",
93
93
  "@types/debug": "^4.1.12",
94
94
  "@types/lodash": "^4.17.16",
@@ -114,8 +114,8 @@
114
114
  "racejar": "1.2.9"
115
115
  },
116
116
  "peerDependencies": {
117
- "@sanity/schema": "^3.96.0",
118
- "@sanity/types": "^3.96.0",
117
+ "@sanity/schema": "^3.97.1",
118
+ "@sanity/types": "^3.97.1",
119
119
  "react": "^16.9 || ^17 || ^18 || ^19",
120
120
  "rxjs": "^7.8.2"
121
121
  },
@@ -1,6 +1,7 @@
1
- import {isListBlock} from '../internal-utils/parse-blocks'
1
+ import {isListBlock, isTextBlock} from '../internal-utils/parse-blocks'
2
2
  import {defaultKeyboardShortcuts} from '../keyboard-shortcuts/default-keyboard-shortcuts'
3
3
  import * as selectors from '../selectors'
4
+ import {getBlockEndPoint} from '../utils'
4
5
  import {isEmptyTextBlock} from '../utils/util.is-empty-text-block'
5
6
  import {raise} from './behavior.types.action'
6
7
  import {defineBehavior} from './behavior.types.behavior'
@@ -75,6 +76,104 @@ const unindentListOnBackspace = defineBehavior({
75
76
  ],
76
77
  })
77
78
 
79
+ /**
80
+ * Hitting Delete in an empty list item would delete it by default. Instead,
81
+ * then text block below should be merged into it, preserving the list
82
+ * properties.
83
+ */
84
+ const mergeTextIntoListOnDelete = defineBehavior({
85
+ on: 'delete.forward',
86
+ guard: ({snapshot}) => {
87
+ const focusListBlock = selectors.getFocusListBlock(snapshot)
88
+ const nextBlock = selectors.getNextBlock(snapshot)
89
+
90
+ if (!focusListBlock || !nextBlock) {
91
+ return false
92
+ }
93
+
94
+ if (!isTextBlock(snapshot.context, nextBlock.node)) {
95
+ return false
96
+ }
97
+
98
+ if (!isEmptyTextBlock(snapshot.context, focusListBlock.node)) {
99
+ return false
100
+ }
101
+
102
+ return {focusListBlock, nextBlock}
103
+ },
104
+ actions: [
105
+ (_, {nextBlock}) => [
106
+ raise({
107
+ type: 'insert.block',
108
+ block: nextBlock.node,
109
+ placement: 'auto',
110
+ select: 'start',
111
+ }),
112
+ raise({
113
+ type: 'delete.block',
114
+ at: nextBlock.path,
115
+ }),
116
+ ],
117
+ ],
118
+ })
119
+
120
+ /**
121
+ * Hitting Backspace before an empty list item would delete it by default.
122
+ * Instead, the text block below should be merged into it, preserving the list
123
+ * properties.
124
+ */
125
+ const mergeTextIntoListOnBackspace = defineBehavior({
126
+ on: 'delete.backward',
127
+ guard: ({snapshot}) => {
128
+ const focusTextBlock = selectors.getFocusTextBlock(snapshot)
129
+ const previousBlock = selectors.getPreviousBlock(snapshot)
130
+
131
+ if (!focusTextBlock || !previousBlock) {
132
+ return false
133
+ }
134
+
135
+ if (!isListBlock(snapshot.context, previousBlock.node)) {
136
+ return false
137
+ }
138
+
139
+ if (!isEmptyTextBlock(snapshot.context, previousBlock.node)) {
140
+ return false
141
+ }
142
+
143
+ const previousBlockEndPoint = getBlockEndPoint({
144
+ context: snapshot.context,
145
+ block: previousBlock,
146
+ })
147
+
148
+ return {focusTextBlock, previousBlockEndPoint}
149
+ },
150
+ actions: [
151
+ (_, {focusTextBlock, previousBlockEndPoint}) => [
152
+ raise({
153
+ type: 'select',
154
+ at: {
155
+ anchor: previousBlockEndPoint,
156
+ focus: previousBlockEndPoint,
157
+ },
158
+ }),
159
+ raise({
160
+ type: 'insert.block',
161
+ block: focusTextBlock.node,
162
+ placement: 'auto',
163
+ select: 'start',
164
+ }),
165
+ raise({
166
+ type: 'delete.block',
167
+ at: focusTextBlock.path,
168
+ }),
169
+ ],
170
+ ],
171
+ })
172
+
173
+ /**
174
+ * Hitting Enter in an empty list item would create a new list item below by
175
+ * default. Instead, the list properties should be cleared.
176
+ */
78
177
  const clearListOnEnter = defineBehavior({
79
178
  on: 'insert.break',
80
179
  guard: ({snapshot}) => {
@@ -102,6 +201,9 @@ const clearListOnEnter = defineBehavior({
102
201
  ],
103
202
  })
104
203
 
204
+ /**
205
+ * Hitting Tab should indent the list item.
206
+ */
105
207
  const indentListOnTab = defineBehavior({
106
208
  on: 'keyboard.keydown',
107
209
  guard: ({snapshot, event}) => {
@@ -146,6 +248,9 @@ const indentListOnTab = defineBehavior({
146
248
  ],
147
249
  })
148
250
 
251
+ /**
252
+ * Hitting Shift+Tab should unindent the list item.
253
+ */
149
254
  const unindentListOnShiftTab = defineBehavior({
150
255
  on: 'keyboard.keydown',
151
256
  guard: ({snapshot, event}) => {
@@ -192,10 +297,221 @@ const unindentListOnShiftTab = defineBehavior({
192
297
  ],
193
298
  })
194
299
 
300
+ /**
301
+ * An inserted list inherits the `level` from the list item where it's
302
+ * inserted. The entire list tree is adjusted to match the new level.
303
+ */
304
+ const inheritListLevel = defineBehavior({
305
+ on: 'insert.blocks',
306
+ guard: ({snapshot, event}) => {
307
+ const focusListBlock = selectors.getFocusListBlock(snapshot)
308
+
309
+ if (!focusListBlock) {
310
+ return false
311
+ }
312
+
313
+ const firstInsertedBlock = event.blocks.at(0)
314
+ const secondInsertedBlock = event.blocks.at(1)
315
+ const insertedListBlock = isListBlock(snapshot.context, firstInsertedBlock)
316
+ ? firstInsertedBlock
317
+ : isListBlock(snapshot.context, secondInsertedBlock)
318
+ ? secondInsertedBlock
319
+ : undefined
320
+
321
+ if (!insertedListBlock) {
322
+ return false
323
+ }
324
+
325
+ const levelDifference = focusListBlock.node.level - insertedListBlock.level
326
+
327
+ if (levelDifference === 0) {
328
+ return false
329
+ }
330
+
331
+ return {levelDifference, insertedListBlock}
332
+ },
333
+ actions: [
334
+ ({snapshot, event}, {levelDifference, insertedListBlock}) => {
335
+ let adjustLevel = true
336
+ let listStartBlockFound = false
337
+
338
+ return [
339
+ raise({
340
+ ...event,
341
+ blocks: event.blocks.map((block) => {
342
+ if (block._key === insertedListBlock._key) {
343
+ listStartBlockFound = true
344
+ }
345
+
346
+ if (!adjustLevel) {
347
+ return block
348
+ }
349
+
350
+ if (
351
+ listStartBlockFound &&
352
+ adjustLevel &&
353
+ isListBlock(snapshot.context, block)
354
+ ) {
355
+ return {
356
+ ...block,
357
+ level: Math.min(
358
+ MAX_LIST_LEVEL,
359
+ Math.max(1, block.level + levelDifference),
360
+ ),
361
+ }
362
+ }
363
+
364
+ if (listStartBlockFound) {
365
+ adjustLevel = false
366
+ }
367
+
368
+ return block
369
+ }),
370
+ }),
371
+ ]
372
+ },
373
+ ],
374
+ })
375
+
376
+ /**
377
+ * An inserted list inherits the `listItem` from the list item at the level
378
+ * it's inserted.
379
+ */
380
+ const inheritListItem = defineBehavior({
381
+ on: 'insert.blocks',
382
+ guard: ({snapshot, event}) => {
383
+ const focusListBlock = selectors.getFocusListBlock(snapshot)
384
+
385
+ if (!focusListBlock) {
386
+ return false
387
+ }
388
+
389
+ if (isEmptyTextBlock(snapshot.context, focusListBlock.node)) {
390
+ return false
391
+ }
392
+
393
+ const firstInsertedBlock = event.blocks.at(0)
394
+ const secondInsertedBlock = event.blocks.at(1)
395
+ const insertedListBlock = isListBlock(snapshot.context, firstInsertedBlock)
396
+ ? firstInsertedBlock
397
+ : isListBlock(snapshot.context, secondInsertedBlock)
398
+ ? secondInsertedBlock
399
+ : undefined
400
+
401
+ if (!insertedListBlock) {
402
+ return false
403
+ }
404
+
405
+ if (focusListBlock.node.level !== insertedListBlock.level) {
406
+ return false
407
+ }
408
+
409
+ if (focusListBlock.node.listItem === insertedListBlock.listItem) {
410
+ return false
411
+ }
412
+
413
+ return {listItem: focusListBlock.node.listItem, insertedListBlock}
414
+ },
415
+ actions: [
416
+ ({snapshot, event}, {listItem, insertedListBlock}) => {
417
+ let adjustListItem = true
418
+ let listStartBlockFound = false
419
+
420
+ return [
421
+ raise({
422
+ ...event,
423
+ blocks: event.blocks.map((block) => {
424
+ if (block._key === insertedListBlock._key) {
425
+ listStartBlockFound = true
426
+ }
427
+
428
+ if (!adjustListItem) {
429
+ return block
430
+ }
431
+
432
+ if (
433
+ listStartBlockFound &&
434
+ adjustListItem &&
435
+ isListBlock(snapshot.context, block)
436
+ ) {
437
+ return {
438
+ ...block,
439
+ listItem:
440
+ block.level === insertedListBlock.level
441
+ ? listItem
442
+ : block.listItem,
443
+ }
444
+ }
445
+
446
+ if (listStartBlockFound) {
447
+ adjustListItem = false
448
+ }
449
+
450
+ return block
451
+ }),
452
+ }),
453
+ ]
454
+ },
455
+ ],
456
+ })
457
+
458
+ /**
459
+ * An inserted text block inherits the `listItem` and `level` from the list
460
+ * item where it's inserted.
461
+ */
462
+ const inheritListProperties = defineBehavior({
463
+ on: 'insert.block',
464
+ guard: ({snapshot, event}) => {
465
+ if (event.placement !== 'auto') {
466
+ return false
467
+ }
468
+
469
+ if (event.block._type !== snapshot.context.schema.block.name) {
470
+ return false
471
+ }
472
+
473
+ if (event.block.listItem !== undefined) {
474
+ return false
475
+ }
476
+
477
+ const focusListBlock = selectors.getFocusListBlock(snapshot)
478
+
479
+ if (!focusListBlock) {
480
+ return false
481
+ }
482
+
483
+ if (!isEmptyTextBlock(snapshot.context, focusListBlock.node)) {
484
+ return false
485
+ }
486
+
487
+ return {
488
+ level: focusListBlock.node.level,
489
+ listItem: focusListBlock.node.listItem,
490
+ }
491
+ },
492
+ actions: [
493
+ ({event}, {level, listItem}) => [
494
+ raise({
495
+ ...event,
496
+ block: {
497
+ ...event.block,
498
+ level,
499
+ listItem,
500
+ },
501
+ }),
502
+ ],
503
+ ],
504
+ })
505
+
195
506
  export const coreListBehaviors = {
196
507
  clearListOnBackspace,
197
508
  unindentListOnBackspace,
509
+ mergeTextIntoListOnDelete,
510
+ mergeTextIntoListOnBackspace,
198
511
  clearListOnEnter,
199
512
  indentListOnTab,
200
513
  unindentListOnShiftTab,
514
+ inheritListLevel,
515
+ inheritListItem,
516
+ inheritListProperties,
201
517
  }
@@ -22,9 +22,14 @@ export const coreBehaviorsConfig = [
22
22
  coreBlockObjectBehaviors.deletingEmptyTextBlockBeforeBlockObject,
23
23
  coreListBehaviors.clearListOnBackspace,
24
24
  coreListBehaviors.unindentListOnBackspace,
25
+ coreListBehaviors.mergeTextIntoListOnDelete,
26
+ coreListBehaviors.mergeTextIntoListOnBackspace,
25
27
  coreListBehaviors.clearListOnEnter,
26
28
  coreListBehaviors.indentListOnTab,
27
29
  coreListBehaviors.unindentListOnShiftTab,
30
+ coreListBehaviors.inheritListLevel,
31
+ coreListBehaviors.inheritListItem,
32
+ coreListBehaviors.inheritListProperties,
28
33
  coreInsertBreakBehaviors.breakingAtTheEndOfTextBlock,
29
34
  coreInsertBreakBehaviors.breakingAtTheStartOfTextBlock,
30
35
  coreInsertBreakBehaviors.breakingEntireDocument,
@@ -79,6 +79,16 @@ test(getTersePt.name, () => {
79
79
  },
80
80
  ]),
81
81
  ).toEqual(['>>#h3:foo'])
82
+ expect(
83
+ getTersePt([
84
+ {
85
+ _key: keyGenerator(),
86
+ _type: 'block',
87
+ children: [{_key: keyGenerator(), _type: 'span', text: 'foo'}],
88
+ style: 'h3',
89
+ },
90
+ ]),
91
+ ).toEqual(['h3:foo'])
82
92
  })
83
93
 
84
94
  test(parseTersePtString.name, () => {
@@ -11,6 +11,7 @@ import {
11
11
  } from '../internal-utils/slate-utils'
12
12
  import {isEqualToEmptyEditor, toSlateValue} from '../internal-utils/values'
13
13
  import type {PortableTextSlateEditor} from '../types/editor'
14
+ import {isEmptyTextBlock} from '../utils'
14
15
  import type {BehaviorOperationImplementation} from './behavior.operations'
15
16
 
16
17
  export const insertBlockOperationImplementation: BehaviorOperationImplementation<
@@ -240,6 +241,16 @@ export function insertBlock({
240
241
  return
241
242
  }
242
243
 
244
+ Transforms.setNodes(
245
+ editor,
246
+ {
247
+ markDefs: [...(endBlock.markDefs ?? []), ...(block.markDefs ?? [])],
248
+ },
249
+ {
250
+ at: endBlockPath,
251
+ },
252
+ )
253
+
243
254
  if (select === 'end') {
244
255
  Transforms.insertFragment(editor, [block], {
245
256
  voids: true,
@@ -290,7 +301,7 @@ export function insertBlock({
290
301
  Transforms.select(editor, Editor.start(editor, endBlockPath))
291
302
  }
292
303
 
293
- if (isEqualToEmptyEditor([endBlock], schema)) {
304
+ if (isEmptyTextBlock({schema}, endBlock)) {
294
305
  Transforms.removeNodes(editor, {at: Path.next(endBlockPath)})
295
306
  }
296
307
  } else if (