svedit 0.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/dist/AnnotatedStringProperty.svelte +126 -0
  4. package/dist/AnnotatedStringProperty.svelte.d.ts +7 -0
  5. package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -0
  6. package/dist/CustomProperty.svelte +40 -0
  7. package/dist/CustomProperty.svelte.d.ts +8 -0
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -0
  9. package/dist/Document.svelte.d.ts +191 -0
  10. package/dist/Document.svelte.d.ts.map +1 -0
  11. package/dist/Document.svelte.js +681 -0
  12. package/dist/Node.svelte +41 -0
  13. package/dist/Node.svelte.d.ts +7 -0
  14. package/dist/Node.svelte.d.ts.map +1 -0
  15. package/dist/NodeArrayProperty.svelte +45 -0
  16. package/dist/NodeArrayProperty.svelte.d.ts +7 -0
  17. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -0
  18. package/dist/Svedit.svelte +936 -0
  19. package/dist/Svedit.svelte.d.ts +12 -0
  20. package/dist/Svedit.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +198 -0
  22. package/dist/Transaction.svelte.d.ts.map +1 -0
  23. package/dist/Transaction.svelte.js +577 -0
  24. package/dist/UnknownNode.svelte +12 -0
  25. package/dist/UnknownNode.svelte.d.ts +12 -0
  26. package/dist/UnknownNode.svelte.d.ts.map +1 -0
  27. package/dist/commands.svelte.d.ts +5 -0
  28. package/dist/commands.svelte.d.ts.map +1 -0
  29. package/dist/commands.svelte.js +261 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +14 -0
  33. package/dist/types.d.ts +264 -0
  34. package/dist/util.d.ts +4 -0
  35. package/dist/util.d.ts.map +1 -0
  36. package/dist/util.js +78 -0
  37. package/package.json +65 -0
  38. package/src/lib/AnnotatedStringProperty.svelte +126 -0
  39. package/src/lib/CustomProperty.svelte +40 -0
  40. package/src/lib/Document.svelte.js +681 -0
  41. package/src/lib/Node.svelte +41 -0
  42. package/src/lib/NodeArrayProperty.svelte +45 -0
  43. package/src/lib/Svedit.svelte +936 -0
  44. package/src/lib/Transaction.svelte.js +577 -0
  45. package/src/lib/UnknownNode.svelte +12 -0
  46. package/src/lib/commands.svelte.js +261 -0
  47. package/src/lib/index.js +14 -0
  48. package/src/lib/types.d.ts +264 -0
  49. package/src/lib/util.js +78 -0
@@ -0,0 +1,577 @@
1
+ /**
2
+ * @import { NodeId, SerializedNode, Selection, DocumentPath } from './types.js'
3
+ */
4
+
5
+ /**
6
+ * Transaction class for managing atomic document operations with undo/redo support.
7
+ *
8
+ * A Transaction provides a way to group multiple document operations (create, delete, set)
9
+ * into a single atomic unit that can be applied or rolled back as one. It maintains
10
+ * both forward operations and their inverse operations for undo functionality.
11
+ *
12
+ * @example
13
+ * ```js
14
+ * const tr = doc.tr;
15
+ * tr.set(['node_1', 'title'], 'New Title');
16
+ * tr.create({id: 'node_2', type: 'paragraph', content: 'Hello'});
17
+ * doc.apply(tr); // Applies all operations atomically
18
+ * ```
19
+ */
20
+ export default class Transaction {
21
+ /**
22
+ * Creates a new Transaction for the given document.
23
+ *
24
+ * @param {any} doc - The document instance this transaction operates on
25
+ */
26
+ constructor(doc) {
27
+ this.doc = doc;
28
+ // Here we track the ops during the transaction
29
+ /** @type {Record<string, any>[]} */
30
+ this.ops = [];
31
+ /** @type {Record<string, any>[]} */
32
+ this.inverse_ops = [];
33
+ // Remember the selection before the transaction started
34
+ this.selection_before = this.doc.selection;
35
+ }
36
+
37
+ /**
38
+ * Gets a value from the document at the specified path.
39
+ *
40
+ * @param {DocumentPath} path - The path to the value in the document
41
+ * @returns {any} The value at the specified path
42
+ */
43
+ get(path) {
44
+ return this.doc.get(path);
45
+ }
46
+
47
+ /**
48
+ * Gets the current selection state of the document.
49
+ *
50
+ * @returns {Selection} The current selection object
51
+ */
52
+ get selection() {
53
+ return this.doc.selection;
54
+ }
55
+
56
+ /**
57
+ * Sets a property of a node to a new value.
58
+ *
59
+ * This is the core operation for modifying document properties. It records
60
+ * both the forward operation and its inverse for undo support.
61
+ *
62
+ * @param {DocumentPath} path - Array path to the property (e.g., ["node_1", "title"])
63
+ * @param {any} value - The new value to set
64
+ *
65
+ * @example
66
+ * ```js
67
+ * tr.set(["list_1", "list_items"], [1, 2, 3]);
68
+ * tr.set(["page_1", "body", "0", "description"], ["Hello world", []]);
69
+ * ```
70
+ */
71
+ set (path, value) {
72
+ const node = this.doc.get(path.slice(0, -1));
73
+
74
+ // Turns ["page_1", "body", "0", "description"]
75
+ // into ["paragraph_1", "description"].
76
+ // Important to keep changes of multiple ops invertible.
77
+ const normalized_path = [node.id, path.at(-1)];
78
+
79
+ // Just to be sure, make a deep copy of the old value
80
+ const property_key = path.at(-1);
81
+ if (property_key === undefined) {
82
+ throw new Error('Invalid path: cannot get property key');
83
+ }
84
+ const previous_value = structuredClone($state.snapshot(node[property_key]));
85
+
86
+ const op = ['set', normalized_path, value];
87
+ this.ops.push(op);
88
+ this.inverse_ops.push(['set', path, previous_value]);
89
+ this.doc._apply_op(op);
90
+ }
91
+
92
+ /**
93
+ * Creates a new node in the document.
94
+ *
95
+ * The node must have a valid id and must not already exist in the document.
96
+ * The node is validated against the document schema before creation.
97
+ *
98
+ * @param {any} node - The node object to create (must include id, type, and other properties)
99
+ * @returns {Transaction} This transaction instance for method chaining
100
+ * @throws {Error} If the node ID is invalid or if the node already exists
101
+ *
102
+ * @example
103
+ * ```js
104
+ * tr.create({
105
+ * id: 'para_123',
106
+ * type: 'paragraph',
107
+ * content: ['Hello world', []]
108
+ * });
109
+ * ```
110
+ */
111
+ create(node) {
112
+ // Validate node against schema
113
+ this.doc.validate_node(node);
114
+
115
+ if (this.doc.get(node.id)) {
116
+ throw new Error('Node with id ' + node.id + ' already exists');
117
+ }
118
+
119
+ const op = ['create', node];
120
+ this.ops.push(op);
121
+ this.inverse_ops.push(['delete', node.id]);
122
+ this.doc._apply_op(op);
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * Deletes a node from the document by its ID.
128
+ *
129
+ * The node's current state is captured for undo support before deletion.
130
+ *
131
+ * @param {any} id - The ID of the node to delete
132
+ * @returns {Transaction} This transaction instance for method chaining
133
+ *
134
+ * @example
135
+ * ```js
136
+ * tr.delete('node_123');
137
+ * ```
138
+ */
139
+ delete(id) {
140
+ const previous_value = $state.snapshot(this.doc.get(id));
141
+ const op = ['delete', id];
142
+ this.ops.push(op);
143
+ this.inverse_ops.push(['create', previous_value]);
144
+ this.doc._apply_op(op);
145
+ return this;
146
+ }
147
+
148
+ /**
149
+ * Sets the document selection.
150
+ *
151
+ * @param {Selection} selection - The new selection object
152
+ * @returns {Transaction} This transaction instance for method chaining
153
+ * @todo Check if selection is valid and throw error if not
154
+ */
155
+ set_selection(selection) {
156
+ // TODO: Check if selection is valid and throw error if not
157
+ this.doc.selection = selection;
158
+ return this;
159
+ }
160
+
161
+ /**
162
+ * Adds, updates, or removes text annotations in the current selection.
163
+ *
164
+ * Handles various annotation scenarios including adding new annotations,
165
+ * updating existing ones (especially for links), and removing annotations
166
+ * when conflicting types are applied.
167
+ *
168
+ * @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
169
+ * @param {any} annotation_data - Additional data for the annotation (e.g., href for links)
170
+ * @returns {Transaction} This transaction instance for method chaining
171
+ *
172
+ * @example
173
+ * ```js
174
+ * // Add a link annotation
175
+ * tr.annotate_text('link', { href: 'https://example.com' });
176
+ *
177
+ * // Add emphasis
178
+ * tr.annotate_text('emphasis', {});
179
+ * ```
180
+ */
181
+ annotate_text(annotation_type, annotation_data) {
182
+ if (this.doc.selection.type !== 'text') return this;
183
+
184
+ const { start, end } = this.doc.get_selection_range();
185
+ const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
186
+ const annotations = annotated_string[1];
187
+ const existing_annotations = this.doc.active_annotation();
188
+
189
+ // Special annotation type handling should probably be done in a separate function.
190
+ // The goal is to keep the core logic simple and allow developer to extend and pick only what they need.
191
+ // It could also be abstracted to not check for type (e.g. "link") but for a special attribute
192
+ // e.g. "zero-range-updatable" for annotations that are updatable without a range selection change.
193
+
194
+ // Special handling for links when there's no selection range
195
+ // Links should be updatable by just clicking on them without a range selection
196
+ if (annotation_type === 'link' && start === end && existing_annotations) {
197
+
198
+ // Use findIndex for deep comparison of annotation properties (comparison of annotation properties rather than object reference via indexOf)
199
+ const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
200
+ anno[0] === existing_annotations[0] &&
201
+ anno[1] === existing_annotations[1] &&
202
+ anno[2] === existing_annotations[2]
203
+ );
204
+ // const index = annotations.indexOf(existing_annotations);
205
+
206
+ if (index !== -1) {
207
+ if (annotation_data.href === '') {
208
+ // Remove the annotation if the href is empty
209
+ annotations.splice(index, 1);
210
+ } else {
211
+ annotations[index] = [
212
+ existing_annotations[0],
213
+ existing_annotations[1],
214
+ 'link',
215
+ { ...existing_annotations[3], ...annotation_data }
216
+ ];
217
+ }
218
+
219
+ this.set(this.doc.selection.path, annotated_string);
220
+ return this;
221
+ }
222
+ }
223
+
224
+ // Regular annotation handling
225
+ if (start === end) {
226
+ // For non-link annotations: You can not annotate text if the selection is collapsed.
227
+ return this;
228
+ }
229
+
230
+ if (existing_annotations) {
231
+ // If there's an existing annotation of the same type, remove it
232
+ if (existing_annotations[2] === annotation_type) {
233
+ const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
234
+ anno[0] === existing_annotations[0] &&
235
+ anno[1] === existing_annotations[1] &&
236
+ anno[2] === existing_annotations[2]
237
+ );
238
+ if (index !== -1) {
239
+ annotations.splice(index, 1);
240
+ }
241
+ } else {
242
+ // If there's an annotation of a different type, don't add a new one
243
+ return this;
244
+ }
245
+ } else {
246
+ // If there's no existing annotation, add the new one
247
+ annotations.push([start, end, annotation_type, annotation_data]);
248
+ }
249
+
250
+ // Update the annotated string
251
+ this.set(this.doc.selection.path, annotated_string);
252
+ // Not needed anymore as doc.apply makes sure selection is rerendered
253
+ // this.doc.selection = { ...this.doc.selection };
254
+ return this;
255
+ }
256
+
257
+ /**
258
+ * Deletes the currently selected text or nodes.
259
+ *
260
+ * Behavior depends on selection type:
261
+ * - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
262
+ * - For text selections: Removes selected text and adjusts annotations accordingly
263
+ * - For collapsed selections: Deletes the previous character/node
264
+ *
265
+ * @returns {Transaction} This transaction instance for method chaining
266
+ */
267
+ delete_selection() {
268
+ if (!this.doc.selection) return this;
269
+ const path = this.doc.selection.path;
270
+ // Get the start and end indices for the selection
271
+ let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
272
+ let end = Math.max(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
273
+
274
+ // If selection is collapsed we delete the previous node
275
+ if (start === end) {
276
+ if (start > 0) {
277
+ start = start - 1;
278
+ } else {
279
+ return this; // cursor is at the very beginning, do nothing.
280
+ }
281
+ }
282
+
283
+ if (this.doc.selection.type === 'node') {
284
+ const node_array = [...this.doc.get(path)];
285
+
286
+ // Get the node IDs that will be removed
287
+ const removed_node_ids = node_array.slice(start, end);
288
+
289
+ // Remove the selected nodes from the node_array
290
+ node_array.splice(start, end - start);
291
+
292
+ // Update the node_array in the entry (this implicitly records an op via this.set)
293
+ this.set(path, node_array);
294
+
295
+ // Now check which nodes should be deleted based on reference counting
296
+ this._cascade_delete_unreferenced_nodes(removed_node_ids);
297
+
298
+ // Update the selection to point to the start of the deleted range
299
+ this.doc.selection = {
300
+ type: 'node',
301
+ path,
302
+ anchor_offset: start,
303
+ focus_offset: start
304
+ };
305
+ } else if (this.doc.selection.type === 'text') {
306
+ const path = this.doc.selection.path;
307
+ let text = structuredClone($state.snapshot(this.doc.get(path)));
308
+
309
+ // Update the text content
310
+ text[0] = text[0].slice(0, start) + text[0].slice(end);
311
+
312
+ // Update annotation offsets for deletion
313
+ const deletion_length = end - start;
314
+ const new_annotations = text[1].map(/** @param {any} annotation */ (annotation) => {
315
+ const [annotation_start, annotation_end, type, annotation_data] = annotation;
316
+
317
+ // Adjust start offset
318
+ let new_start = annotation_start;
319
+ if (annotation_start >= end) {
320
+ // Start is after deletion - shift back
321
+ new_start = annotation_start - deletion_length;
322
+ } else if (annotation_start >= start) {
323
+ // Start is within deletion - move to deletion point
324
+ new_start = start;
325
+ }
326
+ // If start is before deletion, keep unchanged
327
+
328
+ // Adjust end offset
329
+ let new_end = annotation_end;
330
+ if (annotation_end >= end) {
331
+ // End is after deletion - shift back
332
+ new_end = annotation_end - deletion_length;
333
+ } else if (annotation_end > start) {
334
+ // End is within deletion - move to deletion point
335
+ new_end = start;
336
+ }
337
+ // If end is before deletion, keep unchanged
338
+
339
+ // Remove annotation if it becomes invalid (start >= end)
340
+ if (new_start >= new_end) {
341
+ return false;
342
+ }
343
+
344
+ return [new_start, new_end, type, annotation_data];
345
+ }).filter(Boolean);
346
+
347
+ text[1] = new_annotations;
348
+
349
+ // Update the text in the entry (this implicitly records an op via this.set)
350
+ this.set(path, text);
351
+ this.set_selection({
352
+ type: 'text',
353
+ path,
354
+ anchor_offset: start,
355
+ focus_offset: start
356
+ });
357
+ }
358
+ return this;
359
+ }
360
+
361
+ /**
362
+ * Inserts an array of nodes at the current node selection.
363
+ *
364
+ * If there's a current selection, it will be deleted first. New nodes are
365
+ * created if they don't already exist (e.g., for cut+paste scenarios).
366
+ *
367
+ * @param {SerializedNode[]} nodes - Array of node objects to insert
368
+ * @returns {Transaction} This transaction instance for method chaining
369
+ *
370
+ * @note Assumes we only insert new nodes, not reference existing ones
371
+ *
372
+ * @example
373
+ * ```js
374
+ * tr.insert_nodes([
375
+ * {id: 'para_1', type: 'paragraph', content: ['Hello', []]},
376
+ * {id: 'para_2', type: 'paragraph', content: ['World', []]}
377
+ * ]);
378
+ * ```
379
+ */
380
+ insert_nodes(nodes) {
381
+ if (this.doc.selection.type !== 'node') return this;
382
+
383
+ // Unless cursor is collapsed, delete the selected nodes as a first step
384
+ if (this.doc.selection.anchor_offset !== this.doc.selection.focus_offset) {
385
+ this.delete_selection();
386
+ }
387
+
388
+ const path = this.doc.selection.path;
389
+ const node_array = [...this.doc.get(path)];
390
+
391
+ // Get the start and end indices for the selection
392
+ let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
393
+ let end = Math.max(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
394
+
395
+ if (start !== end) {
396
+ // Remove the currently selected nodes from the node_array
397
+ // NOTE: We are okay that only the refernces are removed and nodes potentially become orphaned.
398
+ // They'll be purged on doc.to_json() anyways.
399
+ node_array.splice(start, end - start);
400
+ }
401
+
402
+ // First create the new nodes (unless they already exists e.g. in case of cut+paste)
403
+ nodes.forEach(node => {
404
+ if (!this.doc.get(node.id)) {
405
+ this.create(node);
406
+ }
407
+ });
408
+ const node_ids = nodes.map(node => node.id);
409
+ node_array.splice(start, 0, ...node_ids);
410
+
411
+ // Update the node_array in the entry
412
+ this.set(path, node_array);
413
+
414
+ this.doc.selection = {
415
+ type: 'node',
416
+ path: [...this.doc.selection.path],
417
+ anchor_offset: start,
418
+ focus_offset: start + nodes.length
419
+ };
420
+ return this;
421
+ }
422
+
423
+ /**
424
+ * Inserts text at the current text selection, replacing any selected content.
425
+ *
426
+ * Updates both the text content and adjusts annotation offsets to maintain
427
+ * annotation integrity after the text insertion.
428
+ *
429
+ * @param {string} replaced_text - The text to insert
430
+ * @returns {Transaction} This transaction instance for method chaining
431
+ *
432
+ * @todo Support annotations attached to replaced_text for full copy&paste support
433
+ *
434
+ * @example
435
+ * ```js
436
+ * tr.insert_text('Hello, world!');
437
+ * ```
438
+ */
439
+ insert_text(replaced_text) {
440
+ if (this.doc.selection.type !== 'text') return this;
441
+
442
+ const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
443
+ const { start, end } = this.doc.get_selection_range();
444
+
445
+ // Transform the plain text string.
446
+ annotated_string[0] = annotated_string[0].slice(0, start) + replaced_text + annotated_string[0].slice(end);
447
+
448
+ // Transform the annotations (annotated_string[1])
449
+ // NOTE: Annotations are stored as [start_offset, end_offset, type]
450
+ const delta = replaced_text.length - (end - start);
451
+ const new_annotations = annotated_string[1].map(/** @param {any} annotation */ (annotation) => {
452
+ const [annotation_start, annotation_end, type, annotation_data] = annotation;
453
+
454
+ // Case 1: annotation is wrapped in start and end (remove it)
455
+ if (start <= annotation_start && end >= annotation_end) {
456
+ return false;
457
+ }
458
+
459
+ // Case 2: text inserted before the annotation
460
+ if (end <= annotation_start) {
461
+ return [annotation_start + delta, annotation_end + delta, type, annotation_data];
462
+ }
463
+
464
+ // Case 3: text inserted after the annotation
465
+ if (start >= annotation_end) {
466
+ return annotation;
467
+ }
468
+
469
+ // Case 4: annotation is partly selected towards right
470
+ // NOTE: replaced_text will not be part of the annotation, we treat it the same as
471
+ // a cursor right after the annotation
472
+ if (start > annotation_start && start < annotation_end && end === annotation_end) {
473
+ return [annotation_start, start, type, annotation_data];
474
+ }
475
+
476
+ // Case 5: text inserted inside an annotation
477
+ // NOTE: This also covers the case when a annotation is partly selected towards left
478
+ if (start >= annotation_start && start <= annotation_end && end < annotation_end && end >= annotation_start) {
479
+ return [annotation_start, annotation_end + delta, type, annotation_data];
480
+ }
481
+
482
+ // Unhandled edge case:
483
+ console.error('annotation could not be transformed: ', annotation);
484
+ throw new Error('Case for transforming annotation not covered');
485
+
486
+ // return annotation;
487
+ }).filter(Boolean);
488
+
489
+ this.set(this.doc.selection.path, [annotated_string[0], new_annotations]); // this will update the current state and create a history entry
490
+
491
+ // Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
492
+ const new_selection = {
493
+ type: 'text',
494
+ path: this.doc.selection.path,
495
+ anchor_offset: start + replaced_text.length,
496
+ focus_offset: start + replaced_text.length,
497
+ };
498
+ this.doc.selection = new_selection;
499
+ return this;
500
+ }
501
+
502
+ /**
503
+ * Recursively deletes nodes that become unreferenced after the initial deletion.
504
+ *
505
+ * This method implements cascade deletion by checking reference counts and
506
+ * removing nodes that are no longer referenced by any other nodes in the document.
507
+ *
508
+ * @param {NodeId[]} removed_node_ids - Array of node IDs that were initially removed
509
+ * @private
510
+ */
511
+ _cascade_delete_unreferenced_nodes(removed_node_ids) {
512
+ /** @type {Record<NodeId, boolean>} */
513
+ const nodes_to_delete = {};
514
+ const to_check = [...removed_node_ids];
515
+
516
+ while (to_check.length > 0) {
517
+ const node_id = to_check.pop();
518
+
519
+ // Skip if node_id is undefined (shouldn't happen since we check length)
520
+ if (!node_id) continue;
521
+
522
+ // Skip if already marked for deletion
523
+ if (nodes_to_delete[node_id]) continue;
524
+
525
+ // Count references excluding nodes already marked for deletion
526
+ const ref_count = this._count_references_excluding_deleted(node_id, nodes_to_delete);
527
+
528
+ if (ref_count === 0) {
529
+ // No more references, safe to delete this node
530
+ nodes_to_delete[node_id] = true;
531
+
532
+ // Also check all nodes referenced by this node
533
+ const referenced_nodes = this.doc.get_referenced_nodes(node_id);
534
+ to_check.push(...referenced_nodes);
535
+ }
536
+ }
537
+
538
+ // Delete all unreferenced nodes
539
+ for (const node_id in nodes_to_delete) {
540
+ this.delete(node_id);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Counts references to a target node, excluding nodes that are marked for deletion.
546
+ *
547
+ * This is used during cascade deletion to determine if a node can be safely
548
+ * deleted without breaking document integrity.
549
+ *
550
+ * @param {NodeId} target_node_id - The node ID to count references for
551
+ * @param {Record<NodeId, boolean>} nodes_to_delete - Nodes already marked for deletion
552
+ * @returns {number} The number of references to the target node
553
+ * @private
554
+ */
555
+ _count_references_excluding_deleted(target_node_id, nodes_to_delete) {
556
+ let count = 0;
557
+
558
+ for (const node of Object.values(this.doc.nodes)) {
559
+ // Skip nodes that are marked for deletion
560
+ if (nodes_to_delete[node.id]) continue;
561
+
562
+ for (const [property, value] of Object.entries(node)) {
563
+ if (property === 'id' || property === 'type') continue;
564
+
565
+ const prop_type = this.doc.property_type(node.type, property);
566
+
567
+ if (prop_type === 'node_array' && Array.isArray(value)) {
568
+ count += value.filter(id => id === target_node_id).length;
569
+ } else if (prop_type === 'node' && value === target_node_id) {
570
+ count += 1;
571
+ }
572
+ }
573
+ }
574
+
575
+ return count;
576
+ }
577
+ }
@@ -0,0 +1,12 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+ import Node from './Node.svelte';
4
+
5
+ const svedit = getContext('svedit');
6
+ let { path } = $props();
7
+ let node = $derived(svedit.doc.get(path));
8
+ </script>
9
+
10
+ <Node {path}>
11
+ Unknown: {node.type}.
12
+ </Node>