svedit 0.11.0 → 0.12.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 (59) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -5
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +86 -30
  5. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  6. package/dist/KeyMapper.svelte.js +18 -6
  7. package/dist/Node.svelte +29 -1
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +119 -12
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeGap.svelte +48 -6
  12. package/dist/NodeGap.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.d.ts +31 -14
  14. package/dist/Session.svelte.d.ts.map +1 -1
  15. package/dist/Session.svelte.js +155 -77
  16. package/dist/Svedit.svelte +110 -89
  17. package/dist/Svedit.svelte.d.ts.map +1 -1
  18. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  19. package/dist/TextProperty.svelte.d.ts +7 -0
  20. package/dist/TextProperty.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +78 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +412 -234
  24. package/dist/doc_utils.d.ts +96 -21
  25. package/dist/doc_utils.d.ts.map +1 -1
  26. package/dist/doc_utils.js +440 -168
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/node_visibility.svelte.d.ts +30 -4
  30. package/dist/node_visibility.svelte.d.ts.map +1 -1
  31. package/dist/node_visibility.svelte.js +327 -363
  32. package/dist/paste_utils.d.ts +3 -2
  33. package/dist/paste_utils.d.ts.map +1 -1
  34. package/dist/paste_utils.js +16 -13
  35. package/dist/transforms.svelte.d.ts.map +1 -1
  36. package/dist/transforms.svelte.js +5 -6
  37. package/dist/types.d.ts +112 -42
  38. package/dist/utils.d.ts +89 -13
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +320 -85
  41. package/package.json +4 -1
  42. package/src/lib/Command.svelte.js +86 -30
  43. package/src/lib/KeyMapper.svelte.js +18 -6
  44. package/src/lib/Node.svelte +29 -1
  45. package/src/lib/NodeArrayProperty.svelte +119 -12
  46. package/src/lib/NodeGap.svelte +48 -6
  47. package/src/lib/Session.svelte.js +155 -77
  48. package/src/lib/Svedit.svelte +110 -89
  49. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  50. package/src/lib/Transaction.svelte.js +412 -234
  51. package/src/lib/doc_utils.js +440 -168
  52. package/src/lib/index.js +1 -1
  53. package/src/lib/node_visibility.svelte.js +327 -363
  54. package/src/lib/paste_utils.js +16 -13
  55. package/src/lib/transforms.svelte.js +5 -6
  56. package/src/lib/types.d.ts +112 -42
  57. package/src/lib/utils.js +320 -85
  58. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  59. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
@@ -1,13 +1,17 @@
1
1
  /**
2
- * @import { NodeId, Selection, Document, DocumentSchema, DocumentPath, Annotation } from './types.d.js'
2
+ * @import { NodeId, Selection, Document, DocumentSchema, DocumentPath, Attachment, Mark, Annotation } from './types.d.js'
3
3
  */
4
4
 
5
5
  import {
6
6
  get_char_length,
7
7
  char_slice,
8
8
  traverse,
9
+ traverse_ids,
9
10
  get_selection_range,
10
- is_selection_collapsed
11
+ is_selection_collapsed,
12
+ adjust_ranges_for_deletion,
13
+ adjust_ranges_for_insertion,
14
+ are_ranges_exclusive
11
15
  } from './utils.js';
12
16
  import { join_text_node } from './transforms.svelte.js';
13
17
  import {
@@ -15,15 +19,37 @@ import {
15
19
  property_type as doc_property_type,
16
20
  kind as doc_kind,
17
21
  inspect as doc_inspect,
18
- apply_op,
19
- count_references_excluding_deleted,
22
+ create_document_draft,
23
+ apply_op_to_draft,
24
+ build_reference_counts,
25
+ visit_node_references,
20
26
  validate_node,
27
+ is_id_valid,
21
28
  fill_node_defaults,
22
- get_active_annotation,
23
- validate_selection,
24
- can_switch_annotation_type
29
+ can_switch_mark_type,
30
+ get_selected_marks,
31
+ get_selected_annotations,
32
+ get_selected_range_types,
33
+ validate_selection
25
34
  } from './doc_utils.js';
26
35
 
36
+ /**
37
+ * Check whether a selection-relative range fits within a container length.
38
+ *
39
+ * @param {Attachment} range
40
+ * @param {number} length
41
+ * @returns {boolean}
42
+ */
43
+ function is_range_within_bounds(range, length) {
44
+ return (
45
+ Number.isInteger(range.start_offset) &&
46
+ Number.isInteger(range.end_offset) &&
47
+ range.start_offset >= 0 &&
48
+ range.start_offset < range.end_offset &&
49
+ range.end_offset <= length
50
+ );
51
+ }
52
+
27
53
  /**
28
54
  * Transaction class for managing atomic document operations with undo/redo support.
29
55
  *
@@ -50,8 +76,13 @@ export default class Transaction {
50
76
  */
51
77
  constructor(schema, doc, selection, config) {
52
78
  this.schema = schema;
79
+ // The transaction works on a draft: one shallow copy of the nodes map
80
+ // up front, then ops mutate the draft in place (copying node objects
81
+ // on write). This keeps the original document untouched and node
82
+ // identity stable for unchanged nodes, without the previous
83
+ // one-full-map-copy-per-op cost.
53
84
  /** @type {object} */
54
- this.doc = doc;
85
+ this.doc = create_document_draft(doc);
55
86
  /** @type {Selection} */
56
87
  this.selection = selection;
57
88
  this.config = config;
@@ -69,6 +100,9 @@ export default class Transaction {
69
100
  this.modified_node_ids = [];
70
101
  /** @type {NodeId[]} */
71
102
  this.deleted_node_ids = [];
103
+ // True when any op set a node's 'type' property — referrers must be
104
+ // re-validated against type constraints in that case.
105
+ this.changed_node_types = false;
72
106
  }
73
107
 
74
108
  /**
@@ -93,10 +127,10 @@ export default class Transaction {
93
127
  }
94
128
 
95
129
  /**
96
- * Determines the kind of a node ('block', 'text', or 'annotation').
130
+ * Determines the kind of a node ('block', 'text', 'mark', or 'annotation').
97
131
  *
98
132
  * @param {any} node - The node to check
99
- * @returns {'block'|'text'|'annotation'} The node kind
133
+ * @returns {'block'|'text'|'mark'|'annotation'} The node kind
100
134
  */
101
135
  kind(node) {
102
136
  return doc_kind(this.schema, node);
@@ -118,7 +152,13 @@ export default class Transaction {
118
152
  * @returns {string} A new unique ID
119
153
  */
120
154
  generate_id() {
121
- return this.config.generate_id();
155
+ const id = this.config?.generate_id ? this.config.generate_id() : `node_${crypto.randomUUID()}`;
156
+ if (!is_id_valid(id)) {
157
+ throw new Error(
158
+ `Generated node id ${JSON.stringify(id)} is invalid. Node ids must be non-empty strings that start with a letter or underscore, contain only letters, numbers, underscores, or dashes, and must not contain "__".`
159
+ );
160
+ }
161
+ return id;
122
162
  }
123
163
 
124
164
  /**
@@ -131,7 +171,6 @@ export default class Transaction {
131
171
  validate_node(node, this.schema, this.doc.nodes, { require_references: false });
132
172
  }
133
173
 
134
-
135
174
  /**
136
175
  * Gets all nodes referenced by a given node (recursively).
137
176
  *
@@ -139,8 +178,18 @@ export default class Transaction {
139
178
  * @returns {NodeId[]} Array of referenced node IDs
140
179
  */
141
180
  get_referenced_nodes(node_id) {
142
- const traversed_nodes = traverse(node_id, this.schema, this.doc.nodes);
143
- return traversed_nodes.slice(0, -1).map((node) => node.id);
181
+ return traverse_ids(node_id, this.schema, this.doc.nodes).slice(0, -1);
182
+ }
183
+
184
+ /**
185
+ * Gets the available mark types for the current selection.
186
+ *
187
+ * @returns {string[]} Array of available mark type names
188
+ */
189
+ get available_mark_types() {
190
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
191
+ const property_definition = this.inspect(this.selection.path);
192
+ return property_definition.mark_types || [];
144
193
  }
145
194
 
146
195
  /**
@@ -149,21 +198,35 @@ export default class Transaction {
149
198
  * @returns {string[]} Array of available annotation type names
150
199
  */
151
200
  get available_annotation_types() {
152
- if (this.selection?.type !== 'text') return [];
153
- const path = this.selection.path;
154
- const property_definition = this.inspect(path);
155
- return property_definition.node_types || [];
201
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
202
+ const property_definition = this.inspect(this.selection.path);
203
+ return property_definition.annotation_types || [];
156
204
  }
157
205
 
158
206
  /**
159
- * Returns the annotation object that is currently "under the caret".
160
- * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
207
+ * Returns marks touched by the current selection.
161
208
  *
162
- * @param {string} [annotation_type] Optional annotation type to filter by
163
- * @returns {Annotation|null}
209
+ * @returns {Array<Mark & { index: number, node: any }>}
164
210
  */
165
- active_annotation(annotation_type) {
166
- return get_active_annotation(this.schema, this.doc, this.selection, annotation_type);
211
+ get selected_marks() {
212
+ return get_selected_marks(this.schema, this.doc, this.selection);
213
+ }
214
+
215
+ get active_mark() {
216
+ return this.selected_marks.length === 1 ? this.selected_marks[0] : null;
217
+ }
218
+
219
+ /**
220
+ * Returns annotations touched by the current selection.
221
+ *
222
+ * @returns {Array<Annotation & { index: number, node: any }>}
223
+ */
224
+ get selected_annotations() {
225
+ return get_selected_annotations(this.schema, this.doc, this.selection);
226
+ }
227
+
228
+ get active_annotation() {
229
+ return this.selected_annotations.length === 1 ? this.selected_annotations[0] : null;
167
230
  }
168
231
 
169
232
  /**
@@ -173,7 +236,7 @@ export default class Transaction {
173
236
  * @private
174
237
  */
175
238
  _apply_op(op) {
176
- this.doc = apply_op(this.doc, op);
239
+ apply_op_to_draft(this.doc, op);
177
240
  }
178
241
 
179
242
  /**
@@ -198,7 +261,7 @@ export default class Transaction {
198
261
  * @example
199
262
  * ```js
200
263
  * tr.set(["list_1", "list_items"], [1, 2, 3]);
201
- * tr.set(["page_1", "body", "0", "description"], {text: "Hello world", annotations: []});
264
+ * tr.set(["page_1", "body", "0", "description"], {content: "Hello world", marks: [], annotations: []});
202
265
  * ```
203
266
  */
204
267
  set(path, value) {
@@ -228,13 +291,13 @@ export default class Transaction {
228
291
 
229
292
  if (prop_type === 'node' && typeof previous_value === 'string' && previous_value !== value) {
230
293
  removed_node_ids = [previous_value];
231
- } else if (
232
- prop_type === 'node_array' &&
233
- Array.isArray(previous_value) &&
234
- Array.isArray(value)
235
- ) {
294
+ } else if (prop_type === 'node_array') {
295
+ const previous_node_ids = previous_value.nodes;
296
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local lookup set.
297
+ const next_node_ids = new Set(value.nodes);
298
+
236
299
  // Only include node IDs that were in previous_value but are not in the new value
237
- removed_node_ids = previous_value.filter((id) => !value.includes(id));
300
+ removed_node_ids = previous_node_ids.filter((id) => !next_node_ids.has(id));
238
301
  }
239
302
 
240
303
  const op = ['set', normalized_path, value];
@@ -242,6 +305,7 @@ export default class Transaction {
242
305
  this.inverse_ops.push(['set', normalized_path, previous_value]);
243
306
  this._apply_op(op);
244
307
  this._track_node_id(this.modified_node_ids, node.id);
308
+ if (property_key_str === 'type') this.changed_node_types = true;
245
309
 
246
310
  // Garbage-collect nodes whose only reference was via this property.
247
311
  // We must use a ref-count-aware sweep instead of unconditional deletion
@@ -276,16 +340,25 @@ export default class Transaction {
276
340
  const prop_type = property_definition.type;
277
341
  const value = new_node[property_name];
278
342
 
279
- if (prop_type === 'node_array' && Array.isArray(value)) {
280
- new_node[property_name] = value.map((ref_id) => id_map[ref_id]);
281
- } else if (prop_type === 'node' && typeof value === 'string') {
282
- new_node[property_name] = id_map[value];
283
- } else if (prop_type === 'annotated_text' && value) {
284
- const annotations = value.annotations.map((annotation) => {
285
- const { start_offset, end_offset, node_id } = annotation;
343
+ const remap_ranges = (ranges) =>
344
+ (ranges ?? []).map(({ start_offset, end_offset, node_id }) => {
286
345
  return { start_offset, end_offset, node_id: id_map[node_id] || node_id };
287
346
  });
288
- new_node[property_name] = { text: value.text, annotations };
347
+
348
+ if (prop_type === 'node_array' && value && typeof value === 'object') {
349
+ new_node[property_name] = {
350
+ nodes: value.nodes.map((ref_id) => id_map[ref_id]),
351
+ marks: remap_ranges(value.marks),
352
+ annotations: remap_ranges(value.annotations)
353
+ };
354
+ } else if (prop_type === 'node' && typeof value === 'string') {
355
+ new_node[property_name] = id_map[value];
356
+ } else if (prop_type === 'text' && value) {
357
+ new_node[property_name] = {
358
+ content: value.content,
359
+ marks: remap_ranges(value.marks),
360
+ annotations: remap_ranges(value.annotations)
361
+ };
289
362
  }
290
363
  }
291
364
 
@@ -340,7 +413,7 @@ export default class Transaction {
340
413
  *
341
414
  * NOTE: This is a force-delete and intentionally does NOT check whether
342
415
  * the node is still referenced from elsewhere — callers (e.g.
343
- * `annotate_text`) sometimes need to remove a node while a stale
416
+ * `toggle_mark`) sometimes need to remove a node while a stale
344
417
  * reference to it still exists in the property they are about to update.
345
418
  * For ref-count-aware cleanup, see how `set` calls
346
419
  * `_cascade_delete_unreferenced_nodes`.
@@ -396,74 +469,136 @@ export default class Transaction {
396
469
  }
397
470
 
398
471
  /**
399
- * Adds, updates, or removes text annotations in the current selection.
472
+ * Toggles a mark on the current text or node selection.
400
473
  *
401
- * Handles various annotation scenarios including adding new annotations,
402
- * updating existing ones (especially for links), and removing annotations
403
- * when conflicting types are applied.
474
+ * Marks are mutually exclusive. Whole marks are toggled, removed, or
475
+ * switched; ranges are never truncated or expanded by this command.
404
476
  *
405
- * @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
406
- * @param {any} annotation_properties - Additional data for the annotation (e.g., href for links)
477
+ * @param {any} mark_type - The type of mark (e.g., 'link', 'strong', 'emphasis')
478
+ * @param {any} mark_properties - Additional data for the mark (e.g., href for links)
407
479
  * @returns {Transaction} This transaction instance for method chaining
408
480
  *
409
481
  * @example
410
482
  * ```js
411
- * // Add a link annotation
412
- * tr.annotate_text('link', { href: 'https://example.com' });
483
+ * // Add a link mark
484
+ * tr.toggle_mark('link', { href: 'https://example.com' });
413
485
  *
414
486
  * // Add emphasis
415
- * tr.annotate_text('emphasis', {});
487
+ * tr.toggle_mark('emphasis', {});
416
488
  * ```
417
489
  */
418
- annotate_text(annotation_type, annotation_properties) {
419
- if (this.selection.type !== 'text') return this;
490
+ toggle_mark(mark_type, mark_properties) {
491
+ if (this.selection.type !== 'text' && this.selection.type !== 'node') return this;
492
+ if (this.selection.type === 'node' && is_selection_collapsed(this.selection)) return this;
493
+ if (!this.available_mark_types.includes(mark_type)) {
494
+ console.warn(`Mark type ${mark_type} is not allowed here.`);
495
+ return this;
496
+ }
420
497
 
421
498
  const range = get_selection_range(this.selection);
422
- const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path)));
423
- const annotations = annotated_text.annotations;
424
- const existing_annotation = this.active_annotation();
425
- const existing_annotation_same_type = this.active_annotation(annotation_type);
426
- const has_annotation_properties =
427
- annotation_properties && Object.keys(annotation_properties).length > 0;
428
-
429
- if (existing_annotation) {
430
- // If there's an existing annotation of the same type, remove it
431
- if (existing_annotation_same_type) {
432
- const index = annotations.findIndex(
433
- /** @param {any} anno */ (anno) =>
434
- anno.start_offset === existing_annotation.start_offset &&
435
- anno.end_offset === existing_annotation.end_offset
436
- );
437
- if (index !== -1) {
438
- // Remove the annotation node from the graph
439
- this.delete(annotations[index].node_id);
440
- annotations.splice(index, 1);
441
- }
442
- } else {
443
- const existing_annotation_node = this.get(existing_annotation.node_id);
444
- const can_switch_annotation =
445
- !has_annotation_properties &&
446
- this.available_annotation_types.includes(annotation_type) &&
447
- can_switch_annotation_type(
448
- this.schema,
449
- existing_annotation_node,
450
- annotation_type
451
- );
452
-
453
- if (can_switch_annotation) {
454
- this.set([existing_annotation.node_id, 'type'], annotation_type);
455
- return this;
456
- }
499
+ if (!range) return this;
457
500
 
458
- // If there's an annotation of a different incompatible type, don't add a new one
459
- return this;
460
- }
461
- } else {
501
+ const annotated_value = structuredClone($state.snapshot(this.get(this.selection.path)));
502
+
503
+ // All marks compete for toggling, since marks are mutually exclusive.
504
+ // Annotations never block a mark toggle.
505
+ const selected_marks = this.selected_marks;
506
+ const selected_mark_types = get_selected_range_types(selected_marks);
507
+
508
+ if (selected_mark_types.size > 1) return this;
509
+
510
+ if (selected_marks.length === 0) {
462
511
  if (is_selection_collapsed(this.selection)) {
463
512
  return this;
464
513
  }
465
- if (!this.available_annotation_types.includes(annotation_type)) {
466
- console.warn(`Annotation type ${annotation_type} is not allowed here.`);
514
+ const new_mark_node = {
515
+ id: this.generate_id(),
516
+ type: mark_type,
517
+ ...mark_properties
518
+ };
519
+ this.create(new_mark_node);
520
+ // If there's no existing mark, add the new one
521
+ annotated_value.marks.push({
522
+ start_offset: range.start_offset,
523
+ end_offset: range.end_offset,
524
+ node_id: new_mark_node.id
525
+ });
526
+ this.set(this.selection.path, annotated_value);
527
+ return this;
528
+ }
529
+
530
+ const first_selected_mark = selected_marks[0];
531
+ const selected_mark_type = first_selected_mark.node.type;
532
+
533
+ if (selected_mark_type === mark_type) {
534
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
535
+ const selected_indices = new Set(selected_marks.map(({ index }) => index));
536
+ const removed_node_ids = selected_marks.map(({ node_id }) => node_id);
537
+ annotated_value.marks = annotated_value.marks.filter(
538
+ (_, index) => !selected_indices.has(index)
539
+ );
540
+ this.set(this.selection.path, annotated_value);
541
+ this._cascade_delete_unreferenced_nodes(removed_node_ids);
542
+ return this;
543
+ }
544
+
545
+ if (
546
+ selected_marks.length === 1 &&
547
+ can_switch_mark_type(this.schema, selected_mark_type, mark_type)
548
+ ) {
549
+ const selected_mark = first_selected_mark;
550
+ this.set([selected_mark.node_id, 'type'], mark_type);
551
+ this.set_selection({
552
+ type: this.selection.type,
553
+ path: this.selection.path,
554
+ anchor_offset: selected_mark.start_offset,
555
+ focus_offset: selected_mark.end_offset
556
+ });
557
+ return this;
558
+ }
559
+
560
+ return this;
561
+ }
562
+
563
+ /**
564
+ * Toggles an annotation on the current text or node selection.
565
+ *
566
+ * Annotations only compete with touched annotations of the same type:
567
+ * touched same-type annotations are removed, otherwise a new annotation is
568
+ * created. Marks and other annotation types never block a toggle, and
569
+ * same-type annotations may still overlap when created through lower-level
570
+ * APIs.
571
+ *
572
+ * @param {any} annotation_type - The type of annotation (e.g., 'comment', 'marker')
573
+ * @param {any} annotation_properties - Additional data for the annotation
574
+ * @returns {Transaction} This transaction instance for method chaining
575
+ *
576
+ * @example
577
+ * ```js
578
+ * // Add a comment annotation
579
+ * tr.toggle_annotation('comment', { text: 'Please review' });
580
+ * ```
581
+ */
582
+ toggle_annotation(annotation_type, annotation_properties) {
583
+ if (this.selection.type !== 'text' && this.selection.type !== 'node') return this;
584
+ if (this.selection.type === 'node' && is_selection_collapsed(this.selection)) return this;
585
+ if (!this.available_annotation_types.includes(annotation_type)) {
586
+ console.warn(`Annotation type ${annotation_type} is not allowed here.`);
587
+ return this;
588
+ }
589
+
590
+ const range = get_selection_range(this.selection);
591
+ if (!range) return this;
592
+
593
+ const annotated_value = structuredClone($state.snapshot(this.get(this.selection.path)));
594
+
595
+ // Only touched annotations of the same type compete
596
+ const selected_annotations = this.selected_annotations.filter(
597
+ ({ node }) => node?.type === annotation_type
598
+ );
599
+
600
+ if (selected_annotations.length === 0) {
601
+ if (is_selection_collapsed(this.selection)) {
467
602
  return this;
468
603
  }
469
604
  const new_annotation_node = {
@@ -472,16 +607,24 @@ export default class Transaction {
472
607
  ...annotation_properties
473
608
  };
474
609
  this.create(new_annotation_node);
475
- // If there's no existing annotation, add the new one
476
- annotations.push({
610
+ annotated_value.annotations.push({
477
611
  start_offset: range.start_offset,
478
612
  end_offset: range.end_offset,
479
613
  node_id: new_annotation_node.id
480
614
  });
615
+ this.set(this.selection.path, annotated_value);
616
+ return this;
481
617
  }
482
618
 
483
- // Update the annotated text
484
- this.set(this.selection.path, annotated_text);
619
+ // Remove touched same-type annotations
620
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
621
+ const selected_indices = new Set(selected_annotations.map(({ index }) => index));
622
+ const removed_node_ids = selected_annotations.map(({ node_id }) => node_id);
623
+ annotated_value.annotations = annotated_value.annotations.filter(
624
+ (_, index) => !selected_indices.has(index)
625
+ );
626
+ this.set(this.selection.path, annotated_value);
627
+ this._cascade_delete_unreferenced_nodes(removed_node_ids);
485
628
  return this;
486
629
  }
487
630
 
@@ -513,11 +656,11 @@ export default class Transaction {
513
656
  let length;
514
657
 
515
658
  if (this.selection?.type === 'text') {
516
- const text_content = this.get(this.selection.path).text;
659
+ const text_content = this.get(this.selection.path).content;
517
660
  length = get_char_length(text_content);
518
661
  } else if (this.selection?.type === 'node') {
519
662
  const node_array = this.get(this.selection.path);
520
- length = node_array.length;
663
+ length = node_array.nodes.length;
521
664
  }
522
665
 
523
666
  // If selection is collapsed we delete the previous char/node (backward)
@@ -551,14 +694,26 @@ export default class Transaction {
551
694
  }
552
695
 
553
696
  if (this.selection.type === 'node') {
554
- const node_array = [...this.get(path)];
697
+ const current_value = structuredClone($state.snapshot(this.get(path)));
698
+ const node_array = [...current_value.nodes];
699
+ const deletion_length = end - start;
555
700
 
556
701
  // Remove the selected nodes from the node_array
557
- node_array.splice(start, end - start);
702
+ node_array.splice(start, deletion_length);
703
+ const marks_result = adjust_ranges_for_deletion(current_value.marks, start, end);
704
+ const annotations_result = adjust_ranges_for_deletion(current_value.annotations, start, end);
558
705
 
559
706
  // Update the node_array in the entry (this implicitly records an op via this.set)
560
707
  // Note: this.set() will automatically cascade delete unreferenced nodes
561
- this.set(path, node_array);
708
+ this.set(path, {
709
+ nodes: node_array,
710
+ marks: marks_result.ranges,
711
+ annotations: annotations_result.ranges
712
+ });
713
+ this._cascade_delete_unreferenced_nodes([
714
+ ...marks_result.removed_node_ids,
715
+ ...annotations_result.removed_node_ids
716
+ ]);
562
717
 
563
718
  // Update the selection to point to the start of the deleted range
564
719
  this.selection = {
@@ -572,60 +727,23 @@ export default class Transaction {
572
727
  let text = structuredClone($state.snapshot(this.get(path)));
573
728
 
574
729
  // Update the text content using character-based operations
575
- const original_text = text.text;
576
- text.text =
730
+ const original_text = text.content;
731
+ text.content =
577
732
  char_slice(original_text, 0, start) +
578
733
  char_slice(original_text, end, get_char_length(original_text));
579
734
 
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;
735
+ const marks_result = adjust_ranges_for_deletion(text.marks, start, end);
736
+ const annotations_result = adjust_ranges_for_deletion(text.annotations, start, end);
737
+ text.marks = marks_result.ranges;
738
+ text.annotations = annotations_result.ranges;
624
739
 
625
740
  this.set(path, text);
626
741
 
627
- // Delete marked annotation nodes only if no other references remain.
628
- this._cascade_delete_unreferenced_nodes(_deleted_nodes);
742
+ // Delete removed mark/annotation nodes only if no other references remain.
743
+ this._cascade_delete_unreferenced_nodes([
744
+ ...marks_result.removed_node_ids,
745
+ ...annotations_result.removed_node_ids
746
+ ]);
629
747
 
630
748
  // Update the selection to the new caret position
631
749
  this.selection = {
@@ -646,9 +764,12 @@ export default class Transaction {
646
764
  * before inserting the new ones.
647
765
  *
648
766
  * @param {NodeId[]} node_ids - Array of node IDs to insert
767
+ * @param {Array<Mark>} [marks] - Selection-relative marks to restore
768
+ * @param {Array<Annotation>} [annotations] - Selection-relative annotations to restore
769
+ * @param {Record<NodeId, any>} [nodes] - Node graph for restored marks and annotations
649
770
  * @returns {Transaction} This transaction instance for method chaining
650
771
  */
651
- insert_nodes(node_ids) {
772
+ insert_nodes(node_ids, marks = [], annotations = [], nodes = {}) {
652
773
  if (this.selection.type !== 'node') return this;
653
774
 
654
775
  // Unless caret is collapsed, delete the selected nodes as a first step
@@ -657,13 +778,20 @@ export default class Transaction {
657
778
  }
658
779
 
659
780
  const path = this.selection.path;
660
- const node_array = [...this.get(path)];
781
+ const current_value = structuredClone($state.snapshot(this.get(path)));
782
+ const node_array = [...current_value.nodes];
661
783
 
662
784
  let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
663
785
 
786
+ let next_marks = adjust_ranges_for_insertion(current_value.marks, start, node_ids.length);
787
+ let next_annotations = adjust_ranges_for_insertion(
788
+ current_value.annotations,
789
+ start,
790
+ node_ids.length
791
+ );
792
+
664
793
  // Insert the new nodes
665
794
  node_array.splice(start, 0, ...node_ids);
666
- this.set(path, node_array);
667
795
 
668
796
  this.selection = {
669
797
  type: 'node',
@@ -672,74 +800,97 @@ export default class Transaction {
672
800
  focus_offset: start + node_ids.length
673
801
  };
674
802
 
803
+ const property_definition = this.inspect(path);
804
+
805
+ // Restore marks only when they stay mutually exclusive
806
+ if (!this.active_mark && marks.length > 0 && are_ranges_exclusive(marks, node_ids.length)) {
807
+ const restored_marks = marks
808
+ .map((mark) => {
809
+ const mark_node = nodes[mark.node_id];
810
+ if (!property_definition.mark_types?.includes(mark_node?.type)) return null;
811
+
812
+ const new_mark_node_id = this.build(mark.node_id, nodes);
813
+ return {
814
+ start_offset: start + mark.start_offset,
815
+ end_offset: start + mark.end_offset,
816
+ node_id: new_mark_node_id
817
+ };
818
+ })
819
+ .filter(Boolean);
820
+ const combined_marks = next_marks.concat(restored_marks);
821
+ if (are_ranges_exclusive(combined_marks)) {
822
+ next_marks = combined_marks;
823
+ } else {
824
+ for (const mark of restored_marks) {
825
+ this.delete(mark.node_id);
826
+ }
827
+ }
828
+ }
829
+
830
+ // Restore annotations; overlap is allowed
831
+ const restored_annotations = annotations
832
+ .map((annotation) => {
833
+ if (!is_range_within_bounds(annotation, node_ids.length)) return null;
834
+ const annotation_node = nodes[annotation.node_id];
835
+ if (!property_definition.annotation_types?.includes(annotation_node?.type)) return null;
836
+
837
+ const new_annotation_node_id = this.build(annotation.node_id, nodes);
838
+ return {
839
+ start_offset: start + annotation.start_offset,
840
+ end_offset: start + annotation.end_offset,
841
+ node_id: new_annotation_node_id
842
+ };
843
+ })
844
+ .filter(Boolean);
845
+ next_annotations = next_annotations.concat(restored_annotations);
846
+
847
+ this.set(path, { nodes: node_array, marks: next_marks, annotations: next_annotations });
675
848
  return this;
676
849
  }
677
850
 
678
851
  /**
679
852
  * Inserts text at the current text selection position.
680
853
  *
681
- * Handles annotation adjustments when text is inserted, including:
682
- * - Expanding annotations that contain the insertion point
683
- * - Shifting annotations that come after the insertion point
684
- * - Optionally applying new annotations to the inserted text
854
+ * Handles mark and annotation adjustments when text is inserted, including:
855
+ * - Expanding ranges that contain the insertion point
856
+ * - Shifting ranges that come after the insertion point
857
+ * - Optionally applying new marks and annotations to the inserted text
685
858
  *
686
859
  * @param {string} replaced_text - The text to insert
687
- * @param {Array} [annotations] - Optional annotations to apply to the inserted text
688
- * @param {object} [nodes] - Optional node definitions for annotation nodes
860
+ * @param {Array<Mark>} [marks] - Optional marks to apply to the inserted text
861
+ * @param {Array<Annotation>} [annotations] - Optional annotations to apply to the inserted text
862
+ * @param {object} [nodes] - Optional node definitions for mark and annotation nodes
689
863
  * @returns {Transaction} This transaction instance for method chaining
690
864
  */
691
- insert_text(replaced_text, annotations = [], nodes = {}) {
865
+ insert_text(replaced_text, marks = [], annotations = [], nodes = {}) {
692
866
  if (this.selection?.type !== 'text') return this;
693
867
 
694
868
  // Unless selection is collapsed, delete the selected content
695
- // NOTE: This makes sure wrapped annotations are disposed correctly
869
+ // NOTE: This makes sure wrapped marks and annotations are disposed correctly
696
870
  if (!is_selection_collapsed(this.selection)) {
697
871
  this.delete_selection();
698
872
  }
699
873
 
700
- const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path)));
874
+ const text_value = structuredClone($state.snapshot(this.get(this.selection.path)));
701
875
  const range = get_selection_range(this.selection);
702
876
 
703
877
  // Transform the plain text string using character-based operations
704
- const text = annotated_text.text;
705
- annotated_text.text =
706
- char_slice(text, 0, range.start_offset) + replaced_text + char_slice(text, range.end_offset);
878
+ const current_text = text_value.content;
879
+ text_value.content =
880
+ char_slice(current_text, 0, range.start_offset) +
881
+ replaced_text +
882
+ char_slice(current_text, range.end_offset);
707
883
 
708
884
  // Calculate the change in length
709
885
  const delta = get_char_length(replaced_text);
710
886
 
711
- const new_annotations = annotated_text.annotations.map((/** @type {any} */ annotation) => {
712
- const annotation_start = annotation.start_offset;
713
- const annotation_end = annotation.end_offset;
714
- const node_id = annotation.node_id;
715
-
716
- // Annotation is entirely before the insertion point
717
- if (annotation_end <= range.start_offset) {
718
- return annotation;
719
- }
720
-
721
- // Insertion point is inside the annotation (not at the start) - extend it
722
- if (annotation_start < range.start_offset && annotation_end >= range.start_offset) {
723
- return {
724
- start_offset: annotation_start,
725
- end_offset: annotation_end + delta,
726
- node_id
727
- };
728
- }
729
-
730
- // Annotation is entirely after the insertion point - shift it
731
- if (annotation_start >= range.start_offset) {
732
- return {
733
- start_offset: annotation_start + delta,
734
- end_offset: annotation_end + delta,
735
- node_id
736
- };
737
- }
738
- return annotation;
739
- });
740
-
741
- annotated_text.annotations = new_annotations;
742
- this.set(this.selection.path, annotated_text); // this will update the current state and create a history entry
887
+ text_value.marks = adjust_ranges_for_insertion(text_value.marks, range.start_offset, delta);
888
+ text_value.annotations = adjust_ranges_for_insertion(
889
+ text_value.annotations,
890
+ range.start_offset,
891
+ delta
892
+ );
893
+ this.set(this.selection.path, text_value); // this will update the current state and create a history entry
743
894
 
744
895
  // Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
745
896
  /** @type {Selection} */
@@ -751,16 +902,45 @@ export default class Transaction {
751
902
  };
752
903
  this.selection = new_selection;
753
904
 
754
- // Now we apply annotations if there are any, but only if there's no active annotation
905
+ const text_property_definition = this.inspect(this.selection.path);
906
+ const next_text = structuredClone(text_value);
907
+ let text_changed = false;
908
+
909
+ // Now we apply marks if there are any, but only if there's no active mark
755
910
  // at the current collapsed caret
756
- if (!this.active_annotation() && annotations.length > 0) {
757
- const new_annotations = annotations
911
+ if (!this.active_mark && marks.length > 0 && are_ranges_exclusive(marks, delta)) {
912
+ const restored_marks = marks
913
+ .map((mark) => {
914
+ const original_mark_node = nodes[mark.node_id];
915
+ if (text_property_definition.mark_types?.includes(original_mark_node?.type)) {
916
+ const new_mark_node_id = this.build(mark.node_id, nodes);
917
+ return {
918
+ start_offset: range.start_offset + mark.start_offset,
919
+ end_offset: range.start_offset + mark.end_offset,
920
+ node_id: new_mark_node_id
921
+ };
922
+ }
923
+ return null;
924
+ })
925
+ .filter(Boolean);
926
+ const combined_marks = text_value.marks.concat(restored_marks);
927
+ if (are_ranges_exclusive(combined_marks)) {
928
+ next_text.marks = combined_marks;
929
+ text_changed = true;
930
+ } else {
931
+ for (const mark of restored_marks) {
932
+ this.delete(mark.node_id);
933
+ }
934
+ }
935
+ }
936
+
937
+ // Apply annotations to the inserted text; overlap is allowed
938
+ if (annotations.length > 0) {
939
+ const restored_annotations = annotations
758
940
  .map((annotation) => {
941
+ if (!is_range_within_bounds(annotation, delta)) return null;
759
942
  const original_annotation_node = nodes[annotation.node_id];
760
- const text_property_definition = this.inspect(this.selection.path);
761
- // console.log('original_annotation_node.type', original_annotation_node.type);
762
- // console.log('text_property_definition', text_property_definition);
763
- if (text_property_definition.node_types.includes(original_annotation_node.type)) {
943
+ if (text_property_definition.annotation_types?.includes(original_annotation_node?.type)) {
764
944
  const new_annotation_node_id = this.build(annotation.node_id, nodes);
765
945
  return {
766
946
  start_offset: range.start_offset + annotation.start_offset,
@@ -771,9 +951,14 @@ export default class Transaction {
771
951
  return null;
772
952
  })
773
953
  .filter(Boolean);
774
- const next_annotated_text = structuredClone(annotated_text);
775
- next_annotated_text.annotations = next_annotated_text.annotations.concat(new_annotations);
776
- this.set(this.selection.path, next_annotated_text); // this will update the current state and create a history entry
954
+ if (restored_annotations.length > 0) {
955
+ next_text.annotations = text_value.annotations.concat(restored_annotations);
956
+ text_changed = true;
957
+ }
958
+ }
959
+
960
+ if (text_changed) {
961
+ this.set(this.selection.path, next_text);
777
962
  }
778
963
 
779
964
  return this;
@@ -790,6 +975,15 @@ export default class Transaction {
790
975
  * @private
791
976
  */
792
977
  _cascade_delete_unreferenced_nodes(potentially_orphaned_nodes) {
978
+ if (potentially_orphaned_nodes.length === 0) return;
979
+
980
+ // Build reference counts in ONE full-document scan, then maintain them
981
+ // incrementally: marking a node deleted decrements the counts of every
982
+ // node it references (per occurrence). This replaces the previous
983
+ // per-candidate full-document re-scan, which made deleting M nodes
984
+ // O(M·N) instead of O(N + M).
985
+ const ref_counts = build_reference_counts(this.schema, this.doc);
986
+
793
987
  /** @type {Record<NodeId, boolean>} */
794
988
  const nodes_to_delete = {};
795
989
  const to_check = [...potentially_orphaned_nodes];
@@ -798,16 +992,20 @@ export default class Transaction {
798
992
  const node_id = to_check.pop();
799
993
  if (!node_id || nodes_to_delete[node_id]) continue;
800
994
 
801
- // Count references to this node, excluding nodes already marked for deletion
802
- const ref_count = this._count_references_excluding_deleted(node_id, nodes_to_delete);
803
-
804
- if (ref_count === 0) {
995
+ if ((ref_counts.get(node_id) || 0) === 0) {
805
996
  // No more references, safe to delete this node
806
997
  nodes_to_delete[node_id] = true;
807
998
 
808
- // Also check all nodes referenced by this node
809
- const referenced_nodes = this.get_referenced_nodes(node_id);
810
- to_check.push(...referenced_nodes);
999
+ // Release this node's outgoing references and re-check the
1000
+ // nodes it pointed at — they may have become unreferenced.
1001
+ const node = this.doc.nodes[node_id];
1002
+ if (node) {
1003
+ visit_node_references(this.schema, node, (referenced_id) => {
1004
+ const count = ref_counts.get(referenced_id);
1005
+ if (count !== undefined) ref_counts.set(referenced_id, count - 1);
1006
+ to_check.push(referenced_id);
1007
+ });
1008
+ }
811
1009
  }
812
1010
  }
813
1011
 
@@ -823,24 +1021,4 @@ export default class Transaction {
823
1021
  }
824
1022
  }
825
1023
  }
826
-
827
- /**
828
- * Counts references to a node, excluding nodes that have been marked for deletion.
829
- *
830
- * This is used during cascade deletion to accurately count remaining references
831
- * as nodes are being deleted.
832
- *
833
- * @param {NodeId} target_node_id - The node ID to count references for
834
- * @param {Record<NodeId, boolean>} nodes_to_delete - Nodes already marked for deletion
835
- * @returns {number} The number of references to the target node
836
- * @private
837
- */
838
- _count_references_excluding_deleted(target_node_id, nodes_to_delete) {
839
- return count_references_excluding_deleted(
840
- this.schema,
841
- this.doc,
842
- target_node_id,
843
- nodes_to_delete
844
- );
845
- }
846
1024
  }