@portabletext/editor 1.0.18 → 1.1.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.
Files changed (75) hide show
  1. package/lib/index.d.mts +140 -66
  2. package/lib/index.d.ts +140 -66
  3. package/lib/index.esm.js +1164 -410
  4. package/lib/index.esm.js.map +1 -1
  5. package/lib/index.js +1164 -410
  6. package/lib/index.js.map +1 -1
  7. package/lib/index.mjs +1164 -410
  8. package/lib/index.mjs.map +1 -1
  9. package/package.json +8 -4
  10. package/src/editor/Editable.tsx +107 -36
  11. package/src/editor/PortableTextEditor.tsx +47 -12
  12. package/src/editor/__tests__/PortableTextEditor.test.tsx +42 -15
  13. package/src/editor/__tests__/PortableTextEditorTester.tsx +50 -38
  14. package/src/editor/__tests__/RangeDecorations.test.tsx +0 -1
  15. package/src/editor/__tests__/handleClick.test.tsx +28 -9
  16. package/src/editor/__tests__/insert-block.test.tsx +22 -6
  17. package/src/editor/__tests__/pteWarningsSelfSolving.test.tsx +30 -62
  18. package/src/editor/__tests__/utils.ts +10 -3
  19. package/src/editor/components/DraggableBlock.tsx +36 -13
  20. package/src/editor/components/Element.tsx +59 -17
  21. package/src/editor/components/Leaf.tsx +106 -68
  22. package/src/editor/components/SlateContainer.tsx +12 -5
  23. package/src/editor/components/Synchronizer.tsx +5 -2
  24. package/src/editor/hooks/usePortableTextEditor.ts +2 -2
  25. package/src/editor/hooks/usePortableTextEditorSelection.tsx +9 -3
  26. package/src/editor/hooks/useSyncValue.test.tsx +9 -4
  27. package/src/editor/hooks/useSyncValue.ts +199 -130
  28. package/src/editor/nodes/DefaultAnnotation.tsx +6 -3
  29. package/src/editor/plugins/__tests__/createWithInsertData.test.tsx +25 -7
  30. package/src/editor/plugins/__tests__/withEditableAPIDelete.test.tsx +26 -9
  31. package/src/editor/plugins/__tests__/withEditableAPIGetFragment.test.tsx +15 -5
  32. package/src/editor/plugins/__tests__/withEditableAPIInsert.test.tsx +60 -19
  33. package/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx +4 -2
  34. package/src/editor/plugins/__tests__/withPortableTextLists.test.tsx +4 -2
  35. package/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx +61 -550
  36. package/src/editor/plugins/__tests__/withPortableTextSelections.test.tsx +6 -3
  37. package/src/editor/plugins/__tests__/withUndoRedo.test.tsx +30 -13
  38. package/src/editor/plugins/createWithEditableAPI.ts +354 -115
  39. package/src/editor/plugins/createWithHotKeys.ts +41 -121
  40. package/src/editor/plugins/createWithInsertBreak.ts +166 -27
  41. package/src/editor/plugins/createWithInsertData.ts +60 -23
  42. package/src/editor/plugins/createWithMaxBlocks.ts +5 -2
  43. package/src/editor/plugins/createWithObjectKeys.ts +7 -3
  44. package/src/editor/plugins/createWithPatches.ts +60 -16
  45. package/src/editor/plugins/createWithPlaceholderBlock.ts +7 -3
  46. package/src/editor/plugins/createWithPortableTextBlockStyle.ts +17 -7
  47. package/src/editor/plugins/createWithPortableTextLists.ts +21 -8
  48. package/src/editor/plugins/createWithPortableTextMarkModel.ts +301 -155
  49. package/src/editor/plugins/createWithPortableTextSelections.ts +4 -2
  50. package/src/editor/plugins/createWithSchemaTypes.ts +25 -9
  51. package/src/editor/plugins/createWithUndoRedo.ts +107 -24
  52. package/src/editor/plugins/createWithUtils.ts +32 -10
  53. package/src/editor/plugins/index.ts +31 -10
  54. package/src/types/editor.ts +44 -15
  55. package/src/types/options.ts +4 -2
  56. package/src/types/slate.ts +2 -2
  57. package/src/utils/__tests__/dmpToOperations.test.ts +38 -13
  58. package/src/utils/__tests__/operationToPatches.test.ts +3 -2
  59. package/src/utils/__tests__/patchToOperations.test.ts +15 -4
  60. package/src/utils/__tests__/ranges.test.ts +8 -3
  61. package/src/utils/__tests__/valueNormalization.test.tsx +12 -4
  62. package/src/utils/__tests__/values.test.ts +0 -1
  63. package/src/utils/applyPatch.ts +71 -20
  64. package/src/utils/getPortableTextMemberSchemaTypes.ts +30 -15
  65. package/src/utils/operationToPatches.ts +126 -43
  66. package/src/utils/paths.ts +24 -7
  67. package/src/utils/ranges.ts +12 -5
  68. package/src/utils/selection.ts +19 -7
  69. package/src/utils/validateValue.ts +118 -45
  70. package/src/utils/values.ts +31 -10
  71. package/src/utils/weakMaps.ts +18 -8
  72. package/src/utils/withChanges.ts +4 -2
  73. package/src/editor/plugins/__tests__/withHotkeys.test.tsx +0 -212
  74. package/src/editor/plugins/__tests__/withInsertBreak.test.tsx +0 -220
  75. package/src/editor/plugins/__tests__/withPlaceholderBlock.test.tsx +0 -133
@@ -7,10 +7,19 @@
7
7
  */
8
8
 
9
9
  import {isPortableTextBlock, isPortableTextSpan} from '@portabletext/toolkit'
10
+ import {type PortableTextObject} from '@sanity/types'
10
11
  import {isEqual, uniq} from 'lodash'
11
12
  import {type Subject} from 'rxjs'
12
- import {type Descendant, Editor, Element, Node, Path, Range, Text, Transforms} from 'slate'
13
-
13
+ import {
14
+ Editor,
15
+ Element,
16
+ Node,
17
+ Path,
18
+ Range,
19
+ Text,
20
+ Transforms,
21
+ type Descendant,
22
+ } from 'slate'
14
23
  import {
15
24
  type EditorChange,
16
25
  type PortableTextMemberSchemaTypes,
@@ -18,7 +27,6 @@ import {
18
27
  } from '../../types/editor'
19
28
  import {debugWithName} from '../../utils/debug'
20
29
  import {toPortableTextRange} from '../../utils/ranges'
21
- import {EMPTY_MARKS} from '../../utils/values'
22
30
  import {isChangingRemotely} from '../../utils/withChanges'
23
31
  import {isRedoing, isUndoing} from '../../utils/withUndoRedo'
24
32
 
@@ -48,7 +56,11 @@ export function createWithPortableTextMarkModel(
48
56
  Transforms.select(editor, {...editor.selection})
49
57
  editor.selection = {...editor.selection} // Ensure new object
50
58
  }
51
- const ptRange = toPortableTextRange(editor.children, editor.selection, types)
59
+ const ptRange = toPortableTextRange(
60
+ editor.children,
61
+ editor.selection,
62
+ types,
63
+ )
52
64
  change$.next({type: 'selection', selection: ptRange})
53
65
  }
54
66
 
@@ -56,9 +68,6 @@ export function createWithPortableTextMarkModel(
56
68
  editor.normalizeNode = (nodeEntry) => {
57
69
  const [node, path] = nodeEntry
58
70
 
59
- const isSpan = Text.isText(node) && node._type === types.span.name
60
- const isTextBlock = editor.isTextBlock(node)
61
-
62
71
  if (editor.isTextBlock(node)) {
63
72
  const children = Node.children(editor, path)
64
73
 
@@ -75,148 +84,160 @@ export function createWithPortableTextMarkModel(
75
84
  JSON.stringify(child, null, 2),
76
85
  JSON.stringify(nextNode, null, 2),
77
86
  )
78
- Transforms.mergeNodes(editor, {at: [childPath[0], childPath[1] + 1], voids: true})
87
+ Transforms.mergeNodes(editor, {
88
+ at: [childPath[0], childPath[1] + 1],
89
+ voids: true,
90
+ })
79
91
  return
80
92
  }
81
93
  }
82
94
  }
83
95
 
84
- if (isSpan || isTextBlock) {
85
- if (isSpan && !Array.isArray(node.marks)) {
86
- debug('Adding .marks to span node')
87
- Transforms.setNodes(editor, {marks: []}, {at: path})
88
- return
89
- }
90
- const hasSpanMarks = isSpan && (node.marks || []).length > 0
91
- if (hasSpanMarks) {
92
- const spanMarks = node.marks || EMPTY_MARKS
93
- // Test that every annotation mark used has a definition in markDefs
94
- const annotationMarks = spanMarks.filter(
95
- (mark) => !types.decorators.map((dec) => dec.value).includes(mark),
96
- )
97
- if (annotationMarks.length > 0) {
98
- const [block] = Editor.node(editor, Path.parent(path))
99
- const orphanedMarks =
100
- (editor.isTextBlock(block) &&
101
- annotationMarks.filter(
102
- (mark) => !block.markDefs?.find((def) => def._key === mark),
103
- )) ||
104
- []
105
- if (orphanedMarks.length > 0) {
106
- debug('Removing orphaned .marks from span node')
107
- Transforms.setNodes(
108
- editor,
109
- {marks: spanMarks.filter((mark) => !orphanedMarks.includes(mark))},
110
- {at: path},
111
- )
112
- return
113
- }
96
+ /**
97
+ * Add missing .markDefs to block nodes
98
+ */
99
+ if (editor.isTextBlock(node) && !Array.isArray(node.markDefs)) {
100
+ debug('Adding .markDefs to block node')
101
+ Transforms.setNodes(editor, {markDefs: []}, {at: path})
102
+ return
103
+ }
104
+
105
+ /**
106
+ * Add missing .marks to span nodes
107
+ */
108
+ if (editor.isTextSpan(node) && !Array.isArray(node.marks)) {
109
+ debug('Adding .marks to span node')
110
+ Transforms.setNodes(editor, {marks: []}, {at: path})
111
+ return
112
+ }
113
+
114
+ /**
115
+ * Remove annotations from empty spans
116
+ */
117
+ if (editor.isTextSpan(node)) {
118
+ const blockPath = Path.parent(path)
119
+ const [block] = Editor.node(editor, blockPath)
120
+ const decorators = types.decorators.map((decorator) => decorator.value)
121
+ const annotations = node.marks?.filter(
122
+ (mark) => !decorators.includes(mark),
123
+ )
124
+
125
+ if (editor.isTextBlock(block)) {
126
+ if (node.text === '' && annotations && annotations.length > 0) {
127
+ debug('Removing annotations from empty span node')
128
+ Transforms.setNodes(
129
+ editor,
130
+ {marks: node.marks?.filter((mark) => decorators.includes(mark))},
131
+ {at: path},
132
+ )
133
+ return
114
134
  }
115
135
  }
116
- for (const op of editor.operations) {
117
- // Make sure markDefs are copied over when merging two blocks.
118
- if (
119
- op.type === 'merge_node' &&
120
- op.path.length === 1 &&
121
- 'markDefs' in op.properties &&
122
- op.properties._type === types.block.name &&
123
- Array.isArray(op.properties.markDefs) &&
124
- op.properties.markDefs.length > 0 &&
125
- op.path[0] - 1 >= 0
126
- ) {
127
- const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] - 1])
128
- debug(`Copying markDefs over to merged block`, op)
129
- if (editor.isTextBlock(targetBlock)) {
130
- const oldDefs = (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
131
- const newMarkDefs = uniq([...oldDefs, ...op.properties.markDefs])
132
- const isNormalized = isEqual(newMarkDefs, targetBlock.markDefs)
133
- // eslint-disable-next-line max-depth
134
- if (!isNormalized) {
135
- Transforms.setNodes(editor, {markDefs: newMarkDefs}, {at: targetPath, voids: false})
136
- return
137
- }
138
- }
139
- }
140
- // Make sure markDefs are copied over to new block when splitting a block.
141
- if (
142
- op.type === 'split_node' &&
143
- op.path.length === 1 &&
144
- Element.isElementProps(op.properties) &&
145
- op.properties._type === types.block.name &&
146
- 'markDefs' in op.properties &&
147
- Array.isArray(op.properties.markDefs) &&
148
- op.properties.markDefs.length > 0 &&
149
- op.path[0] + 1 < editor.children.length
150
- ) {
151
- const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] + 1])
152
- debug(`Copying markDefs over to split block`, op)
153
- if (editor.isTextBlock(targetBlock)) {
154
- const oldDefs = (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
136
+ }
137
+
138
+ /**
139
+ * Remove orphaned annotations from child spans of block nodes
140
+ */
141
+ if (editor.isTextBlock(node)) {
142
+ const decorators = types.decorators.map((decorator) => decorator.value)
143
+
144
+ for (const [child, childPath] of Node.children(editor, path)) {
145
+ if (editor.isTextSpan(child)) {
146
+ const marks = child.marks ?? []
147
+ const orphanedAnnotations = marks.filter((mark) => {
148
+ return (
149
+ !decorators.includes(mark) &&
150
+ !node.markDefs?.find((def) => def._key === mark)
151
+ )
152
+ })
153
+
154
+ if (orphanedAnnotations.length > 0) {
155
+ debug('Removing orphaned annotations from span node')
155
156
  Transforms.setNodes(
156
157
  editor,
157
- {markDefs: uniq([...oldDefs, ...op.properties.markDefs])},
158
- {at: targetPath, voids: false},
158
+ {
159
+ marks: marks.filter(
160
+ (mark) => !orphanedAnnotations.includes(mark),
161
+ ),
162
+ },
163
+ {at: childPath},
159
164
  )
160
165
  return
161
166
  }
162
167
  }
163
- // Make sure marks are reset, if a block is split at the end.
164
- if (
165
- op.type === 'split_node' &&
166
- op.path.length === 2 &&
167
- (op.properties as unknown as Descendant)._type === types.span.name &&
168
- 'marks' in op.properties &&
169
- Array.isArray(op.properties.marks) &&
170
- op.properties.marks.length > 0 &&
171
- op.path[0] + 1 < editor.children.length
172
- ) {
173
- const [child, childPath] = Editor.node(editor, [op.path[0] + 1, 0])
174
- if (
175
- Text.isText(child) &&
176
- child.text === '' &&
177
- Array.isArray(child.marks) &&
178
- child.marks.length > 0
179
- ) {
180
- Transforms.setNodes(editor, {marks: []}, {at: childPath, voids: false})
181
- return
182
- }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Remove orphaned annotations from span nodes
173
+ */
174
+ if (editor.isTextSpan(node)) {
175
+ const blockPath = Path.parent(path)
176
+ const [block] = Editor.node(editor, blockPath)
177
+
178
+ if (editor.isTextBlock(block)) {
179
+ const decorators = types.decorators.map(
180
+ (decorator) => decorator.value,
181
+ )
182
+ const marks = node.marks ?? []
183
+ const orphanedAnnotations = marks.filter((mark) => {
184
+ return (
185
+ !decorators.includes(mark) &&
186
+ !block.markDefs?.find((def) => def._key === mark)
187
+ )
188
+ })
189
+
190
+ if (orphanedAnnotations.length > 0) {
191
+ debug('Removing orphaned annotations from span node')
192
+ Transforms.setNodes(
193
+ editor,
194
+ {
195
+ marks: marks.filter(
196
+ (mark) => !orphanedAnnotations.includes(mark),
197
+ ),
198
+ },
199
+ {at: path},
200
+ )
201
+ return
183
202
  }
184
- // Make sure markDefs are reset, if a block is split at start.
185
- if (
186
- op.type === 'split_node' &&
187
- op.path.length === 1 &&
188
- (op.properties as unknown as Descendant)._type === types.block.name &&
189
- 'markDefs' in op.properties &&
190
- Array.isArray(op.properties.markDefs) &&
191
- op.properties.markDefs.length > 0
192
- ) {
193
- const [block, blockPath] = Editor.node(editor, [op.path[0]])
194
- if (
195
- editor.isTextBlock(block) &&
196
- block.children.length === 1 &&
197
- block.markDefs &&
198
- block.markDefs.length > 0 &&
199
- Text.isText(block.children[0]) &&
200
- block.children[0].text === '' &&
201
- (!block.children[0].marks || block.children[0].marks.length === 0)
202
- ) {
203
- Transforms.setNodes(editor, {markDefs: []}, {at: blockPath})
204
- return
205
- }
203
+ }
204
+ }
205
+
206
+ // Remove duplicate markDefs
207
+ if (editor.isTextBlock(node)) {
208
+ const markDefs = node.markDefs ?? []
209
+ const markDefKeys = new Set<string>()
210
+ const newMarkDefs: Array<PortableTextObject> = []
211
+
212
+ for (const markDef of markDefs) {
213
+ if (!markDefKeys.has(markDef._key)) {
214
+ markDefKeys.add(markDef._key)
215
+ newMarkDefs.push(markDef)
206
216
  }
207
217
  }
218
+
219
+ if (markDefs.length !== newMarkDefs.length) {
220
+ debug('Removing duplicate markDefs')
221
+ Transforms.setNodes(editor, {markDefs: newMarkDefs}, {at: path})
222
+ }
208
223
  }
224
+
209
225
  // Check consistency of markDefs (unless we are merging two nodes)
210
226
  if (
211
227
  editor.isTextBlock(node) &&
212
228
  !editor.operations.some(
213
- (op) => op.type === 'merge_node' && 'markDefs' in op.properties && op.path.length === 1,
229
+ (op) =>
230
+ op.type === 'merge_node' &&
231
+ 'markDefs' in op.properties &&
232
+ op.path.length === 1,
214
233
  )
215
234
  ) {
216
235
  const newMarkDefs = (node.markDefs || []).filter((def) => {
217
236
  return node.children.find((child) => {
218
237
  return (
219
- Text.isText(child) && Array.isArray(child.marks) && child.marks.includes(def._key)
238
+ Text.isText(child) &&
239
+ Array.isArray(child.marks) &&
240
+ child.marks.includes(def._key)
220
241
  )
221
242
  })
222
243
  })
@@ -261,13 +282,16 @@ export function createWithPortableTextMarkModel(
261
282
  if (
262
283
  selection &&
263
284
  Range.isCollapsed(selection) &&
264
- Editor.marks(editor)?.marks?.some((mark) => !decorators.includes(mark))
285
+ Editor.marks(editor)?.marks?.some(
286
+ (mark) => !decorators.includes(mark),
287
+ )
265
288
  ) {
266
289
  const [node] = Array.from(
267
290
  Editor.nodes(editor, {
268
291
  mode: 'lowest',
269
292
  at: selection.focus,
270
- match: (n) => (n as unknown as Descendant)._type === types.span.name,
293
+ match: (n) =>
294
+ (n as unknown as Descendant)._type === types.span.name,
271
295
  voids: false,
272
296
  }),
273
297
  )[0] || [undefined]
@@ -307,15 +331,25 @@ export function createWithPortableTextMarkModel(
307
331
  const blockEntry = Editor.node(editor, Path.parent(op.path))
308
332
  const block = blockEntry[0]
309
333
 
310
- if (node && isPortableTextSpan(node) && block && isPortableTextBlock(block)) {
334
+ if (
335
+ node &&
336
+ isPortableTextSpan(node) &&
337
+ block &&
338
+ isPortableTextBlock(block)
339
+ ) {
311
340
  const markDefs = block.markDefs ?? []
312
341
  const nodeHasAnnotations = (node.marks ?? []).some((mark) =>
313
342
  markDefs.find((markDef) => markDef._key === mark),
314
343
  )
315
344
  const deletingPartOfTheNode = op.offset !== 0
316
- const deletingFromTheEnd = op.offset + op.text.length === node.text.length
345
+ const deletingFromTheEnd =
346
+ op.offset + op.text.length === node.text.length
317
347
 
318
- if (nodeHasAnnotations && deletingPartOfTheNode && deletingFromTheEnd) {
348
+ if (
349
+ nodeHasAnnotations &&
350
+ deletingPartOfTheNode &&
351
+ deletingFromTheEnd
352
+ ) {
319
353
  Editor.withoutNormalizing(editor, () => {
320
354
  Transforms.splitNodes(editor, {
321
355
  match: Text.isText,
@@ -339,7 +373,11 @@ export function createWithPortableTextMarkModel(
339
373
 
340
374
  Editor.withoutNormalizing(editor, () => {
341
375
  apply(op)
342
- Transforms.setNodes(editor, {marks: marksWithoutAnnotationMarks}, {at: op.path})
376
+ Transforms.setNodes(
377
+ editor,
378
+ {marks: marksWithoutAnnotationMarks},
379
+ {at: op.path},
380
+ )
343
381
  })
344
382
 
345
383
  editor.onChange()
@@ -360,6 +398,36 @@ export function createWithPortableTextMarkModel(
360
398
  }
361
399
  }
362
400
 
401
+ /**
402
+ * Copy over markDefs when merging blocks
403
+ */
404
+ if (
405
+ op.type === 'merge_node' &&
406
+ op.path.length === 1 &&
407
+ 'markDefs' in op.properties &&
408
+ op.properties._type === types.block.name &&
409
+ Array.isArray(op.properties.markDefs) &&
410
+ op.properties.markDefs.length > 0 &&
411
+ op.path[0] - 1 >= 0
412
+ ) {
413
+ const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] - 1])
414
+
415
+ if (editor.isTextBlock(targetBlock)) {
416
+ const oldDefs =
417
+ (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
418
+ const newMarkDefs = uniq([...oldDefs, ...op.properties.markDefs])
419
+
420
+ debug(`Copying markDefs over to merged block`, op)
421
+ Transforms.setNodes(
422
+ editor,
423
+ {markDefs: newMarkDefs},
424
+ {at: targetPath, voids: false},
425
+ )
426
+ apply(op)
427
+ return
428
+ }
429
+ }
430
+
363
431
  apply(op)
364
432
  }
365
433
 
@@ -369,10 +437,19 @@ export function createWithPortableTextMarkModel(
369
437
  if (Range.isExpanded(editor.selection)) {
370
438
  Editor.withoutNormalizing(editor, () => {
371
439
  // Split if needed
372
- Transforms.setNodes(editor, {}, {match: Text.isText, split: true})
440
+ Transforms.setNodes(
441
+ editor,
442
+ {},
443
+ {match: Text.isText, split: true, hanging: true},
444
+ )
373
445
  // Use new selection
374
446
  const splitTextNodes = Range.isRange(editor.selection)
375
- ? [...Editor.nodes(editor, {at: editor.selection, match: Text.isText})]
447
+ ? [
448
+ ...Editor.nodes(editor, {
449
+ at: editor.selection,
450
+ match: Text.isText,
451
+ }),
452
+ ]
376
453
  : []
377
454
  const shouldRemoveMark =
378
455
  splitTextNodes.length > 1 &&
@@ -397,17 +474,49 @@ export function createWithPortableTextMarkModel(
397
474
  }
398
475
  })
399
476
  } else {
400
- const existingMarks: string[] =
401
- {
477
+ const [block, blockPath] = Editor.node(editor, editor.selection, {
478
+ depth: 1,
479
+ })
480
+ const lonelyEmptySpan =
481
+ editor.isTextBlock(block) &&
482
+ block.children.length === 1 &&
483
+ editor.isTextSpan(block.children[0]) &&
484
+ block.children[0].text === ''
485
+ ? block.children[0]
486
+ : undefined
487
+
488
+ if (lonelyEmptySpan) {
489
+ const existingMarks = lonelyEmptySpan.marks ?? []
490
+ const existingMarksWithoutDecorator = existingMarks.filter(
491
+ (existingMark) => existingMark !== mark,
492
+ )
493
+
494
+ Transforms.setNodes(
495
+ editor,
496
+ {
497
+ marks:
498
+ existingMarks.length === existingMarksWithoutDecorator.length
499
+ ? [...existingMarks, mark]
500
+ : existingMarksWithoutDecorator,
501
+ },
502
+ {
503
+ at: blockPath,
504
+ match: (node) => editor.isTextSpan(node),
505
+ },
506
+ )
507
+ } else {
508
+ const existingMarks: string[] =
509
+ {
510
+ ...(Editor.marks(editor) || {}),
511
+ }.marks || []
512
+ const marks = {
402
513
  ...(Editor.marks(editor) || {}),
403
- }.marks || []
404
- const marks = {
405
- ...(Editor.marks(editor) || {}),
406
- marks: [...existingMarks, mark],
514
+ marks: [...existingMarks, mark],
515
+ }
516
+ editor.marks = marks as Text
517
+ forceNewSelection()
518
+ return editor
407
519
  }
408
- editor.marks = marks as Text
409
- forceNewSelection()
410
- return editor
411
520
  }
412
521
  editor.onChange()
413
522
  forceNewSelection()
@@ -422,10 +531,17 @@ export function createWithPortableTextMarkModel(
422
531
  if (Range.isExpanded(selection)) {
423
532
  Editor.withoutNormalizing(editor, () => {
424
533
  // Split if needed
425
- Transforms.setNodes(editor, {}, {match: Text.isText, split: true})
534
+ Transforms.setNodes(
535
+ editor,
536
+ {},
537
+ {match: Text.isText, split: true, hanging: true},
538
+ )
426
539
  if (editor.selection) {
427
540
  const splitTextNodes = [
428
- ...Editor.nodes(editor, {at: editor.selection, match: Text.isText}),
541
+ ...Editor.nodes(editor, {
542
+ at: editor.selection,
543
+ match: Text.isText,
544
+ }),
429
545
  ]
430
546
  splitTextNodes.forEach(([node, path]) => {
431
547
  const block = editor.children[path[0]]
@@ -433,9 +549,10 @@ export function createWithPortableTextMarkModel(
433
549
  Transforms.setNodes(
434
550
  editor,
435
551
  {
436
- marks: (Array.isArray(node.marks) ? node.marks : []).filter(
437
- (eMark: string) => eMark !== mark,
438
- ),
552
+ marks: (Array.isArray(node.marks)
553
+ ? node.marks
554
+ : []
555
+ ).filter((eMark: string) => eMark !== mark),
439
556
  _type: 'span',
440
557
  },
441
558
  {at: path},
@@ -446,17 +563,46 @@ export function createWithPortableTextMarkModel(
446
563
  })
447
564
  Editor.normalize(editor)
448
565
  } else {
449
- const existingMarks: string[] =
450
- {
566
+ const [block, blockPath] = Editor.node(editor, selection, {
567
+ depth: 1,
568
+ })
569
+ const lonelyEmptySpan =
570
+ editor.isTextBlock(block) &&
571
+ block.children.length === 1 &&
572
+ editor.isTextSpan(block.children[0]) &&
573
+ block.children[0].text === ''
574
+ ? block.children[0]
575
+ : undefined
576
+
577
+ if (lonelyEmptySpan) {
578
+ const existingMarks = lonelyEmptySpan.marks ?? []
579
+ const existingMarksWithoutDecorator = existingMarks.filter(
580
+ (existingMark) => existingMark !== mark,
581
+ )
582
+
583
+ Transforms.setNodes(
584
+ editor,
585
+ {
586
+ marks: existingMarksWithoutDecorator,
587
+ },
588
+ {
589
+ at: blockPath,
590
+ match: (node) => editor.isTextSpan(node),
591
+ },
592
+ )
593
+ } else {
594
+ const existingMarks: string[] =
595
+ {
596
+ ...(Editor.marks(editor) || {}),
597
+ }.marks || []
598
+ const marks = {
451
599
  ...(Editor.marks(editor) || {}),
452
- }.marks || []
453
- const marks = {
454
- ...(Editor.marks(editor) || {}),
455
- marks: existingMarks.filter((eMark) => eMark !== mark),
456
- } as Text
457
- editor.marks = {marks: marks.marks, _type: 'span'} as Text
458
- forceNewSelection()
459
- return editor
600
+ marks: existingMarks.filter((eMark) => eMark !== mark),
601
+ } as Text
602
+ editor.marks = {marks: marks.marks, _type: 'span'} as Text
603
+ forceNewSelection()
604
+ return editor
605
+ }
460
606
  }
461
607
  editor.onChange()
462
608
  forceNewSelection()
@@ -1,6 +1,5 @@
1
1
  import {type Subject} from 'rxjs'
2
2
  import {type BaseRange} from 'slate'
3
-
4
3
  import {
5
4
  type EditorChange,
6
5
  type EditorSelection,
@@ -8,7 +7,10 @@ import {
8
7
  type PortableTextSlateEditor,
9
8
  } from '../../types/editor'
10
9
  import {debugWithName} from '../../utils/debug'
11
- import {type ObjectWithKeyAndType, toPortableTextRange} from '../../utils/ranges'
10
+ import {
11
+ toPortableTextRange,
12
+ type ObjectWithKeyAndType,
13
+ } from '../../utils/ranges'
12
14
  import {SLATE_TO_PORTABLE_TEXT_RANGE} from '../../utils/weakMaps'
13
15
 
14
16
  const debug = debugWithName('plugin:withPortableTextSelections')