@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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/lib/index.d.mts +911 -0
- package/lib/index.d.ts +911 -0
- package/lib/index.esm.js +4896 -0
- package/lib/index.esm.js.map +1 -0
- package/lib/index.js +4874 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +4896 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +119 -0
- package/src/editor/Editable.tsx +683 -0
- package/src/editor/PortableTextEditor.tsx +308 -0
- package/src/editor/__tests__/PortableTextEditor.test.tsx +386 -0
- package/src/editor/__tests__/PortableTextEditorTester.tsx +116 -0
- package/src/editor/__tests__/RangeDecorations.test.tsx +115 -0
- package/src/editor/__tests__/handleClick.test.tsx +218 -0
- package/src/editor/__tests__/pteWarningsSelfSolving.test.tsx +389 -0
- package/src/editor/__tests__/utils.ts +39 -0
- package/src/editor/components/DraggableBlock.tsx +287 -0
- package/src/editor/components/Element.tsx +279 -0
- package/src/editor/components/Leaf.tsx +288 -0
- package/src/editor/components/SlateContainer.tsx +81 -0
- package/src/editor/components/Synchronizer.tsx +190 -0
- package/src/editor/hooks/usePortableTextEditor.ts +23 -0
- package/src/editor/hooks/usePortableTextEditorKeyGenerator.ts +24 -0
- package/src/editor/hooks/usePortableTextEditorSelection.ts +22 -0
- package/src/editor/hooks/usePortableTextEditorValue.ts +16 -0
- package/src/editor/hooks/usePortableTextReadOnly.ts +20 -0
- package/src/editor/hooks/useSyncValue.test.tsx +125 -0
- package/src/editor/hooks/useSyncValue.ts +372 -0
- package/src/editor/nodes/DefaultAnnotation.tsx +16 -0
- package/src/editor/nodes/DefaultObject.tsx +15 -0
- package/src/editor/nodes/index.ts +189 -0
- package/src/editor/plugins/__tests__/withEditableAPIDelete.test.tsx +244 -0
- package/src/editor/plugins/__tests__/withEditableAPIGetFragment.test.tsx +142 -0
- package/src/editor/plugins/__tests__/withEditableAPIInsert.test.tsx +346 -0
- package/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx +162 -0
- package/src/editor/plugins/__tests__/withHotkeys.test.tsx +212 -0
- package/src/editor/plugins/__tests__/withInsertBreak.test.tsx +204 -0
- package/src/editor/plugins/__tests__/withPlaceholderBlock.test.tsx +133 -0
- package/src/editor/plugins/__tests__/withPortableTextLists.test.tsx +65 -0
- package/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx +1377 -0
- package/src/editor/plugins/__tests__/withPortableTextSelections.test.tsx +91 -0
- package/src/editor/plugins/__tests__/withUndoRedo.test.tsx +115 -0
- package/src/editor/plugins/createWithEditableAPI.ts +573 -0
- package/src/editor/plugins/createWithHotKeys.ts +304 -0
- package/src/editor/plugins/createWithInsertBreak.ts +45 -0
- package/src/editor/plugins/createWithInsertData.ts +359 -0
- package/src/editor/plugins/createWithMaxBlocks.ts +24 -0
- package/src/editor/plugins/createWithObjectKeys.ts +63 -0
- package/src/editor/plugins/createWithPatches.ts +274 -0
- package/src/editor/plugins/createWithPlaceholderBlock.ts +36 -0
- package/src/editor/plugins/createWithPortableTextBlockStyle.ts +91 -0
- package/src/editor/plugins/createWithPortableTextLists.ts +160 -0
- package/src/editor/plugins/createWithPortableTextMarkModel.ts +441 -0
- package/src/editor/plugins/createWithPortableTextSelections.ts +65 -0
- package/src/editor/plugins/createWithSchemaTypes.ts +76 -0
- package/src/editor/plugins/createWithUndoRedo.ts +494 -0
- package/src/editor/plugins/createWithUtils.ts +81 -0
- package/src/editor/plugins/index.ts +155 -0
- package/src/index.ts +11 -0
- package/src/patch/PatchEvent.ts +33 -0
- package/src/patch/applyPatch.ts +29 -0
- package/src/patch/array.ts +89 -0
- package/src/patch/arrayInsert.ts +27 -0
- package/src/patch/object.ts +39 -0
- package/src/patch/patches.ts +53 -0
- package/src/patch/primitive.ts +43 -0
- package/src/patch/string.ts +51 -0
- package/src/types/editor.ts +576 -0
- package/src/types/options.ts +17 -0
- package/src/types/patch.ts +65 -0
- package/src/types/slate.ts +25 -0
- package/src/utils/__tests__/dmpToOperations.test.ts +181 -0
- package/src/utils/__tests__/operationToPatches.test.ts +421 -0
- package/src/utils/__tests__/patchToOperations.test.ts +293 -0
- package/src/utils/__tests__/ranges.test.ts +18 -0
- package/src/utils/__tests__/valueNormalization.test.tsx +62 -0
- package/src/utils/__tests__/values.test.ts +253 -0
- package/src/utils/applyPatch.ts +407 -0
- package/src/utils/bufferUntil.ts +15 -0
- package/src/utils/debug.ts +12 -0
- package/src/utils/getPortableTextMemberSchemaTypes.ts +100 -0
- package/src/utils/operationToPatches.ts +357 -0
- package/src/utils/patches.ts +36 -0
- package/src/utils/paths.ts +60 -0
- package/src/utils/ranges.ts +77 -0
- package/src/utils/schema.ts +8 -0
- package/src/utils/selection.ts +65 -0
- package/src/utils/ucs2Indices.ts +67 -0
- package/src/utils/validateValue.ts +394 -0
- package/src/utils/values.ts +208 -0
- package/src/utils/weakMaps.ts +24 -0
- package/src/utils/withChanges.ts +25 -0
- package/src/utils/withPreserveKeys.ts +14 -0
- 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
|
+
}
|