svedit 0.4.0 → 0.6.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 (63) hide show
  1. package/README.md +762 -120
  2. package/dist/AnnotatedTextProperty.svelte +122 -75
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +118 -0
  5. package/dist/Command.svelte.d.ts.map +1 -0
  6. package/dist/Command.svelte.js +314 -0
  7. package/dist/CustomProperty.svelte +31 -42
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  9. package/dist/KeyMapper.svelte.d.ts +36 -0
  10. package/dist/KeyMapper.svelte.d.ts.map +1 -0
  11. package/dist/KeyMapper.svelte.js +141 -0
  12. package/dist/Node.svelte +33 -35
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +40 -37
  15. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  16. package/dist/Session.svelte.d.ts +223 -0
  17. package/dist/Session.svelte.d.ts.map +1 -0
  18. package/dist/Session.svelte.js +594 -0
  19. package/dist/Svedit.svelte +1352 -1465
  20. package/dist/Svedit.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.d.ts +122 -51
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +845 -551
  24. package/dist/UnknownNode.svelte +6 -6
  25. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  26. package/dist/doc_utils.d.ts +127 -0
  27. package/dist/doc_utils.d.ts.map +1 -0
  28. package/dist/doc_utils.js +482 -0
  29. package/dist/index.d.ts +7 -3
  30. package/dist/index.js +20 -4
  31. package/dist/{commands.svelte.d.ts → transforms.svelte.d.ts} +1 -2
  32. package/dist/transforms.svelte.d.ts.map +1 -0
  33. package/dist/transforms.svelte.js +155 -0
  34. package/dist/types.d.ts +256 -110
  35. package/dist/{util.d.ts → utils.d.ts} +23 -2
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +338 -0
  38. package/package.json +25 -23
  39. package/src/lib/AnnotatedTextProperty.svelte +122 -75
  40. package/src/lib/Command.svelte.js +314 -0
  41. package/src/lib/CustomProperty.svelte +31 -42
  42. package/src/lib/KeyMapper.svelte.js +141 -0
  43. package/src/lib/Node.svelte +33 -35
  44. package/src/lib/NodeArrayProperty.svelte +40 -37
  45. package/src/lib/Session.svelte.js +594 -0
  46. package/src/lib/Svedit.svelte +1352 -1465
  47. package/src/lib/Transaction.svelte.js +845 -551
  48. package/src/lib/UnknownNode.svelte +6 -6
  49. package/src/lib/doc_utils.js +482 -0
  50. package/src/lib/index.js +20 -4
  51. package/src/lib/transforms.svelte.js +155 -0
  52. package/src/lib/types.d.ts +256 -110
  53. package/src/lib/utils.js +338 -0
  54. package/dist/Document.svelte.d.ts +0 -214
  55. package/dist/Document.svelte.d.ts.map +0 -1
  56. package/dist/Document.svelte.js +0 -796
  57. package/dist/commands.svelte.d.ts.map +0 -1
  58. package/dist/commands.svelte.js +0 -271
  59. package/dist/util.d.ts.map +0 -1
  60. package/dist/util.js +0 -235
  61. package/src/lib/Document.svelte.js +0 -796
  62. package/src/lib/commands.svelte.js +0 -271
  63. package/src/lib/util.js +0 -235
@@ -1,8 +1,25 @@
1
1
  /**
2
- * @import { NodeId, SerializedNode, Selection, DocumentPath } from './types.js'
2
+ * @import { NodeId, Selection, Document, DocumentSchema, DocumentPath, Annotation } from './types.d.js'
3
3
  */
4
4
 
5
- import { get_char_length, char_slice } from './util.js';
5
+ import {
6
+ get_char_length,
7
+ char_slice,
8
+ traverse,
9
+ get_selection_range,
10
+ is_selection_collapsed
11
+ } from './utils.js';
12
+ import { join_text_node } from './transforms.svelte.js';
13
+ import {
14
+ get as doc_get,
15
+ property_type as doc_property_type,
16
+ kind as doc_kind,
17
+ inspect as doc_inspect,
18
+ apply_op,
19
+ count_references_excluding_deleted,
20
+ validate_node,
21
+ get_active_annotation
22
+ } from './doc_utils.js';
6
23
 
7
24
  /**
8
25
  * Transaction class for managing atomic document operations with undo/redo support.
@@ -13,558 +30,835 @@ import { get_char_length, char_slice } from './util.js';
13
30
  *
14
31
  * @example
15
32
  * ```js
16
- * const tr = doc.tr;
33
+ * const tr = session.tr;
17
34
  * tr.set(['node_1', 'title'], 'New Title');
18
35
  * tr.create({id: 'node_2', type: 'paragraph', content: 'Hello'});
19
- * doc.apply(tr); // Applies all operations atomically
36
+ * session.apply(tr); // Applies all operations atomically
20
37
  * ```
21
38
  */
22
39
  export default class Transaction {
23
- /**
24
- * Creates a new Transaction for the given document.
25
- *
26
- * @param {any} doc - The document instance this transaction operates on
27
- */
28
- constructor(doc) {
29
- this.doc = doc;
30
- // Here we track the ops during the transaction
31
- /** @type {Record<string, any>[]} */
32
- this.ops = [];
33
- /** @type {Record<string, any>[]} */
34
- this.inverse_ops = [];
35
- // Remember the selection before the transaction started
36
- this.selection_before = this.doc.selection;
37
- }
38
-
39
- /**
40
- * Gets a value from the document at the specified path.
41
- *
42
- * @param {DocumentPath} path - The path to the value in the document
43
- * @returns {any} The value at the specified path
44
- */
45
- get(path) {
46
- return this.doc.get(path);
47
- }
48
-
49
- /**
50
- * Gets the current selection state of the document.
51
- *
52
- * @returns {Selection} The current selection object
53
- */
54
- get selection() {
55
- return this.doc.selection;
56
- }
57
-
58
- /**
59
- * Sets a property of a node to a new value.
60
- *
61
- * This is the core operation for modifying document properties. It records
62
- * both the forward operation and its inverse for undo support.
63
- *
64
- * @param {DocumentPath} path - Array path to the property (e.g., ["node_1", "title"])
65
- * @param {any} value - The new value to set
66
- *
67
- * @example
68
- * ```js
69
- * tr.set(["list_1", "list_items"], [1, 2, 3]);
70
- * tr.set(["page_1", "body", "0", "description"], {text: "Hello world", annotations: []});
71
- * ```
72
- */
73
- set (path, value) {
74
- const node = this.doc.get(path.slice(0, -1));
75
-
76
- // Turns ["page_1", "body", "0", "description"]
77
- // into ["paragraph_1", "description"].
78
- // Important to keep changes of multiple ops invertible.
79
- const normalized_path = [node.id, path.at(-1)];
80
-
81
- // Just to be sure, make a deep copy of the old value
82
- const property_key = path.at(-1);
83
- if (property_key === undefined) {
84
- throw new Error('Invalid path: cannot get property key');
85
- }
86
- const previous_value = structuredClone($state.snapshot(node[property_key]));
87
-
88
- const op = ['set', normalized_path, value];
89
- this.ops.push(op);
90
- this.inverse_ops.push(['set', path, previous_value]);
91
- this.doc._apply_op(op);
92
- }
93
-
94
- /**
95
- * Creates a new node in the document.
96
- *
97
- * The node must have a valid id and must not already exist in the document.
98
- * The node is validated against the document schema before creation.
99
- *
100
- * @param {any} node - The node object to create (must include id, type, and other properties)
101
- * @returns {Transaction} This transaction instance for method chaining
102
- * @throws {Error} If the node ID is invalid or if the node already exists
103
- *
104
- * @example
105
- * ```js
106
- * tr.create({
107
- * id: 'para_123',
108
- * type: 'paragraph',
109
- * content: ['Hello world', []]
110
- * });
111
- * ```
112
- */
113
- create(node) {
114
- // Validate node against schema
115
- this.doc.validate_node(node);
116
-
117
- if (this.doc.get(node.id)) {
118
- throw new Error('Node with id ' + node.id + ' already exists');
119
- }
120
-
121
- const op = ['create', node];
122
- this.ops.push(op);
123
- this.inverse_ops.push(['delete', node.id]);
124
- this.doc._apply_op(op);
125
- return this;
126
- }
127
-
128
- /**
129
- * Deletes a node from the document by its ID.
130
- *
131
- * The node's current state is captured for undo support before deletion.
132
- *
133
- * @param {any} id - The ID of the node to delete
134
- * @returns {Transaction} This transaction instance for method chaining
135
- *
136
- * @example
137
- * ```js
138
- * tr.delete('node_123');
139
- * ```
140
- */
141
- delete(id) {
142
- const previous_value = $state.snapshot(this.doc.get(id));
143
- const op = ['delete', id];
144
- this.ops.push(op);
145
- this.inverse_ops.push(['create', previous_value]);
146
- this.doc._apply_op(op);
147
- return this;
148
- }
149
-
150
- /**
151
- * Sets the document selection.
152
- *
153
- * @param {Selection} selection - The new selection object
154
- * @returns {Transaction} This transaction instance for method chaining
155
- * @throws {Error} Throws if the selection is invalid or out of bounds
156
- */
157
- set_selection(selection) {
158
- this.doc._validate_selection(selection);
159
- this.doc.selection = selection;
160
- return this;
161
- }
162
-
163
- /**
164
- * Adds, updates, or removes text annotations in the current selection.
165
- *
166
- * Handles various annotation scenarios including adding new annotations,
167
- * updating existing ones (especially for links), and removing annotations
168
- * when conflicting types are applied.
169
- *
170
- * @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
171
- * @param {any} annotation_properties - Additional data for the annotation (e.g., href for links)
172
- * @returns {Transaction} This transaction instance for method chaining
173
- *
174
- * @example
175
- * ```js
176
- * // Add a link annotation
177
- * tr.annotate_text('link', { href: 'https://example.com' });
178
- *
179
- * // Add emphasis
180
- * tr.annotate_text('emphasis', {});
181
- * ```
182
- */
183
- annotate_text(annotation_type, annotation_properties) {
184
- if (this.doc.selection.type !== 'text') return this;
185
-
186
- const { start, end } = this.doc.get_selection_range();
187
- const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
188
- const annotations = annotated_text.annotations;
189
- const existing_annotation = this.doc.active_annotation();
190
- const existing_annotation_same_type = this.doc.active_annotation(annotation_type);
191
-
192
- if (existing_annotation) {
193
- // If there's an existing annotation of the same type, remove it
194
- if (existing_annotation_same_type) {
195
- const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
196
- anno.start_offset === existing_annotation.start_offset &&
197
- anno.end_offset === existing_annotation.end_offset
198
- );
199
- if (index !== -1) {
200
- // Remove the annotation node from the graph
201
- this.delete(annotations[index].node_id);
202
- annotations.splice(index, 1);
203
- }
204
- } else {
205
- // If there's an annotation of a different type, don't add a new one
206
- return this;
207
- }
208
- } else {
209
- if (!this.doc.available_annotation_types.includes(annotation_type)) {
210
- console.log(`Annotation type ${annotation_type} is not allowed here.`);
211
- return this;
212
- }
213
-
214
- const new_annotation_node = {
215
- id: this.doc.generate_id(),
216
- type: annotation_type,
217
- ...annotation_properties,
218
- }
219
- this.create(new_annotation_node);
220
- // If there's no existing annotation, add the new one
221
- annotations.push({start_offset: start, end_offset: end, node_id: new_annotation_node.id});
222
- }
223
-
224
- // Update the annotated text
225
- this.set(this.doc.selection.path, annotated_text);
226
- return this;
227
- }
228
-
229
- /**
230
- * Deletes the currently selected text or nodes.
231
- *
232
- * Behavior depends on selection type:
233
- * - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
234
- * - For text selections: Removes selected text and adjusts annotations accordingly
235
- * - For collapsed selections: Deletes the previous character/node
236
- *
237
- * @returns {Transaction} This transaction instance for method chaining
238
- */
239
- delete_selection() {
240
- if (!this.doc.selection) return this;
241
- const path = this.doc.selection.path;
242
- // Get the start and end indices for the selection
243
- let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
244
- let end = Math.max(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
245
-
246
- // If selection is collapsed we delete the previous node
247
- if (start === end) {
248
- if (start > 0) {
249
- start = start - 1;
250
- } else {
251
- return this; // cursor is at the very beginning, do nothing.
252
- }
253
- }
254
-
255
- if (this.doc.selection.type === 'node') {
256
- const node_array = [...this.doc.get(path)];
257
-
258
- // Get the node IDs that will be removed
259
- const removed_node_ids = node_array.slice(start, end);
260
-
261
- // Remove the selected nodes from the node_array
262
- node_array.splice(start, end - start);
263
-
264
- // Update the node_array in the entry (this implicitly records an op via this.set)
265
- this.set(path, node_array);
266
-
267
- // Now check which nodes should be deleted based on reference counting
268
- this._cascade_delete_unreferenced_nodes(removed_node_ids);
269
-
270
- // Update the selection to point to the start of the deleted range
271
- this.doc.selection = {
272
- type: 'node',
273
- path,
274
- anchor_offset: start,
275
- focus_offset: start
276
- };
277
- } else if (this.doc.selection.type === 'text') {
278
- const path = this.doc.selection.path;
279
- let text = structuredClone($state.snapshot(this.doc.get(path)));
280
-
281
- // Update the text content using character-based operations
282
- const original_text = text.text;
283
- text.text = char_slice(original_text, 0, start) + char_slice(original_text, end, get_char_length(original_text));
284
-
285
- // To mark annotation nodes for deletion.
286
- const _deleted_nodes = [];
287
- // Update annotation offsets for deletion
288
- const deletion_length = end - start;
289
- const new_annotations = text.annotations.map(/** @param {any} annotation */ (annotation) => {
290
- const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
291
-
292
- // Adjust start offset
293
- let new_start = annotation_start;
294
- if (annotation_start >= end) {
295
- // Start is after deletion - shift back
296
- new_start = annotation_start - deletion_length;
297
- } else if (annotation_start >= start) {
298
- // Start is within deletion - move to deletion point
299
- new_start = start;
300
- }
301
- // If start is before deletion, keep unchanged
302
-
303
- // Adjust end offset
304
- let new_end = annotation_end;
305
- if (annotation_end >= end) {
306
- // End is after deletion - shift back
307
- new_end = annotation_end - deletion_length;
308
- } else if (annotation_end > start) {
309
- // End is within deletion - move to deletion point
310
- new_end = start;
311
- }
312
- // If end is before deletion, keep unchanged
313
-
314
- // Remove annotation if it becomes invalid (start >= end)
315
- if (new_start >= new_end) {
316
- console.log('deleting annotation');
317
- _deleted_nodes.push(node_id);
318
- return false;
319
- }
320
-
321
- return {start_offset: new_start, end_offset: new_end, node_id};
322
- }).filter(Boolean);
323
-
324
- text.annotations = new_annotations;
325
-
326
- // Update the text in the entry (this implicitly records an op via this.set)
327
- this.set(path, text);
328
-
329
- // Remove annotation nodes that were implicitly deleted.
330
- for (const node_id of _deleted_nodes) {
331
- this.delete(node_id);
332
- }
333
-
334
- this.set_selection({
335
- type: 'text',
336
- path,
337
- anchor_offset: start,
338
- focus_offset: start
339
- });
340
- }
341
- return this;
342
- }
343
-
344
- /**
345
- * Inserts an array of nodes at the current node selection.
346
- *
347
- * If there's a current selection, it will be deleted first. New nodes are
348
- * created if they don't already exist (e.g., for cut+paste scenarios).
349
- *
350
- * @param {SerializedNode[]} nodes - Array of node objects to insert
351
- * @returns {Transaction} This transaction instance for method chaining
352
- *
353
- * @note Assumes we only insert new nodes, not reference existing ones
354
- *
355
- * @example
356
- * ```js
357
- * tr.insert_nodes([
358
- * {id: 'para_1', type: 'paragraph', content: ['Hello', []]},
359
- * {id: 'para_2', type: 'paragraph', content: ['World', []]}
360
- * ]);
361
- * ```
362
- */
363
- insert_nodes(nodes) {
364
- if (this.doc.selection.type !== 'node') return this;
365
-
366
- // Unless cursor is collapsed, delete the selected nodes as a first step
367
- if (this.doc.selection.anchor_offset !== this.doc.selection.focus_offset) {
368
- this.delete_selection();
369
- }
370
-
371
- const path = this.doc.selection.path;
372
- const node_array = [...this.doc.get(path)];
373
-
374
- // Get the start and end indices for the selection
375
- let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
376
- let end = Math.max(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
377
-
378
- if (start !== end) {
379
- // Remove the currently selected nodes from the node_array
380
- // NOTE: We are okay that only the refernces are removed and nodes potentially become orphaned.
381
- // They'll be purged on doc.to_json() anyways.
382
- node_array.splice(start, end - start);
383
- }
384
-
385
- // First create the new nodes (unless they already exists e.g. in case of cut+paste)
386
- nodes.forEach(node => {
387
- if (!this.doc.get(node.id)) {
388
- this.create(node);
389
- }
390
- });
391
- const node_ids = nodes.map(node => node.id);
392
- node_array.splice(start, 0, ...node_ids);
393
-
394
- // Update the node_array in the entry
395
- this.set(path, node_array);
396
-
397
- this.doc.selection = {
398
- type: 'node',
399
- path: [...this.doc.selection.path],
400
- anchor_offset: start,
401
- focus_offset: start + nodes.length
402
- };
403
- return this;
404
- }
405
-
406
- /**
407
- * Inserts text at the current text selection, replacing any selected content.
408
- *
409
- * Updates both the text content and adjusts annotation offsets to maintain
410
- * annotation integrity after the text insertion.
411
- *
412
- * @param {string} replaced_text - The text to insert
413
- * @returns {Transaction} This transaction instance for method chaining
414
- *
415
- * @todo Support annotations attached to replaced_text for full copy&paste support
416
- *
417
- * @example
418
- * ```js
419
- * tr.insert_text('Hello, world!');
420
- * ```
421
- */
422
- insert_text(replaced_text) {
423
- if (this.doc.selection.type !== 'text') return this;
424
-
425
- const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
426
- const { start, end } = this.doc.get_selection_range();
427
-
428
- // Transform the plain text string using character-based operations
429
- const text = annotated_text.text;
430
- annotated_text.text = char_slice(text, 0, start) + replaced_text + char_slice(text, end, get_char_length(text));
431
-
432
- // Transform the annotations (annotated_text.annotations)
433
- // NOTE: Annotations are stored as {start_offset, end_offset, node_id}
434
- const delta = get_char_length(replaced_text) - (end - start);
435
- const new_annotations = annotated_text.annotations.map(/** @param {any} annotation */ (annotation) => {
436
- const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
437
-
438
- // Case 1: annotation is wrapped in start and end (remove it)
439
- if (start <= annotation_start && end >= annotation_end) {
440
- return false;
441
- }
442
-
443
- // Case 2: text inserted before the annotation
444
- if (end <= annotation_start) {
445
- return {start_offset: annotation_start + delta, end_offset: annotation_end + delta, node_id};
446
- }
447
-
448
- // Case 3: text inserted after the annotation
449
- if (start >= annotation_end) {
450
- return annotation;
451
- }
452
-
453
- // Case 4: annotation is partly selected towards right
454
- // NOTE: replaced_text will not be part of the annotation, we treat it the same as
455
- // a cursor right after the annotation
456
- if (start > annotation_start && start < annotation_end && end >= annotation_end) {
457
- return {start_offset: annotation_start, end_offset: start, node_id};
458
- }
459
-
460
- // Case 5: text inserted inside an annotation
461
- if (start >= annotation_start && start <= annotation_end && end < annotation_end && end >= annotation_start) {
462
- return {start_offset: annotation_start, end_offset: annotation_end + delta, node_id};
463
- }
464
-
465
- // Case 6: annotation is partly selected towards left
466
- // NOTE: replaced_text will not be part of the annotation, we treat it the same as
467
- // a cursor right before the annotation
468
- if (start < annotation_start && end > annotation_start && end < annotation_end) {
469
- return {start_offset: start + get_char_length(replaced_text), end_offset: annotation_end + delta, node_id};
470
- }
471
-
472
- // Unhandled edge case:
473
- console.error('annotation could not be transformed: ', annotation);
474
- throw new Error('Case for transforming annotation not covered');
475
-
476
- // return annotation;
477
- }).filter(Boolean);
478
-
479
- annotated_text.annotations = new_annotations;
480
- this.set(this.doc.selection.path, annotated_text); // this will update the current state and create a history entry
481
-
482
- // Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
483
- const new_selection = {
484
- type: 'text',
485
- path: this.doc.selection.path,
486
- anchor_offset: start + get_char_length(replaced_text),
487
- focus_offset: start + get_char_length(replaced_text),
488
- };
489
- this.doc.selection = new_selection;
490
- return this;
491
- }
492
-
493
- /**
494
- * Recursively deletes nodes that become unreferenced after the initial deletion.
495
- *
496
- * This method implements cascade deletion by checking reference counts and
497
- * removing nodes that are no longer referenced by any other nodes in the document.
498
- *
499
- * @param {NodeId[]} removed_node_ids - Array of node IDs that were initially removed
500
- * @private
501
- */
502
- _cascade_delete_unreferenced_nodes(removed_node_ids) {
503
- /** @type {Record<NodeId, boolean>} */
504
- const nodes_to_delete = {};
505
- const to_check = [...removed_node_ids];
506
-
507
- while (to_check.length > 0) {
508
- const node_id = to_check.pop();
509
-
510
- // Skip if node_id is undefined (shouldn't happen since we check length)
511
- if (!node_id) continue;
512
-
513
- // Skip if already marked for deletion
514
- if (nodes_to_delete[node_id]) continue;
515
-
516
- // Count references excluding nodes already marked for deletion
517
- const ref_count = this._count_references_excluding_deleted(node_id, nodes_to_delete);
518
-
519
- if (ref_count === 0) {
520
- // No more references, safe to delete this node
521
- nodes_to_delete[node_id] = true;
522
-
523
- // Also check all nodes referenced by this node
524
- const referenced_nodes = this.doc.get_referenced_nodes(node_id);
525
- to_check.push(...referenced_nodes);
526
- }
527
- }
528
-
529
- // Delete all unreferenced nodes
530
- for (const node_id in nodes_to_delete) {
531
- this.delete(node_id);
532
- }
533
- }
534
-
535
- /**
536
- * Counts references to a target node, excluding nodes that are marked for deletion.
537
- *
538
- * This is used during cascade deletion to determine if a node can be safely
539
- * deleted without breaking document integrity.
540
- *
541
- * @param {NodeId} target_node_id - The node ID to count references for
542
- * @param {Record<NodeId, boolean>} nodes_to_delete - Nodes already marked for deletion
543
- * @returns {number} The number of references to the target node
544
- * @private
545
- */
546
- _count_references_excluding_deleted(target_node_id, nodes_to_delete) {
547
- let count = 0;
548
-
549
- for (const node of Object.values(this.doc.nodes)) {
550
- // Skip nodes that are marked for deletion
551
- if (nodes_to_delete[node.id]) continue;
552
-
553
- for (const [property, value] of Object.entries(node)) {
554
- if (property === 'id' || property === 'type') continue;
555
-
556
- const prop_type = this.doc.property_type(node.type, property);
557
-
558
- if (prop_type === 'node_array' && Array.isArray(value)) {
559
- count += value.filter(id => id === target_node_id).length;
560
- } else if (prop_type === 'node' && value === target_node_id) {
561
- count += 1;
562
- } else if (prop_type === 'annotated_text' && value && value.annotations) {
563
- count += value.annotations.filter(annotation => annotation.node_id === target_node_id).length;
564
- }
565
- }
566
- }
567
-
568
- return count;
569
- }
40
+ /**
41
+ * Creates a new Transaction with the given state.
42
+ *
43
+ * @param {DocumentSchema} schema - The document schema
44
+ * @param {Document} doc - The document state {document_id, nodes}
45
+ * @param {Selection | null} selection - The current selection
46
+ * @param {object} config - The document config (including generate_id)
47
+ */
48
+ constructor(schema, doc, selection, config) {
49
+ this.schema = schema;
50
+ /** @type {object} */
51
+ this.doc = doc;
52
+ /** @type {Selection} */
53
+ this.selection = selection;
54
+ this.config = config;
55
+ // Here we track the ops during the transaction
56
+ /** @type {Record<string, any>[]} */
57
+ this.ops = [];
58
+ /** @type {Record<string, any>[]} */
59
+ this.inverse_ops = [];
60
+ // Remember the selection before the transaction started
61
+ this.selection_before = selection;
62
+ }
63
+
64
+ /**
65
+ * Gets a value from the document at the specified path.
66
+ *
67
+ * @param {DocumentPath|string} path - The path to the value in the document, or a string node ID
68
+ * @returns {any} The value at the specified path
69
+ */
70
+ get(path) {
71
+ return doc_get(this.schema, this.doc, path);
72
+ }
73
+
74
+ /**
75
+ * Gets the type of a property from the schema.
76
+ *
77
+ * @param {string} type - The node type
78
+ * @param {string} property - The property name
79
+ * @returns {string} The property type
80
+ */
81
+ property_type(type, property) {
82
+ return doc_property_type(this.schema, type, property);
83
+ }
84
+
85
+ /**
86
+ * Determines the kind of a node ('block', 'text', or 'annotation').
87
+ *
88
+ * @param {any} node - The node to check
89
+ * @returns {'block'|'text'|'annotation'} The node kind
90
+ */
91
+ kind(node) {
92
+ return doc_kind(this.schema, node);
93
+ }
94
+
95
+ /**
96
+ * Inspects a path to get metadata about the value at that location.
97
+ *
98
+ * @param {DocumentPath} path - The path to inspect
99
+ * @returns {{kind: 'property'|'node', [key: string]: any}} Metadata about the path
100
+ */
101
+ inspect(path) {
102
+ return doc_inspect(this.schema, this.doc, path);
103
+ }
104
+
105
+ /**
106
+ * Generates a new unique ID using the config's generate_id function.
107
+ *
108
+ * @returns {string} A new unique ID
109
+ */
110
+ generate_id() {
111
+ return this.config.generate_id();
112
+ }
113
+
114
+ /**
115
+ * Validates a node against the document schema.
116
+ *
117
+ * @param {any} node - The node to validate
118
+ * @throws {Error} Throws if the node is invalid
119
+ */
120
+ validate_node(node) {
121
+ validate_node(node, this.schema, this.doc.nodes);
122
+ }
123
+
124
+ /**
125
+ * Gets all nodes referenced by a given node (recursively).
126
+ *
127
+ * @param {NodeId} node_id - The node ID to get references for
128
+ * @returns {NodeId[]} Array of referenced node IDs
129
+ */
130
+ get_referenced_nodes(node_id) {
131
+ const traversed_nodes = traverse(node_id, this.schema, this.doc.nodes);
132
+ return traversed_nodes.slice(0, -1).map((node) => node.id);
133
+ }
134
+
135
+ /**
136
+ * Gets the available annotation types for the current selection.
137
+ *
138
+ * @returns {string[]} Array of available annotation type names
139
+ */
140
+ get available_annotation_types() {
141
+ if (this.selection?.type !== 'text') return [];
142
+ const path = this.selection.path;
143
+ const property_definition = this.inspect(path);
144
+ return property_definition.node_types || [];
145
+ }
146
+
147
+ /**
148
+ * Returns the annotation object that is currently "under the cursor".
149
+ * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
150
+ *
151
+ * @param {string} [annotation_type] Optional annotation type to filter by
152
+ * @returns {Annotation|null}
153
+ */
154
+ active_annotation(annotation_type) {
155
+ return get_active_annotation(this.schema, this.doc, this.selection, annotation_type);
156
+ }
157
+
158
+ /**
159
+ * Applies an operation to the document (internal).
160
+ *
161
+ * @param {Array} op - The operation to apply
162
+ * @private
163
+ */
164
+ _apply_op(op) {
165
+ this.doc = apply_op(this.doc, op);
166
+ }
167
+
168
+ /**
169
+ * Sets a property of a node to a new value.
170
+ *
171
+ * This is the core operation for modifying document properties. It records
172
+ * both the forward operation and its inverse for undo support.
173
+ *
174
+ * @param {DocumentPath} path - Array path to the property (e.g., ["node_1", "title"])
175
+ * @param {any} value - The new value to set
176
+ * @returns {Transaction} This transaction instance for method chaining
177
+ *
178
+ * @example
179
+ * ```js
180
+ * tr.set(["list_1", "list_items"], [1, 2, 3]);
181
+ * tr.set(["page_1", "body", "0", "description"], {text: "Hello world", annotations: []});
182
+ * ```
183
+ */
184
+ set(path, value) {
185
+ const node = this.get(path.slice(0, -1));
186
+
187
+ // Turns ["page_1", "body", "0", "description"]
188
+ // into ["paragraph_1", "description"].
189
+ // Important to keep changes of multiple ops invertible.
190
+ const normalized_path = [node.id, path.at(-1)];
191
+
192
+ // Just to be sure, make a deep copy of the old value
193
+ const property_key = path.at(-1);
194
+ if (property_key === undefined) {
195
+ throw new Error('Invalid path: cannot get property key');
196
+ }
197
+ const property_key_str = String(property_key);
198
+ const previous_value = structuredClone($state.snapshot(node[property_key_str]));
199
+
200
+ // Collect node IDs that might need to be deleted after the set operation
201
+ const prop_type = this.property_type(node.type, property_key_str);
202
+ /** @type {NodeId[]} */
203
+ let removed_node_ids = [];
204
+
205
+ if (prop_type === 'node' && typeof previous_value === 'string' && previous_value !== value) {
206
+ removed_node_ids = [previous_value];
207
+ } else if (
208
+ prop_type === 'node_array' &&
209
+ Array.isArray(previous_value) &&
210
+ Array.isArray(value)
211
+ ) {
212
+ // Only include node IDs that were in previous_value but are not in the new value
213
+ removed_node_ids = previous_value.filter((id) => !value.includes(id));
214
+ }
215
+
216
+ const op = ['set', normalized_path, value];
217
+ this.ops.push(op);
218
+ this.inverse_ops.push(['set', normalized_path, previous_value]);
219
+ this._apply_op(op);
220
+
221
+ for (const removed_node_id of removed_node_ids) {
222
+ // NOTE: This implicitly deletes childnodes as well, given that they are no longer referenced.
223
+ this.delete(removed_node_id);
224
+ }
225
+
226
+ return this;
227
+ }
228
+
229
+ // Takes a subgraph and constructs new nodes from it
230
+ // NOTE: all ids will be mapped to new unique ids.
231
+ // NOTE: Omitted properties will be populated with default values.
232
+ build(node_id, nodes) {
233
+ const depth_first_nodes = traverse(node_id, this.schema, nodes);
234
+ // This maps original ids to newly generated ids
235
+ const id_map = {};
236
+
237
+ for (const node of depth_first_nodes) {
238
+ const new_id = this.generate_id();
239
+ id_map[node.id] = new_id;
240
+ const new_node = { ...node, id: new_id };
241
+ const node_schema = this.schema[node.type];
242
+
243
+ // Update all property references to use new IDs
244
+ for (const [property_name, property_definition] of Object.entries(node_schema.properties)) {
245
+ const prop_type = property_definition.type;
246
+ const value = node[property_name];
247
+
248
+ // Apply default values
249
+ if (prop_type === 'node_array') {
250
+ // [] is the default value for node arrays
251
+ new_node[property_name] = Array.isArray(value)
252
+ ? value.map((ref_id) => id_map[ref_id])
253
+ : [];
254
+ } else if (prop_type === 'node') {
255
+ // null is the default value for node references
256
+ new_node[property_name] = typeof value === 'string' ? id_map[value] : null;
257
+ } else if (prop_type === 'annotated_text') {
258
+ if (value) {
259
+ const annotations = value.annotations.map((annotation) => {
260
+ const { start_offset, end_offset, node_id } = annotation;
261
+ return { start_offset, end_offset, node_id: id_map[node_id] || node_id };
262
+ });
263
+ new_node[property_name] = { text: value.text, annotations };
264
+ } else {
265
+ new_node[property_name] = { text: '', annotations: [] };
266
+ }
267
+ } else if (prop_type === 'string') {
268
+ new_node[property_name] = value ?? property_definition.default ?? '';
269
+ } else if (prop_type === 'integer') {
270
+ new_node[property_name] = value ?? property_definition.default ?? 0;
271
+ } else if (prop_type === 'number') {
272
+ new_node[property_name] = value ?? property_definition.default ?? 0;
273
+ } else if (prop_type === 'boolean') {
274
+ new_node[property_name] = value ?? property_definition.default ?? false;
275
+ } else if (['integer_array', 'number_array'].includes(prop_type)) {
276
+ new_node[property_name] = value ?? property_definition.default ?? [];
277
+ } else if (prop_type === 'string_array') {
278
+ new_node[property_name] = value ?? property_definition.default ?? [];
279
+ }
280
+ }
281
+
282
+ this.create(new_node);
283
+ }
284
+
285
+ return id_map[depth_first_nodes.at(-1).id];
286
+ }
287
+
288
+ /**
289
+ * Creates a new node in the document.
290
+ *
291
+ * The node must have a valid id and must not already exist in the document.
292
+ * The node is validated against the document schema before creation.
293
+ *
294
+ * @param {any} node - The node object to create (must include id, type, and other properties)
295
+ * @returns {Transaction} This transaction instance for method chaining
296
+ * @throws {Error} If the node ID is invalid or if the node already exists
297
+ *
298
+ * @example
299
+ * ```js
300
+ * tr.create({
301
+ * id: 'para_123',
302
+ * type: 'paragraph',
303
+ * content: ['Hello world', []]
304
+ * });
305
+ * ```
306
+ */
307
+ create(node) {
308
+ // Validate node against schema
309
+ this.validate_node(node);
310
+
311
+ if (this.get(node.id)) {
312
+ throw new Error('Node with id ' + node.id + ' already exists');
313
+ }
314
+
315
+ const op = ['create', node];
316
+ this.ops.push(op);
317
+ this.inverse_ops.push(['delete', node.id]);
318
+ this._apply_op(op);
319
+ return this;
320
+ }
321
+
322
+ /**
323
+ * Deletes a node from the document by its ID.
324
+ *
325
+ * The node's current state is captured for undo support before deletion.
326
+ *
327
+ * @param {any} id - The ID of the node to delete
328
+ * @returns {Transaction} This transaction instance for method chaining
329
+ *
330
+ * @example
331
+ * ```js
332
+ * tr.delete('node_123');
333
+ * ```
334
+ */
335
+ delete(id) {
336
+ const previous_value = this.get(id);
337
+ if (!previous_value) {
338
+ console.warn(`Deletion of node ${id} skipped, as it does not exist.`);
339
+ }
340
+ // Get nodes referenced by this node BEFORE deleting it.
341
+ const referenced_nodes = this.get_referenced_nodes(id);
342
+ const op = ['delete', id];
343
+ this.ops.push(op);
344
+ this.inverse_ops.push(['create', previous_value]);
345
+ this._apply_op(op);
346
+ // Check if the nodes that were referenced by the deleted node are now orphaned
347
+ // NOTE: We don't do this yet, because we still have some manual child node removal code
348
+ // that causes errors. But we should soon implement this only here.
349
+ this._cascade_delete_unreferenced_nodes(referenced_nodes);
350
+ return this;
351
+ }
352
+
353
+ /**
354
+ * Sets the document selection.
355
+ *
356
+ * @param {Selection} selection - The new selection object
357
+ * @returns {Transaction} This transaction instance for method chaining
358
+ * @throws {Error} Throws if the selection is invalid or out of bounds
359
+ */
360
+ set_selection(selection) {
361
+ this._validate_selection(selection);
362
+ this.selection = selection;
363
+ return this;
364
+ }
365
+
366
+ /**
367
+ * Validates a selection against the current document state.
368
+ *
369
+ * @param {Selection} selection - The selection to validate
370
+ * @throws {Error} Throws if the selection is invalid
371
+ * @private
372
+ */
373
+ _validate_selection(selection) {
374
+ if (!selection) return;
375
+
376
+ const selection_type = selection.type;
377
+ if (!['node', 'text', 'property'].includes(selection_type)) {
378
+ throw new Error(`Invalid selection type: ${selection_type}`);
379
+ }
380
+
381
+ if (selection_type === 'node') {
382
+ const node_array = this.get(selection.path);
383
+
384
+ if (!Array.isArray(node_array)) {
385
+ throw new Error('Node selection path must point to a node_array');
386
+ }
387
+
388
+ const max_offset = node_array.length;
389
+ if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
390
+ throw new Error(
391
+ `Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`
392
+ );
393
+ }
394
+ if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
395
+ throw new Error(
396
+ `Node selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${max_offset}.`
397
+ );
398
+ }
399
+ } else if (selection_type === 'text') {
400
+ const annotated_text = this.get(selection.path);
401
+
402
+ if (!annotated_text || typeof annotated_text.text !== 'string') {
403
+ throw new Error('Text selection path must point to annotated_text');
404
+ }
405
+
406
+ const char_length = get_char_length(annotated_text.text);
407
+ if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
408
+ throw new Error(
409
+ `Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`
410
+ );
411
+ }
412
+ if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
413
+ throw new Error(
414
+ `Text selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${char_length}.`
415
+ );
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Adds, updates, or removes text annotations in the current selection.
422
+ *
423
+ * Handles various annotation scenarios including adding new annotations,
424
+ * updating existing ones (especially for links), and removing annotations
425
+ * when conflicting types are applied.
426
+ *
427
+ * @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
428
+ * @param {any} annotation_properties - Additional data for the annotation (e.g., href for links)
429
+ * @returns {Transaction} This transaction instance for method chaining
430
+ *
431
+ * @example
432
+ * ```js
433
+ * // Add a link annotation
434
+ * tr.annotate_text('link', { href: 'https://example.com' });
435
+ *
436
+ * // Add emphasis
437
+ * tr.annotate_text('emphasis', {});
438
+ * ```
439
+ */
440
+ annotate_text(annotation_type, annotation_properties) {
441
+ if (this.selection.type !== 'text') return this;
442
+
443
+ const range = get_selection_range(this.selection);
444
+ const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path)));
445
+ const annotations = annotated_text.annotations;
446
+ const existing_annotation = this.active_annotation();
447
+ const existing_annotation_same_type = this.active_annotation(annotation_type);
448
+
449
+ if (existing_annotation) {
450
+ // If there's an existing annotation of the same type, remove it
451
+ if (existing_annotation_same_type) {
452
+ const index = annotations.findIndex(
453
+ /** @param {any} anno */ (anno) =>
454
+ anno.start_offset === existing_annotation.start_offset &&
455
+ anno.end_offset === existing_annotation.end_offset
456
+ );
457
+ if (index !== -1) {
458
+ // Remove the annotation node from the graph
459
+ this.delete(annotations[index].node_id);
460
+ annotations.splice(index, 1);
461
+ }
462
+ } else {
463
+ // If there's an annotation of a different type, don't add a new one
464
+ return this;
465
+ }
466
+ } else {
467
+ if (is_selection_collapsed(this.selection)) {
468
+ console.log('Annotations can only be added to expanded text selections.');
469
+ return this;
470
+ }
471
+ if (!this.available_annotation_types.includes(annotation_type)) {
472
+ console.log(`Annotation type ${annotation_type} is not allowed here.`);
473
+ return this;
474
+ }
475
+ const new_annotation_node = {
476
+ id: this.generate_id(),
477
+ type: annotation_type,
478
+ ...annotation_properties
479
+ };
480
+ this.create(new_annotation_node);
481
+ // If there's no existing annotation, add the new one
482
+ annotations.push({
483
+ start_offset: range.start_offset,
484
+ end_offset: range.end_offset,
485
+ node_id: new_annotation_node.id
486
+ });
487
+ }
488
+
489
+ // Update the annotated text
490
+ this.set(this.selection.path, annotated_text);
491
+ return this;
492
+ }
493
+
494
+ /**
495
+ * Deletes the currently selected text or nodes.
496
+ *
497
+ * Behavior depends on selection type:
498
+ * - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
499
+ * - For text selections: Removes selected text and adjusts annotations accordingly
500
+ * - For collapsed selections: Deletes the previous character/node (backward) or next character/node (forward)
501
+ * - Property selections are ignored: Those are best handled handled via commands + keyboard shortcuts.
502
+ *
503
+ * @param {'backward' | 'forward'} [direction] - Direction of deletion for collapsed selections
504
+ * @returns {Transaction} This transaction instance for method chaining
505
+ */
506
+ delete_selection(direction = 'backward') {
507
+ if (!this.selection || this.selection.type === 'property') return this;
508
+ const path = this.selection.path;
509
+
510
+ // Get the start and end indices for the selection
511
+ let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
512
+ let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
513
+ let length;
514
+
515
+ if (this.selection?.type === 'text') {
516
+ const text_content = this.get(this.selection.path).text;
517
+ length = get_char_length(text_content);
518
+ } else if (this.selection?.type === 'node') {
519
+ const node_array = this.get(this.selection.path);
520
+ length = node_array.length;
521
+ }
522
+
523
+ // If selection is collapsed we delete the previous char/node (backward)
524
+ // or the next char/node (forward)
525
+ if (start === end) {
526
+ if (direction === 'backward' && start > 0) {
527
+ start = start - 1;
528
+ } else if (direction === 'forward' && end < length) {
529
+ end = end + 1;
530
+ } else if (direction === 'backward' && start === 0) {
531
+ join_text_node(this);
532
+ return this;
533
+ } else if (direction === 'forward' && end === length) {
534
+ // At end of text - try to join with next text node
535
+ const node_index = parseInt(String(this.selection.path.at(-2)), 10);
536
+ const successor_node = this.get([...this.selection.path.slice(0, -2), node_index + 1]);
537
+ // Check if next node is a text node
538
+ if (successor_node && this.kind(successor_node) === 'text') {
539
+ // Set selection to beginning of next text node
540
+ this.set_selection({
541
+ type: 'text',
542
+ path: [...this.selection.path.slice(0, -2), node_index + 1, 'content'],
543
+ anchor_offset: 0,
544
+ focus_offset: 0
545
+ });
546
+ // Use join_text_node to merge with previous node
547
+ join_text_node(this);
548
+ }
549
+ return this;
550
+ }
551
+ }
552
+
553
+ if (this.selection.type === 'node') {
554
+ const node_array = [...this.get(path)];
555
+
556
+ // Remove the selected nodes from the node_array
557
+ node_array.splice(start, end - start);
558
+
559
+ // Update the node_array in the entry (this implicitly records an op via this.set)
560
+ // Note: this.set() will automatically cascade delete unreferenced nodes
561
+ this.set(path, node_array);
562
+
563
+ // Update the selection to point to the start of the deleted range
564
+ this.selection = {
565
+ type: 'node',
566
+ path,
567
+ anchor_offset: start,
568
+ focus_offset: start
569
+ };
570
+ } else if (this.selection.type === 'text') {
571
+ const path = this.selection.path;
572
+ let text = structuredClone($state.snapshot(this.get(path)));
573
+
574
+ // Update the text content using character-based operations
575
+ const original_text = text.text;
576
+ text.text =
577
+ char_slice(original_text, 0, start) +
578
+ char_slice(original_text, end, get_char_length(original_text));
579
+
580
+ // To mark annotation nodes for deletion.
581
+ /** @type {string[]} */
582
+ const _deleted_nodes = [];
583
+ const deletion_length = end - start;
584
+ const new_annotations = text.annotations
585
+ .map((/** @type {any} */ annotation) => {
586
+ const annotation_start = annotation.start_offset;
587
+ const annotation_end = annotation.end_offset;
588
+ const node_id = annotation.node_id;
589
+
590
+ // Case 1: Annotation is entirely before the deleted range - keep unchanged
591
+ if (annotation_end <= start) {
592
+ return annotation;
593
+ }
594
+
595
+ // Case 2: Annotation is entirely after the deleted range - shift it
596
+ let new_start = annotation_start;
597
+ if (annotation_start >= end) {
598
+ new_start = annotation_start - deletion_length;
599
+ } else if (annotation_start > start) {
600
+ // Annotation starts inside deleted range
601
+ new_start = start;
602
+ }
603
+
604
+ // Case 3: Annotation overlaps with deleted range - adjust end
605
+ let new_end = annotation_end;
606
+ if (annotation_end >= end) {
607
+ new_end = annotation_end - deletion_length;
608
+ } else if (annotation_end > start) {
609
+ // Annotation ends inside deleted range
610
+ new_end = start;
611
+ }
612
+
613
+ // If annotation is now empty, mark for deletion
614
+ if (new_start >= new_end) {
615
+ _deleted_nodes.push(node_id);
616
+ return null;
617
+ }
618
+
619
+ return { start_offset: new_start, end_offset: new_end, node_id };
620
+ })
621
+ .filter(Boolean);
622
+
623
+ text.annotations = new_annotations;
624
+
625
+ // Delete marked annotation nodes
626
+ for (const node_id of _deleted_nodes) {
627
+ this.delete(node_id);
628
+ }
629
+
630
+ this.set(path, text);
631
+
632
+ // Update the selection to the new caret position
633
+ this.selection = {
634
+ type: 'text',
635
+ path,
636
+ anchor_offset: start,
637
+ focus_offset: start
638
+ };
639
+ }
640
+
641
+ return this;
642
+ }
643
+
644
+ /**
645
+ * Inserts nodes at the current node selection position.
646
+ *
647
+ * If the selection is expanded (not collapsed), first deletes the selected nodes
648
+ * before inserting the new ones.
649
+ *
650
+ * @param {NodeId[]} node_ids - Array of node IDs to insert
651
+ * @returns {Transaction} This transaction instance for method chaining
652
+ */
653
+ insert_nodes(node_ids) {
654
+ if (this.selection.type !== 'node') return this;
655
+
656
+ // Unless cursor is collapsed, delete the selected nodes as a first step
657
+ if (this.selection.anchor_offset !== this.selection.focus_offset) {
658
+ this.delete_selection();
659
+ }
660
+
661
+ const path = this.selection.path;
662
+ const node_array = [...this.get(path)];
663
+
664
+ // Get the start and end indices for the selection
665
+ let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
666
+ let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
667
+
668
+ if (start !== end) {
669
+ // Remove the selected nodes from the node_array
670
+ node_array.splice(start, end - start);
671
+ }
672
+
673
+ // Insert the new nodes
674
+ node_array.splice(start, 0, ...node_ids);
675
+ this.set(path, node_array);
676
+
677
+ this.selection = {
678
+ type: 'node',
679
+ path: [...this.selection.path],
680
+ anchor_offset: start,
681
+ focus_offset: start + node_ids.length
682
+ };
683
+
684
+ return this;
685
+ }
686
+
687
+ /**
688
+ * Inserts text at the current text selection position.
689
+ *
690
+ * Handles annotation adjustments when text is inserted, including:
691
+ * - Expanding annotations that contain the insertion point
692
+ * - Shifting annotations that come after the insertion point
693
+ * - Optionally applying new annotations to the inserted text
694
+ *
695
+ * @param {string} replaced_text - The text to insert
696
+ * @param {Array} [annotations] - Optional annotations to apply to the inserted text
697
+ * @param {object} [nodes] - Optional node definitions for annotation nodes
698
+ * @returns {Transaction} This transaction instance for method chaining
699
+ */
700
+ insert_text(replaced_text, annotations = [], nodes = {}) {
701
+ if (this.selection?.type !== 'text') return this;
702
+
703
+ // Unless selection is collapsed, delete the selected content
704
+ // NOTE: This makes sure wrapped annotations are disposed correctly
705
+ if (!is_selection_collapsed(this.selection)) {
706
+ this.delete_selection();
707
+ }
708
+
709
+ const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path)));
710
+ const range = get_selection_range(this.selection);
711
+
712
+ // Transform the plain text string using character-based operations
713
+ const text = annotated_text.text;
714
+ annotated_text.text =
715
+ char_slice(text, 0, range.start_offset) + replaced_text + char_slice(text, range.end_offset);
716
+
717
+ // Calculate the change in length
718
+ const delta = get_char_length(replaced_text);
719
+
720
+ const new_annotations = annotated_text.annotations.map((/** @type {any} */ annotation) => {
721
+ const annotation_start = annotation.start_offset;
722
+ const annotation_end = annotation.end_offset;
723
+ const node_id = annotation.node_id;
724
+
725
+ // Annotation is entirely before the insertion point
726
+ if (annotation_end <= range.start_offset) {
727
+ return annotation;
728
+ }
729
+
730
+ // Annotation starts at the insertion point - extend it
731
+ if (annotation_start <= range.start_offset && annotation_end >= range.start_offset) {
732
+ return {
733
+ start_offset: annotation_start,
734
+ end_offset: annotation_end + delta,
735
+ node_id
736
+ };
737
+ }
738
+
739
+ // Annotation is entirely after the insertion point - shift it
740
+ if (annotation_start >= range.start_offset) {
741
+ return {
742
+ start_offset: annotation_start + delta,
743
+ end_offset: annotation_end + delta,
744
+ node_id
745
+ };
746
+ }
747
+
748
+ // Insertion point is inside the annotation - extend the annotation
749
+ if (annotation_start < range.start_offset && annotation_end > range.start_offset) {
750
+ return {
751
+ start_offset: annotation_start,
752
+ end_offset: annotation_end + delta,
753
+ node_id
754
+ };
755
+ }
756
+
757
+ return annotation;
758
+ });
759
+
760
+ annotated_text.annotations = new_annotations;
761
+ this.set(this.selection.path, annotated_text); // this will update the current state and create a history entry
762
+
763
+ // Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
764
+ /** @type {Selection} */
765
+ const new_selection = {
766
+ type: /** @type {const} */ ('text'),
767
+ path: this.selection.path,
768
+ anchor_offset: range.start_offset + get_char_length(replaced_text),
769
+ focus_offset: range.start_offset + get_char_length(replaced_text)
770
+ };
771
+ this.selection = new_selection;
772
+
773
+ // Now we apply annotations if there are any, but only if there's no active annotation
774
+ // at the current collapsed cursor
775
+ if (!this.active_annotation() && annotations.length > 0) {
776
+ const new_annotations = annotations
777
+ .map((annotation) => {
778
+ const original_annotation_node = nodes[annotation.node_id];
779
+ const text_property_definition = this.inspect(this.selection.path);
780
+ // console.log('original_annotation_node.type', original_annotation_node.type);
781
+ // console.log('text_property_definition', text_property_definition);
782
+ if (text_property_definition.node_types.includes(original_annotation_node.type)) {
783
+ const new_annotation_node_id = this.build(annotation.node_id, nodes);
784
+ return {
785
+ start_offset: range.start_offset + annotation.start_offset,
786
+ end_offset: range.start_offset + annotation.end_offset,
787
+ node_id: new_annotation_node_id
788
+ };
789
+ }
790
+ return null;
791
+ })
792
+ .filter(Boolean);
793
+ const next_annotated_text = structuredClone(annotated_text);
794
+ next_annotated_text.annotations = next_annotated_text.annotations.concat(new_annotations);
795
+ this.set(this.selection.path, next_annotated_text); // this will update the current state and create a history entry
796
+ }
797
+
798
+ return this;
799
+ }
800
+
801
+ /**
802
+ * Recursively deletes nodes that are no longer referenced in the document.
803
+ *
804
+ * This handles the cascade deletion of child nodes when their parent
805
+ * references are removed. Uses reference counting to determine which
806
+ * nodes are safe to delete.
807
+ *
808
+ * @param {NodeId[]} potentially_orphaned_nodes - Array of node IDs to check
809
+ * @private
810
+ */
811
+ _cascade_delete_unreferenced_nodes(potentially_orphaned_nodes) {
812
+ /** @type {Record<NodeId, boolean>} */
813
+ const nodes_to_delete = {};
814
+ const to_check = [...potentially_orphaned_nodes];
815
+
816
+ while (to_check.length > 0) {
817
+ const node_id = to_check.pop();
818
+ if (!node_id || nodes_to_delete[node_id]) continue;
819
+
820
+ // Count references to this node, excluding nodes already marked for deletion
821
+ const ref_count = this._count_references_excluding_deleted(node_id, nodes_to_delete);
822
+
823
+ if (ref_count === 0) {
824
+ // No more references, safe to delete this node
825
+ nodes_to_delete[node_id] = true;
826
+
827
+ // Also check all nodes referenced by this node
828
+ const referenced_nodes = this.get_referenced_nodes(node_id);
829
+ to_check.push(...referenced_nodes);
830
+ }
831
+ }
832
+
833
+ // Now perform the actual deletions
834
+ for (const node_id of Object.keys(nodes_to_delete)) {
835
+ const previous_value = this.get([node_id]);
836
+ if (previous_value) {
837
+ const op = ['delete', node_id];
838
+ this.ops.push(op);
839
+ this.inverse_ops.push(['create', previous_value]);
840
+ this._apply_op(op);
841
+ }
842
+ }
843
+ }
844
+
845
+ /**
846
+ * Counts references to a node, excluding nodes that have been marked for deletion.
847
+ *
848
+ * This is used during cascade deletion to accurately count remaining references
849
+ * as nodes are being deleted.
850
+ *
851
+ * @param {NodeId} target_node_id - The node ID to count references for
852
+ * @param {Record<NodeId, boolean>} nodes_to_delete - Nodes already marked for deletion
853
+ * @returns {number} The number of references to the target node
854
+ * @private
855
+ */
856
+ _count_references_excluding_deleted(target_node_id, nodes_to_delete) {
857
+ return count_references_excluding_deleted(
858
+ this.schema,
859
+ this.doc,
860
+ target_node_id,
861
+ nodes_to_delete
862
+ );
863
+ }
570
864
  }