@portabletext/editor 0.0.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/lib/index.d.mts +911 -0
  4. package/lib/index.d.ts +911 -0
  5. package/lib/index.esm.js +4896 -0
  6. package/lib/index.esm.js.map +1 -0
  7. package/lib/index.js +4874 -0
  8. package/lib/index.js.map +1 -0
  9. package/lib/index.mjs +4896 -0
  10. package/lib/index.mjs.map +1 -0
  11. package/package.json +119 -0
  12. package/src/editor/Editable.tsx +683 -0
  13. package/src/editor/PortableTextEditor.tsx +308 -0
  14. package/src/editor/__tests__/PortableTextEditor.test.tsx +386 -0
  15. package/src/editor/__tests__/PortableTextEditorTester.tsx +116 -0
  16. package/src/editor/__tests__/RangeDecorations.test.tsx +115 -0
  17. package/src/editor/__tests__/handleClick.test.tsx +218 -0
  18. package/src/editor/__tests__/pteWarningsSelfSolving.test.tsx +389 -0
  19. package/src/editor/__tests__/utils.ts +39 -0
  20. package/src/editor/components/DraggableBlock.tsx +287 -0
  21. package/src/editor/components/Element.tsx +279 -0
  22. package/src/editor/components/Leaf.tsx +288 -0
  23. package/src/editor/components/SlateContainer.tsx +81 -0
  24. package/src/editor/components/Synchronizer.tsx +190 -0
  25. package/src/editor/hooks/usePortableTextEditor.ts +23 -0
  26. package/src/editor/hooks/usePortableTextEditorKeyGenerator.ts +24 -0
  27. package/src/editor/hooks/usePortableTextEditorSelection.ts +22 -0
  28. package/src/editor/hooks/usePortableTextEditorValue.ts +16 -0
  29. package/src/editor/hooks/usePortableTextReadOnly.ts +20 -0
  30. package/src/editor/hooks/useSyncValue.test.tsx +125 -0
  31. package/src/editor/hooks/useSyncValue.ts +372 -0
  32. package/src/editor/nodes/DefaultAnnotation.tsx +16 -0
  33. package/src/editor/nodes/DefaultObject.tsx +15 -0
  34. package/src/editor/nodes/index.ts +189 -0
  35. package/src/editor/plugins/__tests__/withEditableAPIDelete.test.tsx +244 -0
  36. package/src/editor/plugins/__tests__/withEditableAPIGetFragment.test.tsx +142 -0
  37. package/src/editor/plugins/__tests__/withEditableAPIInsert.test.tsx +346 -0
  38. package/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx +162 -0
  39. package/src/editor/plugins/__tests__/withHotkeys.test.tsx +212 -0
  40. package/src/editor/plugins/__tests__/withInsertBreak.test.tsx +204 -0
  41. package/src/editor/plugins/__tests__/withPlaceholderBlock.test.tsx +133 -0
  42. package/src/editor/plugins/__tests__/withPortableTextLists.test.tsx +65 -0
  43. package/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx +1377 -0
  44. package/src/editor/plugins/__tests__/withPortableTextSelections.test.tsx +91 -0
  45. package/src/editor/plugins/__tests__/withUndoRedo.test.tsx +115 -0
  46. package/src/editor/plugins/createWithEditableAPI.ts +573 -0
  47. package/src/editor/plugins/createWithHotKeys.ts +304 -0
  48. package/src/editor/plugins/createWithInsertBreak.ts +45 -0
  49. package/src/editor/plugins/createWithInsertData.ts +359 -0
  50. package/src/editor/plugins/createWithMaxBlocks.ts +24 -0
  51. package/src/editor/plugins/createWithObjectKeys.ts +63 -0
  52. package/src/editor/plugins/createWithPatches.ts +274 -0
  53. package/src/editor/plugins/createWithPlaceholderBlock.ts +36 -0
  54. package/src/editor/plugins/createWithPortableTextBlockStyle.ts +91 -0
  55. package/src/editor/plugins/createWithPortableTextLists.ts +160 -0
  56. package/src/editor/plugins/createWithPortableTextMarkModel.ts +441 -0
  57. package/src/editor/plugins/createWithPortableTextSelections.ts +65 -0
  58. package/src/editor/plugins/createWithSchemaTypes.ts +76 -0
  59. package/src/editor/plugins/createWithUndoRedo.ts +494 -0
  60. package/src/editor/plugins/createWithUtils.ts +81 -0
  61. package/src/editor/plugins/index.ts +155 -0
  62. package/src/index.ts +11 -0
  63. package/src/patch/PatchEvent.ts +33 -0
  64. package/src/patch/applyPatch.ts +29 -0
  65. package/src/patch/array.ts +89 -0
  66. package/src/patch/arrayInsert.ts +27 -0
  67. package/src/patch/object.ts +39 -0
  68. package/src/patch/patches.ts +53 -0
  69. package/src/patch/primitive.ts +43 -0
  70. package/src/patch/string.ts +51 -0
  71. package/src/types/editor.ts +576 -0
  72. package/src/types/options.ts +17 -0
  73. package/src/types/patch.ts +65 -0
  74. package/src/types/slate.ts +25 -0
  75. package/src/utils/__tests__/dmpToOperations.test.ts +181 -0
  76. package/src/utils/__tests__/operationToPatches.test.ts +421 -0
  77. package/src/utils/__tests__/patchToOperations.test.ts +293 -0
  78. package/src/utils/__tests__/ranges.test.ts +18 -0
  79. package/src/utils/__tests__/valueNormalization.test.tsx +62 -0
  80. package/src/utils/__tests__/values.test.ts +253 -0
  81. package/src/utils/applyPatch.ts +407 -0
  82. package/src/utils/bufferUntil.ts +15 -0
  83. package/src/utils/debug.ts +12 -0
  84. package/src/utils/getPortableTextMemberSchemaTypes.ts +100 -0
  85. package/src/utils/operationToPatches.ts +357 -0
  86. package/src/utils/patches.ts +36 -0
  87. package/src/utils/paths.ts +60 -0
  88. package/src/utils/ranges.ts +77 -0
  89. package/src/utils/schema.ts +8 -0
  90. package/src/utils/selection.ts +65 -0
  91. package/src/utils/ucs2Indices.ts +67 -0
  92. package/src/utils/validateValue.ts +394 -0
  93. package/src/utils/values.ts +208 -0
  94. package/src/utils/weakMaps.ts +24 -0
  95. package/src/utils/withChanges.ts +25 -0
  96. package/src/utils/withPreserveKeys.ts +14 -0
  97. package/src/utils/withoutPatching.ts +14 -0
@@ -0,0 +1,441 @@
1
+ /* eslint-disable max-statements */
2
+ /* eslint-disable complexity */
3
+ /**
4
+ *
5
+ * This plugin will change Slate's default marks model (every prop is a mark) with the Portable Text model (marks is an array of strings on prop .marks).
6
+ *
7
+ */
8
+
9
+ import {isEqual, uniq} from 'lodash'
10
+ import {type Subject} from 'rxjs'
11
+ import {type Descendant, Editor, Element, Path, Range, Text, Transforms} from 'slate'
12
+
13
+ import {
14
+ type EditorChange,
15
+ type PortableTextMemberSchemaTypes,
16
+ type PortableTextSlateEditor,
17
+ } from '../../types/editor'
18
+ import {debugWithName} from '../../utils/debug'
19
+ import {toPortableTextRange} from '../../utils/ranges'
20
+ import {EMPTY_MARKS} from '../../utils/values'
21
+
22
+ const debug = debugWithName('plugin:withPortableTextMarkModel')
23
+
24
+ export function createWithPortableTextMarkModel(
25
+ types: PortableTextMemberSchemaTypes,
26
+ change$: Subject<EditorChange>,
27
+ ): (editor: PortableTextSlateEditor) => PortableTextSlateEditor {
28
+ return function withPortableTextMarkModel(editor: PortableTextSlateEditor) {
29
+ const {apply, normalizeNode} = editor
30
+ const decorators = types.decorators.map((t) => t.value)
31
+
32
+ // Selections are normally emitted automatically via
33
+ // onChange, but they will keep the object reference if
34
+ // the selection is the same as the previous.
35
+ // When toggling marks however, it might not even
36
+ // result in a onChange event (for instance when nothing is selected),
37
+ // and if you toggle marks on a block with one single span,
38
+ // the selection would also stay the same.
39
+ // We should force a new selection object here when toggling marks,
40
+ // because toolbars and other things can very conveniently
41
+ // be memo'ed on the editor selection to update itself.
42
+ const forceNewSelection = () => {
43
+ if (editor.selection) {
44
+ Transforms.select(editor, {...editor.selection})
45
+ editor.selection = {...editor.selection} // Ensure new object
46
+ }
47
+ const ptRange = toPortableTextRange(editor.children, editor.selection, types)
48
+ change$.next({type: 'selection', selection: ptRange})
49
+ }
50
+
51
+ // Extend Slate's default normalization. Merge spans with same set of .marks when doing merge_node operations, and clean up markDefs / marks
52
+ editor.normalizeNode = (nodeEntry) => {
53
+ normalizeNode(nodeEntry)
54
+ if (
55
+ editor.operations.some((op) =>
56
+ [
57
+ 'insert_node',
58
+ 'insert_text',
59
+ 'merge_node',
60
+ 'remove_node',
61
+ 'remove_text',
62
+ 'set_node',
63
+ ].includes(op.type),
64
+ )
65
+ ) {
66
+ mergeSpans(editor)
67
+ }
68
+ const [node, path] = nodeEntry
69
+ const isSpan = Text.isText(node) && node._type === types.span.name
70
+ const isTextBlock = editor.isTextBlock(node)
71
+ if (isSpan || isTextBlock) {
72
+ if (isSpan && !Array.isArray(node.marks)) {
73
+ debug('Adding .marks to span node')
74
+ Transforms.setNodes(editor, {marks: []}, {at: path})
75
+ editor.onChange()
76
+ }
77
+ const hasSpanMarks = isSpan && (node.marks || []).length > 0
78
+ if (hasSpanMarks) {
79
+ const spanMarks = node.marks || EMPTY_MARKS
80
+ // Test that every annotation mark used has a definition in markDefs
81
+ const annotationMarks = spanMarks.filter(
82
+ (mark) => !types.decorators.map((dec) => dec.value).includes(mark),
83
+ )
84
+ if (annotationMarks.length > 0) {
85
+ const [block] = Editor.node(editor, Path.parent(path))
86
+ const orphanedMarks =
87
+ (editor.isTextBlock(block) &&
88
+ annotationMarks.filter(
89
+ (mark) => !block.markDefs?.find((def) => def._key === mark),
90
+ )) ||
91
+ []
92
+ if (orphanedMarks.length > 0) {
93
+ debug('Removing orphaned .marks from span node')
94
+ Transforms.setNodes(
95
+ editor,
96
+ {marks: spanMarks.filter((mark) => !orphanedMarks.includes(mark))},
97
+ {at: path},
98
+ )
99
+ editor.onChange()
100
+ }
101
+ }
102
+ }
103
+ for (const op of editor.operations) {
104
+ // Make sure markDefs are copied over when merging two blocks.
105
+ if (
106
+ op.type === 'merge_node' &&
107
+ op.path.length === 1 &&
108
+ 'markDefs' in op.properties &&
109
+ op.properties._type === types.block.name &&
110
+ Array.isArray(op.properties.markDefs) &&
111
+ op.properties.markDefs.length > 0 &&
112
+ op.path[0] - 1 >= 0
113
+ ) {
114
+ const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] - 1])
115
+ debug(`Copying markDefs over to merged block`, op)
116
+ if (editor.isTextBlock(targetBlock)) {
117
+ const oldDefs = (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
118
+ const newMarkDefs = uniq([...oldDefs, ...op.properties.markDefs])
119
+ const isNormalized = isEqual(newMarkDefs, targetBlock.markDefs)
120
+ // eslint-disable-next-line max-depth
121
+ if (!isNormalized) {
122
+ Transforms.setNodes(editor, {markDefs: newMarkDefs}, {at: targetPath, voids: false})
123
+ editor.onChange()
124
+ }
125
+ }
126
+ }
127
+ // Make sure markDefs are copied over to new block when splitting a block.
128
+ if (
129
+ op.type === 'split_node' &&
130
+ op.path.length === 1 &&
131
+ Element.isElementProps(op.properties) &&
132
+ op.properties._type === types.block.name &&
133
+ 'markDefs' in op.properties &&
134
+ Array.isArray(op.properties.markDefs) &&
135
+ op.properties.markDefs.length > 0 &&
136
+ op.path[0] + 1 < editor.children.length
137
+ ) {
138
+ const [targetBlock, targetPath] = Editor.node(editor, [op.path[0] + 1])
139
+ debug(`Copying markDefs over to split block`, op)
140
+ if (editor.isTextBlock(targetBlock)) {
141
+ const oldDefs = (Array.isArray(targetBlock.markDefs) && targetBlock.markDefs) || []
142
+ Transforms.setNodes(
143
+ editor,
144
+ {markDefs: uniq([...oldDefs, ...op.properties.markDefs])},
145
+ {at: targetPath, voids: false},
146
+ )
147
+ editor.onChange()
148
+ }
149
+ }
150
+ // Make sure marks are reset, if a block is split at the end.
151
+ if (
152
+ op.type === 'split_node' &&
153
+ op.path.length === 2 &&
154
+ (op.properties as unknown as Descendant)._type === types.span.name &&
155
+ 'marks' in op.properties &&
156
+ Array.isArray(op.properties.marks) &&
157
+ op.properties.marks.length > 0 &&
158
+ op.path[0] + 1 < editor.children.length
159
+ ) {
160
+ const [child, childPath] = Editor.node(editor, [op.path[0] + 1, 0])
161
+ if (
162
+ Text.isText(child) &&
163
+ child.text === '' &&
164
+ Array.isArray(child.marks) &&
165
+ child.marks.length > 0
166
+ ) {
167
+ Transforms.setNodes(editor, {marks: []}, {at: childPath, voids: false})
168
+ editor.onChange()
169
+ }
170
+ }
171
+ // Make sure markDefs are reset, if a block is split at start.
172
+ if (
173
+ op.type === 'split_node' &&
174
+ op.path.length === 1 &&
175
+ (op.properties as unknown as Descendant)._type === types.block.name &&
176
+ 'markDefs' in op.properties &&
177
+ Array.isArray(op.properties.markDefs) &&
178
+ op.properties.markDefs.length > 0
179
+ ) {
180
+ const [block, blockPath] = Editor.node(editor, [op.path[0]])
181
+ if (
182
+ editor.isTextBlock(block) &&
183
+ block.children.length === 1 &&
184
+ block.markDefs &&
185
+ block.markDefs.length > 0 &&
186
+ Text.isText(block.children[0]) &&
187
+ block.children[0].text === '' &&
188
+ (!block.children[0].marks || block.children[0].marks.length === 0)
189
+ ) {
190
+ Transforms.setNodes(editor, {markDefs: []}, {at: blockPath})
191
+ editor.onChange()
192
+ }
193
+ }
194
+ }
195
+ // Empty marks if text is empty
196
+ if (
197
+ isSpan &&
198
+ Array.isArray(node.marks) &&
199
+ (!node.marks || (node.marks.length > 0 && node.text === ''))
200
+ ) {
201
+ Transforms.setNodes(editor, {marks: []}, {at: path, voids: false})
202
+ editor.onChange()
203
+ }
204
+ }
205
+ // Check consistency of markDefs (unless we are merging two nodes)
206
+ if (
207
+ editor.isTextBlock(node) &&
208
+ !editor.operations.some(
209
+ (op) => op.type === 'merge_node' && 'markDefs' in op.properties && op.path.length === 1,
210
+ )
211
+ ) {
212
+ const newMarkDefs = (node.markDefs || []).filter((def) => {
213
+ return node.children.find((child) => {
214
+ return (
215
+ Text.isText(child) && Array.isArray(child.marks) && child.marks.includes(def._key)
216
+ )
217
+ })
218
+ })
219
+ if (node.markDefs && !isEqual(newMarkDefs, node.markDefs)) {
220
+ debug('Removing markDef not in use')
221
+ Transforms.setNodes(
222
+ editor,
223
+ {
224
+ markDefs: newMarkDefs,
225
+ },
226
+ {at: path},
227
+ )
228
+ editor.onChange()
229
+ }
230
+ }
231
+ }
232
+
233
+ // Special hook before inserting text at the end of an annotation.
234
+ editor.apply = (op) => {
235
+ if (op.type === 'insert_text') {
236
+ const {selection} = editor
237
+ if (
238
+ selection &&
239
+ Range.isCollapsed(selection) &&
240
+ Editor.marks(editor)?.marks?.some((mark) => !decorators.includes(mark))
241
+ ) {
242
+ const [node] = Array.from(
243
+ Editor.nodes(editor, {
244
+ mode: 'lowest',
245
+ at: selection.focus,
246
+ match: (n) => (n as unknown as Descendant)._type === types.span.name,
247
+ voids: false,
248
+ }),
249
+ )[0] || [undefined]
250
+ if (
251
+ Text.isText(node) &&
252
+ node.text.length === selection.focus.offset &&
253
+ Array.isArray(node.marks) &&
254
+ node.marks.length > 0
255
+ ) {
256
+ apply(op)
257
+ Transforms.splitNodes(editor, {
258
+ match: Text.isText,
259
+ at: {...selection.focus, offset: selection.focus.offset},
260
+ })
261
+ const marksWithoutAnnotationMarks: string[] = (
262
+ {
263
+ ...(Editor.marks(editor) || {}),
264
+ }.marks || []
265
+ ).filter((mark) => decorators.includes(mark))
266
+ Transforms.setNodes(
267
+ editor,
268
+ {marks: marksWithoutAnnotationMarks},
269
+ {at: Path.next(selection.focus.path)},
270
+ )
271
+ debug('Inserting text at end of annotation')
272
+ return
273
+ }
274
+ }
275
+ }
276
+ apply(op)
277
+ }
278
+
279
+ // Override built in addMark function
280
+ editor.addMark = (mark: string) => {
281
+ if (editor.selection) {
282
+ if (Range.isExpanded(editor.selection)) {
283
+ // Split if needed
284
+ Transforms.setNodes(editor, {}, {match: Text.isText, split: true})
285
+ // Use new selection
286
+ const splitTextNodes = [
287
+ ...Editor.nodes(editor, {at: editor.selection, match: Text.isText}),
288
+ ]
289
+ const shouldRemoveMark = splitTextNodes.every((node) => node[0].marks?.includes(mark))
290
+
291
+ if (shouldRemoveMark) {
292
+ editor.removeMark(mark)
293
+ return editor
294
+ }
295
+ Editor.withoutNormalizing(editor, () => {
296
+ splitTextNodes.forEach(([node, path]) => {
297
+ const marks = [
298
+ ...(Array.isArray(node.marks) ? node.marks : []).filter(
299
+ (eMark: string) => eMark !== mark,
300
+ ),
301
+ mark,
302
+ ]
303
+ Transforms.setNodes(
304
+ editor,
305
+ {marks},
306
+ {at: path, match: Text.isText, split: true, hanging: true},
307
+ )
308
+ })
309
+ })
310
+ Editor.normalize(editor)
311
+ } else {
312
+ const existingMarks: string[] =
313
+ {
314
+ ...(Editor.marks(editor) || {}),
315
+ }.marks || []
316
+ const marks = {
317
+ ...(Editor.marks(editor) || {}),
318
+ marks: [...existingMarks, mark],
319
+ }
320
+ editor.marks = marks as Text
321
+ forceNewSelection()
322
+ return editor
323
+ }
324
+ editor.onChange()
325
+ forceNewSelection()
326
+ }
327
+ return editor
328
+ }
329
+
330
+ // Override built in removeMark function
331
+ editor.removeMark = (mark: string) => {
332
+ const {selection} = editor
333
+ if (selection) {
334
+ if (Range.isExpanded(selection)) {
335
+ Editor.withoutNormalizing(editor, () => {
336
+ // Split if needed
337
+ Transforms.setNodes(editor, {}, {match: Text.isText, split: true})
338
+ if (editor.selection) {
339
+ const splitTextNodes = [
340
+ ...Editor.nodes(editor, {at: editor.selection, match: Text.isText}),
341
+ ]
342
+ splitTextNodes.forEach(([node, path]) => {
343
+ const block = editor.children[path[0]]
344
+ if (Element.isElement(block) && block.children.includes(node)) {
345
+ Transforms.setNodes(
346
+ editor,
347
+ {
348
+ marks: (Array.isArray(node.marks) ? node.marks : []).filter(
349
+ (eMark: string) => eMark !== mark,
350
+ ),
351
+ _type: 'span',
352
+ },
353
+ {at: path},
354
+ )
355
+ }
356
+ })
357
+ }
358
+ })
359
+ Editor.normalize(editor)
360
+ } else {
361
+ const existingMarks: string[] =
362
+ {
363
+ ...(Editor.marks(editor) || {}),
364
+ }.marks || []
365
+ const marks = {
366
+ ...(Editor.marks(editor) || {}),
367
+ marks: existingMarks.filter((eMark) => eMark !== mark),
368
+ } as Text
369
+ editor.marks = {marks: marks.marks, _type: 'span'} as Text
370
+ forceNewSelection()
371
+ return editor
372
+ }
373
+ editor.onChange()
374
+ forceNewSelection()
375
+ }
376
+ return editor
377
+ }
378
+
379
+ editor.pteIsMarkActive = (mark: string): boolean => {
380
+ if (!editor.selection) {
381
+ return false
382
+ }
383
+
384
+ const selectedNodes = Array.from(
385
+ Editor.nodes(editor, {match: Text.isText, at: editor.selection}),
386
+ )
387
+
388
+ if (Range.isExpanded(editor.selection)) {
389
+ return selectedNodes.every((n) => {
390
+ const [node] = n
391
+
392
+ return node.marks?.includes(mark)
393
+ })
394
+ }
395
+
396
+ return (
397
+ {
398
+ ...(Editor.marks(editor) || {}),
399
+ }.marks || []
400
+ ).includes(mark)
401
+ }
402
+
403
+ // Custom editor function to toggle a mark
404
+ editor.pteToggleMark = (mark: string) => {
405
+ const isActive = editor.pteIsMarkActive(mark)
406
+ if (isActive) {
407
+ debug(`Remove mark '${mark}'`)
408
+ Editor.removeMark(editor, mark)
409
+ } else {
410
+ debug(`Add mark '${mark}'`)
411
+ Editor.addMark(editor, mark, true)
412
+ }
413
+ }
414
+ return editor
415
+ }
416
+
417
+ /**
418
+ * Normalize re-marked spans in selection
419
+ */
420
+ function mergeSpans(editor: PortableTextSlateEditor) {
421
+ const {selection} = editor
422
+ if (selection) {
423
+ for (const [node, path] of Array.from(
424
+ Editor.nodes(editor, {
425
+ at: Editor.range(editor, [selection.anchor.path[0]], [selection.focus.path[0]]),
426
+ }),
427
+ ).reverse()) {
428
+ const [parent] = path.length > 1 ? Editor.node(editor, Path.parent(path)) : [undefined]
429
+ const nextPath = [path[0], path[1] + 1]
430
+ if (editor.isTextBlock(parent)) {
431
+ const nextNode = parent.children[nextPath[1]]
432
+ if (Text.isText(node) && Text.isText(nextNode) && isEqual(nextNode.marks, node.marks)) {
433
+ debug('Merging spans')
434
+ Transforms.mergeNodes(editor, {at: nextPath, voids: true})
435
+ editor.onChange()
436
+ }
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
@@ -0,0 +1,65 @@
1
+ import {type Subject} from 'rxjs'
2
+ import {type BaseRange} from 'slate'
3
+
4
+ import {
5
+ type EditorChange,
6
+ type EditorSelection,
7
+ type PortableTextMemberSchemaTypes,
8
+ type PortableTextSlateEditor,
9
+ } from '../../types/editor'
10
+ import {debugWithName} from '../../utils/debug'
11
+ import {type ObjectWithKeyAndType, toPortableTextRange} from '../../utils/ranges'
12
+ import {SLATE_TO_PORTABLE_TEXT_RANGE} from '../../utils/weakMaps'
13
+
14
+ const debug = debugWithName('plugin:withPortableTextSelections')
15
+ const debugVerbose = debug.enabled && false
16
+
17
+ // This plugin will make sure that we emit a PT selection whenever the editor has changed.
18
+ export function createWithPortableTextSelections(
19
+ change$: Subject<EditorChange>,
20
+ types: PortableTextMemberSchemaTypes,
21
+ ): (editor: PortableTextSlateEditor) => PortableTextSlateEditor {
22
+ let prevSelection: BaseRange | null = null
23
+ return function withPortableTextSelections(
24
+ editor: PortableTextSlateEditor,
25
+ ): PortableTextSlateEditor {
26
+ const emitPortableTextSelection = () => {
27
+ if (prevSelection !== editor.selection) {
28
+ let ptRange: EditorSelection = null
29
+ if (editor.selection) {
30
+ const existing = SLATE_TO_PORTABLE_TEXT_RANGE.get(editor.selection)
31
+ if (existing) {
32
+ ptRange = existing
33
+ } else {
34
+ const value = editor.children satisfies ObjectWithKeyAndType[]
35
+ ptRange = toPortableTextRange(value, editor.selection, types)
36
+ SLATE_TO_PORTABLE_TEXT_RANGE.set(editor.selection, ptRange)
37
+ }
38
+ }
39
+ if (debugVerbose) {
40
+ debug(
41
+ `Emitting selection ${JSON.stringify(ptRange || null)} (${JSON.stringify(
42
+ editor.selection,
43
+ )})`,
44
+ )
45
+ }
46
+ if (ptRange) {
47
+ change$.next({type: 'selection', selection: ptRange})
48
+ } else {
49
+ change$.next({type: 'selection', selection: null})
50
+ }
51
+ }
52
+ prevSelection = editor.selection
53
+ }
54
+
55
+ const {onChange} = editor
56
+ editor.onChange = () => {
57
+ const hasChanges = editor.operations.length > 0
58
+ onChange()
59
+ if (hasChanges) {
60
+ emitPortableTextSelection()
61
+ }
62
+ }
63
+ return editor
64
+ }
65
+ }
@@ -0,0 +1,76 @@
1
+ import {
2
+ isPortableTextListBlock,
3
+ isPortableTextSpan,
4
+ isPortableTextTextBlock,
5
+ type PortableTextListBlock,
6
+ type PortableTextSpan,
7
+ type PortableTextTextBlock,
8
+ } from '@sanity/types'
9
+ import {type Element, Transforms} from 'slate'
10
+
11
+ import {type PortableTextMemberSchemaTypes, type PortableTextSlateEditor} from '../../types/editor'
12
+ import {debugWithName} from '../../utils/debug'
13
+
14
+ const debug = debugWithName('plugin:withSchemaTypes')
15
+ /**
16
+ * This plugin makes sure that schema types are recognized properly by Slate as blocks, voids, inlines
17
+ *
18
+ */
19
+ export function createWithSchemaTypes({
20
+ schemaTypes,
21
+ keyGenerator,
22
+ }: {
23
+ schemaTypes: PortableTextMemberSchemaTypes
24
+ keyGenerator: () => string
25
+ }) {
26
+ return function withSchemaTypes(editor: PortableTextSlateEditor): PortableTextSlateEditor {
27
+ editor.isTextBlock = (value: unknown): value is PortableTextTextBlock => {
28
+ return isPortableTextTextBlock(value) && value._type === schemaTypes.block.name
29
+ }
30
+ editor.isTextSpan = (value: unknown): value is PortableTextSpan => {
31
+ return isPortableTextSpan(value) && value._type == schemaTypes.span.name
32
+ }
33
+ editor.isListBlock = (value: unknown): value is PortableTextListBlock => {
34
+ return isPortableTextListBlock(value) && value._type === schemaTypes.block.name
35
+ }
36
+ editor.isVoid = (element: Element): boolean => {
37
+ return (
38
+ schemaTypes.block.name !== element._type &&
39
+ (schemaTypes.blockObjects.map((obj) => obj.name).includes(element._type) ||
40
+ schemaTypes.inlineObjects.map((obj) => obj.name).includes(element._type))
41
+ )
42
+ }
43
+ editor.isInline = (element: Element): boolean => {
44
+ const inlineSchemaTypes = schemaTypes.inlineObjects.map((obj) => obj.name)
45
+ return (
46
+ inlineSchemaTypes.includes(element._type) &&
47
+ '__inline' in element &&
48
+ element.__inline === true
49
+ )
50
+ }
51
+
52
+ // Extend Slate's default normalization
53
+ const {normalizeNode} = editor
54
+ editor.normalizeNode = (entry) => {
55
+ const [node, path] = entry
56
+
57
+ // If text block children node is missing _type, set it to the span type
58
+ if (node._type === undefined && path.length === 2) {
59
+ debug('Setting span type on text node without a type')
60
+ const span = node as PortableTextSpan
61
+ const key = span._key || keyGenerator()
62
+ Transforms.setNodes(editor, {...span, _type: schemaTypes.span.name, _key: key}, {at: path})
63
+ }
64
+
65
+ // catches cases when the children are missing keys but excludes it when the normalize is running the node as the editor object
66
+ if (node._key === undefined && (path.length === 1 || path.length === 2)) {
67
+ debug('Setting missing key on child node without a key')
68
+ const key = keyGenerator()
69
+ Transforms.setNodes(editor, {_key: key}, {at: path})
70
+ }
71
+
72
+ normalizeNode(entry)
73
+ }
74
+ return editor
75
+ }
76
+ }