svedit 0.11.1 → 0.13.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 (93) hide show
  1. package/README.md +260 -82
  2. package/dist/Command.svelte.d.ts +54 -29
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +286 -283
  5. package/dist/CustomProperty.svelte +3 -5
  6. package/dist/CustomProperty.svelte.d.ts +3 -6
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/KeyMapper.svelte.d.ts +19 -6
  9. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  10. package/dist/KeyMapper.svelte.js +95 -112
  11. package/dist/Node.svelte +40 -9
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +142 -18
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +1 -1
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +60 -12
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +19 -10
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +20 -6
  27. package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
  28. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  29. package/dist/Session.svelte.d.ts +85 -116
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +543 -571
  32. package/dist/Svedit.svelte +243 -216
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  36. package/dist/TextProperty.svelte.d.ts +5 -0
  37. package/dist/TextProperty.svelte.d.ts.map +1 -0
  38. package/dist/Transaction.svelte.d.ts +101 -126
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +843 -827
  41. package/dist/UnknownNode.svelte +4 -3
  42. package/dist/UnknownNode.svelte.d.ts +5 -9
  43. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  44. package/dist/doc_utils.d.ts +109 -122
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +726 -698
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -18
  50. package/dist/node_visibility.svelte.d.ts +132 -5
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +535 -642
  53. package/dist/paste_utils.d.ts +11 -43
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -149
  56. package/dist/transforms.svelte.d.ts +9 -7
  57. package/dist/transforms.svelte.d.ts.map +1 -1
  58. package/dist/transforms.svelte.js +126 -152
  59. package/dist/types.d.ts +329 -291
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +79 -95
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +392 -305
  65. package/package.json +69 -67
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
  67. package/src/lib/CustomProperty.svelte +3 -5
  68. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  69. package/src/lib/Node.svelte +40 -9
  70. package/src/lib/NodeArrayProperty.svelte +142 -18
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +60 -12
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/Session.svelte.ts +656 -0
  76. package/src/lib/Svedit.svelte +243 -216
  77. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  78. package/src/lib/Transaction.svelte.ts +1020 -0
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/doc_utils.ts +1120 -0
  81. package/src/lib/{index.js → index.ts} +48 -1
  82. package/src/lib/node_visibility.svelte.ts +658 -0
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
  85. package/src/lib/{types.d.ts → types.ts} +250 -103
  86. package/src/lib/utils.ts +660 -0
  87. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  88. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
  89. package/src/lib/Session.svelte.js +0 -575
  90. package/src/lib/Transaction.svelte.js +0 -846
  91. package/src/lib/doc_utils.js +0 -807
  92. package/src/lib/node_visibility.svelte.js +0 -718
  93. package/src/lib/utils.js +0 -465
@@ -0,0 +1,1020 @@
1
+ import {
2
+ get_char_length,
3
+ char_slice,
4
+ traverse,
5
+ traverse_ids,
6
+ get_selection_range,
7
+ is_selection_collapsed,
8
+ adjust_ranges_for_deletion,
9
+ adjust_ranges_for_insertion,
10
+ are_ranges_exclusive
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
+ create_document_draft,
19
+ apply_op_to_draft,
20
+ build_reference_counts,
21
+ visit_node_references,
22
+ validate_node,
23
+ is_id_valid,
24
+ fill_node_defaults,
25
+ can_switch_mark_type,
26
+ get_selected_marks,
27
+ get_selected_annotations,
28
+ get_selected_range_types,
29
+ validate_selection
30
+ } from './doc_utils.js';
31
+ import type { SelectedAttachment } from './doc_utils.js';
32
+ import type {
33
+ NodeId,
34
+ NodeKind,
35
+ Selection,
36
+ Document,
37
+ DocumentSchema,
38
+ DocumentNode,
39
+ DocumentPath,
40
+ Attachment,
41
+ Mark,
42
+ Annotation,
43
+ DocumentOperation,
44
+ DynamicRecord,
45
+ Inspection,
46
+ SessionConfig
47
+ } from './types.js';
48
+
49
+ /**
50
+ * Check whether a selection-relative range fits within a container length.
51
+ */
52
+ function is_range_within_bounds(range: Attachment, length: number): boolean {
53
+ return (
54
+ Number.isInteger(range.start_offset) &&
55
+ Number.isInteger(range.end_offset) &&
56
+ range.start_offset >= 0 &&
57
+ range.start_offset < range.end_offset &&
58
+ range.end_offset <= length
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Transaction class for managing atomic document operations with undo/redo support.
64
+ *
65
+ * A Transaction provides a way to group multiple document operations (create, delete, set)
66
+ * into a single atomic unit that can be applied or rolled back as one. It maintains
67
+ * both forward operations and their inverse operations for undo functionality.
68
+ *
69
+ * @example
70
+ * ```js
71
+ * const tr = session.tr;
72
+ * tr.set(['node_1', 'title'], 'New Title');
73
+ * tr.create({id: 'node_2', type: 'paragraph', content: 'Hello'});
74
+ * session.apply(tr); // Applies all operations atomically
75
+ * ```
76
+ */
77
+ export default class Transaction {
78
+ schema: DocumentSchema;
79
+ doc: Document;
80
+ // NOTE: Typed non-null for historical reasons — most methods assume a
81
+ // selection is present. A null selection is cast here and would surface at
82
+ // runtime exactly as before the TypeScript conversion.
83
+ selection: Selection;
84
+ config: SessionConfig;
85
+ ops: DocumentOperation[];
86
+ inverse_ops: DocumentOperation[];
87
+ selection_before: Selection | null;
88
+ created_node_ids: NodeId[];
89
+ modified_node_ids: NodeId[];
90
+ deleted_node_ids: NodeId[];
91
+ changed_node_types: boolean;
92
+
93
+ /**
94
+ * Creates a new Transaction with the given state.
95
+ *
96
+ * @param schema - The document schema
97
+ * @param doc - The document state {document_id, nodes}
98
+ * @param selection - The current selection
99
+ * @param config - The document config (including generate_id)
100
+ */
101
+ constructor(
102
+ schema: DocumentSchema,
103
+ doc: Document,
104
+ selection: Selection | null,
105
+ config: SessionConfig
106
+ ) {
107
+ this.schema = schema;
108
+ // The transaction works on a draft: one shallow copy of the nodes map
109
+ // up front, then ops mutate the draft in place (copying node objects
110
+ // on write). This keeps the original document untouched and node
111
+ // identity stable for unchanged nodes, without the previous
112
+ // one-full-map-copy-per-op cost.
113
+ this.doc = create_document_draft(doc);
114
+ this.selection = selection as Selection;
115
+ this.config = config;
116
+ // Here we track the ops during the transaction
117
+ this.ops = [];
118
+ this.inverse_ops = [];
119
+ // Remember the selection before the transaction started
120
+ this.selection_before = selection;
121
+
122
+ this.created_node_ids = [];
123
+ this.modified_node_ids = [];
124
+ this.deleted_node_ids = [];
125
+ // True when any op set a node's 'type' property — referrers must be
126
+ // re-validated against type constraints in that case.
127
+ this.changed_node_types = false;
128
+ }
129
+
130
+ /**
131
+ * Gets a value from the document at the specified path.
132
+ *
133
+ * @param path - The path to the value in the document, or a string node ID
134
+ * @returns The value at the specified path
135
+ */
136
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
+ get(path: DocumentPath | string): any {
138
+ return doc_get(this.schema, this.doc, path);
139
+ }
140
+
141
+ /**
142
+ * Gets the type of a property from the schema.
143
+ */
144
+ property_type(type: string, property: string): string {
145
+ return doc_property_type(this.schema, type, property);
146
+ }
147
+
148
+ /**
149
+ * Determines the kind of a node ('document', 'block', 'text', 'mark', or 'annotation').
150
+ */
151
+ kind(node: DocumentNode): NodeKind {
152
+ return doc_kind(this.schema, node);
153
+ }
154
+
155
+ /**
156
+ * Inspects a path to get metadata about the value at that location.
157
+ */
158
+ inspect(path: DocumentPath): Inspection {
159
+ return doc_inspect(this.schema, this.doc, path);
160
+ }
161
+
162
+ /**
163
+ * Generates a new unique ID using the config's generate_id function.
164
+ */
165
+ generate_id(): string {
166
+ const id = this.config?.generate_id ? this.config.generate_id() : `node_${crypto.randomUUID()}`;
167
+ if (!is_id_valid(id)) {
168
+ throw new Error(
169
+ `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 "__".`
170
+ );
171
+ }
172
+ return id;
173
+ }
174
+
175
+ /**
176
+ * Validates a node against the document schema.
177
+ *
178
+ * @throws {Error} Throws if the node is invalid
179
+ */
180
+ validate_node(node: DocumentNode): void {
181
+ validate_node(node, this.schema, this.doc.nodes, { require_references: false });
182
+ }
183
+
184
+ /**
185
+ * Gets all nodes referenced by a given node (recursively).
186
+ */
187
+ get_referenced_nodes(node_id: NodeId): NodeId[] {
188
+ return traverse_ids(node_id, this.schema, this.doc.nodes).slice(0, -1);
189
+ }
190
+
191
+ /**
192
+ * Gets the available mark types for the current selection.
193
+ */
194
+ get available_mark_types(): string[] {
195
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
196
+ const property_definition = this.inspect(this.selection.path);
197
+ return property_definition.mark_types || [];
198
+ }
199
+
200
+ /**
201
+ * Gets the available annotation types for the current selection.
202
+ */
203
+ get available_annotation_types(): string[] {
204
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
205
+ const property_definition = this.inspect(this.selection.path);
206
+ return property_definition.annotation_types || [];
207
+ }
208
+
209
+ /**
210
+ * Returns marks touched by the current selection.
211
+ */
212
+ get selected_marks(): SelectedAttachment[] {
213
+ return get_selected_marks(this.schema, this.doc, this.selection);
214
+ }
215
+
216
+ get active_mark(): SelectedAttachment | null {
217
+ return this.selected_marks.length === 1 ? this.selected_marks[0] : null;
218
+ }
219
+
220
+ /**
221
+ * Returns annotations touched by the current selection.
222
+ */
223
+ get selected_annotations(): SelectedAttachment[] {
224
+ return get_selected_annotations(this.schema, this.doc, this.selection);
225
+ }
226
+
227
+ get active_annotation(): SelectedAttachment | null {
228
+ return this.selected_annotations.length === 1 ? this.selected_annotations[0] : null;
229
+ }
230
+
231
+ /**
232
+ * Applies an operation to the document (internal).
233
+ */
234
+ private _apply_op(op: DocumentOperation): void {
235
+ apply_op_to_draft(this.doc, op);
236
+ }
237
+
238
+ private _track_node_id(node_ids: NodeId[], node_id: NodeId): void {
239
+ if (!node_ids.includes(node_id)) node_ids.push(node_id);
240
+ }
241
+
242
+ /**
243
+ * Sets a property of a node to a new value.
244
+ *
245
+ * This is the core operation for modifying document properties. It records
246
+ * both the forward operation and its inverse for undo support.
247
+ *
248
+ * @param path - Array path to the property (e.g., ["node_1", "title"])
249
+ * @param value - The new value to set
250
+ * @returns This transaction instance for method chaining
251
+ *
252
+ * @example
253
+ * ```js
254
+ * tr.set(["list_1", "list_items"], [1, 2, 3]);
255
+ * tr.set(["page_1", "body", "0", "description"], {content: "Hello world", marks: [], annotations: []});
256
+ * ```
257
+ */
258
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
259
+ set(path: DocumentPath, value: any): this {
260
+ const path_info = this.inspect(path);
261
+ if (path_info?.kind !== 'property') {
262
+ throw new Error(
263
+ `Transaction.set requires a path that points to a property, got ${JSON.stringify(path)}`
264
+ );
265
+ }
266
+
267
+ const node = this.get(path.slice(0, -1));
268
+
269
+ // Turns ["page_1", "body", "0", "description"]
270
+ // into ["paragraph_1", "description"].
271
+ // Important to keep changes of multiple ops invertible.
272
+ const normalized_path = [node.id, path.at(-1)];
273
+
274
+ // Just to be sure, make a deep copy of the old value
275
+ const property_key = path.at(-1);
276
+ const property_key_str = String(property_key);
277
+ const previous_value = structuredClone($state.snapshot(node[property_key_str]));
278
+
279
+ // Collect node IDs that might need to be deleted after the set operation
280
+ const prop_type = this.property_type(node.type, property_key_str);
281
+ let removed_node_ids: NodeId[] = [];
282
+
283
+ if (prop_type === 'node' && typeof previous_value === 'string' && previous_value !== value) {
284
+ removed_node_ids = [previous_value];
285
+ } else if (prop_type === 'node_array') {
286
+ const previous_node_ids = previous_value.nodes;
287
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local lookup set.
288
+ const next_node_ids = new Set(value.nodes);
289
+
290
+ // Only include node IDs that were in previous_value but are not in the new value
291
+ removed_node_ids = previous_node_ids.filter((id: NodeId) => !next_node_ids.has(id));
292
+ }
293
+
294
+ const op: DocumentOperation = ['set', normalized_path, value];
295
+ this.ops.push(op);
296
+ this.inverse_ops.push(['set', normalized_path, previous_value]);
297
+ this._apply_op(op);
298
+ this._track_node_id(this.modified_node_ids, node.id);
299
+ if (property_key_str === 'type') this.changed_node_types = true;
300
+
301
+ // Garbage-collect nodes whose only reference was via this property.
302
+ // We must use a ref-count-aware sweep instead of unconditional deletion
303
+ // because a node ID removed from this array may still be referenced
304
+ // elsewhere — either from a pre-existing reference (e.g. shared nodes)
305
+ // or from a node created earlier in the same transaction (the
306
+ // wrap-in-group pattern: create a wrapper that points at the children,
307
+ // then set the parent array to drop them).
308
+ if (removed_node_ids.length > 0) {
309
+ this._cascade_delete_unreferenced_nodes(removed_node_ids);
310
+ }
311
+
312
+ return this;
313
+ }
314
+
315
+ // Takes a subgraph and constructs new nodes from it
316
+ // NOTE: all ids will be mapped to new unique ids.
317
+ // NOTE: Omitted properties will be populated with default values.
318
+ build(node_id: NodeId, nodes: Record<string, DocumentNode>): NodeId {
319
+ const depth_first_nodes = traverse(node_id, this.schema, nodes);
320
+ // This maps original ids to newly generated ids
321
+ const id_map: Record<string, string> = {};
322
+
323
+ for (const node of depth_first_nodes) {
324
+ const new_id = this.generate_id();
325
+ id_map[node.id] = new_id;
326
+ let new_node: DocumentNode = { ...node, id: new_id };
327
+ const node_schema = this.schema[node.type];
328
+
329
+ // Update all property references to use new IDs
330
+ for (const [property_name, property_definition] of Object.entries(node_schema.properties)) {
331
+ const prop_type = property_definition.type;
332
+ const value = new_node[property_name];
333
+
334
+ const remap_ranges = (ranges: Attachment[] | undefined) =>
335
+ (ranges ?? []).map(({ start_offset, end_offset, node_id }) => {
336
+ return { start_offset, end_offset, node_id: id_map[node_id] || node_id };
337
+ });
338
+
339
+ if (prop_type === 'node_array' && value && typeof value === 'object') {
340
+ new_node[property_name] = {
341
+ nodes: value.nodes.map((ref_id: NodeId) => id_map[ref_id]),
342
+ marks: remap_ranges(value.marks),
343
+ annotations: remap_ranges(value.annotations)
344
+ };
345
+ } else if (prop_type === 'node' && typeof value === 'string') {
346
+ new_node[property_name] = id_map[value];
347
+ } else if (prop_type === 'text' && value) {
348
+ new_node[property_name] = {
349
+ content: value.content,
350
+ marks: remap_ranges(value.marks),
351
+ annotations: remap_ranges(value.annotations)
352
+ };
353
+ }
354
+ }
355
+
356
+ new_node = fill_node_defaults(new_node, this.schema);
357
+ this.create(new_node);
358
+ }
359
+
360
+ return id_map[depth_first_nodes.at(-1)!.id];
361
+ }
362
+
363
+ /**
364
+ * Creates a new node in the document.
365
+ *
366
+ * The node must have a valid id and must not already exist in the document.
367
+ * Omitted properties with schema defaults are filled before validation.
368
+ *
369
+ * @param node - The node object to create (must include id, type, and required properties)
370
+ * @returns This transaction instance for method chaining
371
+ * @throws {Error} If the node ID is invalid or if the node already exists
372
+ *
373
+ * @example
374
+ * ```js
375
+ * tr.create({
376
+ * id: 'para_123',
377
+ * type: 'paragraph',
378
+ * content: ['Hello world', []]
379
+ * });
380
+ * ```
381
+ */
382
+ create(node: DocumentNode): this {
383
+ const node_with_defaults = fill_node_defaults(node, this.schema);
384
+
385
+ // Validate node against schema
386
+ this.validate_node(node_with_defaults);
387
+
388
+ if (this.get(node_with_defaults.id)) {
389
+ throw new Error('Node with id ' + node_with_defaults.id + ' already exists');
390
+ }
391
+
392
+ const op: DocumentOperation = ['create', node_with_defaults];
393
+ this.ops.push(op);
394
+ this.inverse_ops.push(['delete', node_with_defaults.id]);
395
+ this._apply_op(op);
396
+ this._track_node_id(this.created_node_ids, node_with_defaults.id);
397
+ return this;
398
+ }
399
+
400
+ /**
401
+ * Deletes a node from the document by its ID.
402
+ *
403
+ * The node's current state is captured for undo support before deletion.
404
+ *
405
+ * NOTE: This is a force-delete and intentionally does NOT check whether
406
+ * the node is still referenced from elsewhere — callers (e.g.
407
+ * `toggle_mark`) sometimes need to remove a node while a stale
408
+ * reference to it still exists in the property they are about to update.
409
+ * For ref-count-aware cleanup, see how `set` calls
410
+ * `_cascade_delete_unreferenced_nodes`.
411
+ *
412
+ * @param id - The ID of the node to delete
413
+ * @returns This transaction instance for method chaining
414
+ *
415
+ * @example
416
+ * ```js
417
+ * tr.delete('node_123');
418
+ * ```
419
+ */
420
+ delete(id: NodeId): this {
421
+ const previous_value = this.get(id);
422
+ if (!previous_value) {
423
+ console.warn(`Deletion of node ${id} skipped, as it does not exist.`);
424
+ return this;
425
+ }
426
+ // Get nodes referenced by this node BEFORE deleting it.
427
+ const referenced_nodes = this.get_referenced_nodes(id);
428
+ const op: DocumentOperation = ['delete', id];
429
+ this.ops.push(op);
430
+ this.inverse_ops.push(['create', previous_value]);
431
+ this._apply_op(op);
432
+ this._track_node_id(this.deleted_node_ids, id);
433
+ // Cascade delete any nodes that are now orphaned after removing this node
434
+ this._cascade_delete_unreferenced_nodes(referenced_nodes);
435
+ return this;
436
+ }
437
+
438
+ /**
439
+ * Sets the document selection.
440
+ *
441
+ * @param selection - The new selection object
442
+ * @returns This transaction instance for method chaining
443
+ * @throws {Error} Throws if the selection is invalid or out of bounds
444
+ */
445
+ set_selection(selection: Selection): this {
446
+ this._validate_selection(selection);
447
+ this.selection = selection;
448
+ return this;
449
+ }
450
+
451
+ /**
452
+ * Validates a selection against the current document state.
453
+ *
454
+ * @throws {Error} Throws if the selection is invalid
455
+ */
456
+ private _validate_selection(selection: Selection | null): void {
457
+ validate_selection(selection, this);
458
+ }
459
+
460
+ /**
461
+ * Toggles a mark on the current text or node selection.
462
+ *
463
+ * Marks are mutually exclusive. Whole marks are toggled, removed, or
464
+ * switched; ranges are never truncated or expanded by this command.
465
+ *
466
+ * @param mark_type - The type of mark (e.g., 'link', 'strong', 'emphasis')
467
+ * @param mark_properties - Additional data for the mark (e.g., href for links)
468
+ * @returns This transaction instance for method chaining
469
+ *
470
+ * @example
471
+ * ```js
472
+ * // Add a link mark
473
+ * tr.toggle_mark('link', { href: 'https://example.com' });
474
+ *
475
+ * // Add emphasis
476
+ * tr.toggle_mark('emphasis', {});
477
+ * ```
478
+ */
479
+ toggle_mark(mark_type: string, mark_properties?: DynamicRecord): this {
480
+ if (this.selection.type !== 'text' && this.selection.type !== 'node') return this;
481
+ if (this.selection.type === 'node' && is_selection_collapsed(this.selection)) return this;
482
+ if (!this.available_mark_types.includes(mark_type)) {
483
+ console.warn(`Mark type ${mark_type} is not allowed here.`);
484
+ return this;
485
+ }
486
+
487
+ const range = get_selection_range(this.selection);
488
+ if (!range) return this;
489
+
490
+ const annotated_value = structuredClone($state.snapshot(this.get(this.selection.path)));
491
+
492
+ // All marks compete for toggling, since marks are mutually exclusive.
493
+ // Annotations never block a mark toggle.
494
+ const selected_marks = this.selected_marks;
495
+ const selected_mark_types = get_selected_range_types(selected_marks);
496
+
497
+ if (selected_mark_types.size > 1) return this;
498
+
499
+ if (selected_marks.length === 0) {
500
+ if (is_selection_collapsed(this.selection)) {
501
+ return this;
502
+ }
503
+ const new_mark_node = {
504
+ id: this.generate_id(),
505
+ type: mark_type,
506
+ ...mark_properties
507
+ };
508
+ this.create(new_mark_node);
509
+ // If there's no existing mark, add the new one
510
+ annotated_value.marks.push({
511
+ start_offset: range.start_offset,
512
+ end_offset: range.end_offset,
513
+ node_id: new_mark_node.id
514
+ });
515
+ this.set(this.selection.path, annotated_value);
516
+ return this;
517
+ }
518
+
519
+ const first_selected_mark = selected_marks[0];
520
+ const selected_mark_type = first_selected_mark.node.type;
521
+
522
+ if (selected_mark_type === mark_type) {
523
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
524
+ const selected_indices = new Set(selected_marks.map(({ index }) => index));
525
+ const removed_node_ids = selected_marks.map(({ node_id }) => node_id);
526
+ annotated_value.marks = annotated_value.marks.filter(
527
+ (_: Attachment, index: number) => !selected_indices.has(index)
528
+ );
529
+ this.set(this.selection.path, annotated_value);
530
+ this._cascade_delete_unreferenced_nodes(removed_node_ids);
531
+ return this;
532
+ }
533
+
534
+ if (
535
+ selected_marks.length === 1 &&
536
+ can_switch_mark_type(this.schema, selected_mark_type, mark_type)
537
+ ) {
538
+ const selected_mark = first_selected_mark;
539
+ this.set([selected_mark.node_id, 'type'], mark_type);
540
+ this.set_selection({
541
+ type: this.selection.type,
542
+ path: this.selection.path,
543
+ anchor_offset: selected_mark.start_offset,
544
+ focus_offset: selected_mark.end_offset
545
+ });
546
+ return this;
547
+ }
548
+
549
+ return this;
550
+ }
551
+
552
+ /**
553
+ * Toggles an annotation on the current text or node selection.
554
+ *
555
+ * Annotations only compete with touched annotations of the same type:
556
+ * touched same-type annotations are removed, otherwise a new annotation is
557
+ * created. Marks and other annotation types never block a toggle, and
558
+ * same-type annotations may still overlap when created through lower-level
559
+ * APIs.
560
+ *
561
+ * @param annotation_type - The type of annotation (e.g., 'comment', 'marker')
562
+ * @param annotation_properties - Additional data for the annotation
563
+ * @returns This transaction instance for method chaining
564
+ *
565
+ * @example
566
+ * ```js
567
+ * // Add a comment annotation
568
+ * tr.toggle_annotation('comment', { text: 'Please review' });
569
+ * ```
570
+ */
571
+ toggle_annotation(annotation_type: string, annotation_properties?: DynamicRecord): this {
572
+ if (this.selection.type !== 'text' && this.selection.type !== 'node') return this;
573
+ if (this.selection.type === 'node' && is_selection_collapsed(this.selection)) return this;
574
+ if (!this.available_annotation_types.includes(annotation_type)) {
575
+ console.warn(`Annotation type ${annotation_type} is not allowed here.`);
576
+ return this;
577
+ }
578
+
579
+ const range = get_selection_range(this.selection);
580
+ if (!range) return this;
581
+
582
+ const annotated_value = structuredClone($state.snapshot(this.get(this.selection.path)));
583
+
584
+ // Only touched annotations of the same type compete
585
+ const selected_annotations = this.selected_annotations.filter(
586
+ ({ node }) => node?.type === annotation_type
587
+ );
588
+
589
+ if (selected_annotations.length === 0) {
590
+ if (is_selection_collapsed(this.selection)) {
591
+ return this;
592
+ }
593
+ const new_annotation_node = {
594
+ id: this.generate_id(),
595
+ type: annotation_type,
596
+ ...annotation_properties
597
+ };
598
+ this.create(new_annotation_node);
599
+ annotated_value.annotations.push({
600
+ start_offset: range.start_offset,
601
+ end_offset: range.end_offset,
602
+ node_id: new_annotation_node.id
603
+ });
604
+ this.set(this.selection.path, annotated_value);
605
+ return this;
606
+ }
607
+
608
+ // Remove touched same-type annotations
609
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
610
+ const selected_indices = new Set(selected_annotations.map(({ index }) => index));
611
+ const removed_node_ids = selected_annotations.map(({ node_id }) => node_id);
612
+ annotated_value.annotations = annotated_value.annotations.filter(
613
+ (_: Attachment, index: number) => !selected_indices.has(index)
614
+ );
615
+ this.set(this.selection.path, annotated_value);
616
+ this._cascade_delete_unreferenced_nodes(removed_node_ids);
617
+ return this;
618
+ }
619
+
620
+ /**
621
+ * Deletes the currently selected text, node, or property.
622
+ *
623
+ * Behavior depends on selection type:
624
+ * - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
625
+ * - For text selections: Removes selected text and adjusts annotations accordingly
626
+ * - For collapsed selections: Deletes the previous character/node (backward) or next character/node (forward)
627
+ * - For property selections: Delegates to the config's handle_property_deletion hook
628
+ *
629
+ * @param direction - Direction of deletion for collapsed selections
630
+ * @returns This transaction instance for method chaining
631
+ */
632
+ delete_selection(direction: 'backward' | 'forward' = 'backward'): this {
633
+ if (!this.selection) return this;
634
+
635
+ if (this.selection.type === 'property') {
636
+ this.config.handle_property_deletion?.(this, this.selection.path);
637
+ return this;
638
+ }
639
+
640
+ const path = this.selection.path;
641
+
642
+ // Get the start and end indices for the selection
643
+ let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
644
+ let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
645
+ let length = 0;
646
+
647
+ if (this.selection?.type === 'text') {
648
+ const text_content = this.get(this.selection.path).content;
649
+ length = get_char_length(text_content);
650
+ } else if (this.selection?.type === 'node') {
651
+ const node_array = this.get(this.selection.path);
652
+ length = node_array.nodes.length;
653
+ }
654
+
655
+ // If selection is collapsed we delete the previous char/node (backward)
656
+ // or the next char/node (forward)
657
+ if (start === end) {
658
+ if (direction === 'backward' && start > 0) {
659
+ start = start - 1;
660
+ } else if (direction === 'forward' && end < length) {
661
+ end = end + 1;
662
+ } else if (direction === 'backward' && start === 0) {
663
+ join_text_node(this);
664
+ return this;
665
+ } else if (direction === 'forward' && end === length) {
666
+ // At end of text - try to join with next text node
667
+ const node_index = this.selection.path.at(-2) as number;
668
+ const successor_node = this.get([...this.selection.path.slice(0, -2), node_index + 1]);
669
+ // Check if next node is a text node
670
+ if (successor_node && this.kind(successor_node) === 'text') {
671
+ // Set selection to beginning of next text node
672
+ this.set_selection({
673
+ type: 'text',
674
+ path: [...this.selection.path.slice(0, -2), node_index + 1, 'content'],
675
+ anchor_offset: 0,
676
+ focus_offset: 0
677
+ });
678
+ // Use join_text_node to merge with previous node
679
+ join_text_node(this);
680
+ }
681
+ return this;
682
+ }
683
+ }
684
+
685
+ if (this.selection.type === 'node') {
686
+ const current_value = structuredClone($state.snapshot(this.get(path)));
687
+ const node_array = [...current_value.nodes];
688
+ const deletion_length = end - start;
689
+
690
+ // Remove the selected nodes from the node_array
691
+ node_array.splice(start, deletion_length);
692
+ const marks_result = adjust_ranges_for_deletion(current_value.marks, start, end);
693
+ const annotations_result = adjust_ranges_for_deletion(current_value.annotations, start, end);
694
+
695
+ // Update the node_array in the entry (this implicitly records an op via this.set)
696
+ // Note: this.set() will automatically cascade delete unreferenced nodes
697
+ this.set(path, {
698
+ nodes: node_array,
699
+ marks: marks_result.ranges,
700
+ annotations: annotations_result.ranges
701
+ });
702
+ this._cascade_delete_unreferenced_nodes([
703
+ ...marks_result.removed_node_ids,
704
+ ...annotations_result.removed_node_ids
705
+ ]);
706
+
707
+ // Update the selection to point to the start of the deleted range
708
+ this.selection = {
709
+ type: 'node',
710
+ path,
711
+ anchor_offset: start,
712
+ focus_offset: start
713
+ };
714
+ } else if (this.selection.type === 'text') {
715
+ const path = this.selection.path;
716
+ const text = structuredClone($state.snapshot(this.get(path)));
717
+
718
+ // Update the text content using character-based operations
719
+ const original_text = text.content;
720
+ text.content =
721
+ char_slice(original_text, 0, start) +
722
+ char_slice(original_text, end, get_char_length(original_text));
723
+
724
+ const marks_result = adjust_ranges_for_deletion(text.marks, start, end);
725
+ const annotations_result = adjust_ranges_for_deletion(text.annotations, start, end);
726
+ text.marks = marks_result.ranges;
727
+ text.annotations = annotations_result.ranges;
728
+
729
+ this.set(path, text);
730
+
731
+ // Delete removed mark/annotation nodes only if no other references remain.
732
+ this._cascade_delete_unreferenced_nodes([
733
+ ...marks_result.removed_node_ids,
734
+ ...annotations_result.removed_node_ids
735
+ ]);
736
+
737
+ // Update the selection to the new caret position
738
+ this.selection = {
739
+ type: 'text',
740
+ path,
741
+ anchor_offset: start,
742
+ focus_offset: start
743
+ };
744
+ }
745
+
746
+ return this;
747
+ }
748
+
749
+ /**
750
+ * Inserts nodes at the current node selection position.
751
+ *
752
+ * If the selection is expanded (not collapsed), first deletes the selected nodes
753
+ * before inserting the new ones.
754
+ *
755
+ * @param node_ids - Array of node IDs to insert
756
+ * @param marks - Selection-relative marks to restore
757
+ * @param annotations - Selection-relative annotations to restore
758
+ * @param nodes - Node graph for restored marks and annotations
759
+ * @returns This transaction instance for method chaining
760
+ */
761
+ insert_nodes(
762
+ node_ids: NodeId[],
763
+ marks: Array<Mark> = [],
764
+ annotations: Array<Annotation> = [],
765
+ nodes: Record<NodeId, DocumentNode> = {}
766
+ ): this {
767
+ if (this.selection.type !== 'node') return this;
768
+
769
+ // Unless caret is collapsed, delete the selected nodes as a first step
770
+ if (this.selection.anchor_offset !== this.selection.focus_offset) {
771
+ this.delete_selection();
772
+ }
773
+
774
+ const path = this.selection.path;
775
+ const current_value = structuredClone($state.snapshot(this.get(path)));
776
+ const node_array = [...current_value.nodes];
777
+
778
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
779
+
780
+ let next_marks = adjust_ranges_for_insertion(current_value.marks, start, node_ids.length);
781
+ let next_annotations = adjust_ranges_for_insertion(
782
+ current_value.annotations,
783
+ start,
784
+ node_ids.length
785
+ );
786
+
787
+ // Insert the new nodes
788
+ node_array.splice(start, 0, ...node_ids);
789
+
790
+ this.selection = {
791
+ type: 'node',
792
+ path: [...this.selection.path],
793
+ anchor_offset: start,
794
+ focus_offset: start + node_ids.length
795
+ };
796
+
797
+ const property_definition = this.inspect(path);
798
+
799
+ // Restore marks only when they stay mutually exclusive
800
+ if (!this.active_mark && marks.length > 0 && are_ranges_exclusive(marks, node_ids.length)) {
801
+ const restored_marks = marks
802
+ .map((mark) => {
803
+ const mark_node = nodes[mark.node_id];
804
+ if (!property_definition.mark_types?.includes(mark_node?.type)) return null;
805
+
806
+ const new_mark_node_id = this.build(mark.node_id, nodes);
807
+ return {
808
+ start_offset: start + mark.start_offset,
809
+ end_offset: start + mark.end_offset,
810
+ node_id: new_mark_node_id
811
+ };
812
+ })
813
+ .filter((mark): mark is Attachment => mark !== null);
814
+ const combined_marks = next_marks.concat(restored_marks);
815
+ if (are_ranges_exclusive(combined_marks)) {
816
+ next_marks = combined_marks;
817
+ } else {
818
+ for (const mark of restored_marks) {
819
+ this.delete(mark.node_id);
820
+ }
821
+ }
822
+ }
823
+
824
+ // Restore annotations; overlap is allowed
825
+ const restored_annotations = annotations
826
+ .map((annotation) => {
827
+ if (!is_range_within_bounds(annotation, node_ids.length)) return null;
828
+ const annotation_node = nodes[annotation.node_id];
829
+ if (!property_definition.annotation_types?.includes(annotation_node?.type)) return null;
830
+
831
+ const new_annotation_node_id = this.build(annotation.node_id, nodes);
832
+ return {
833
+ start_offset: start + annotation.start_offset,
834
+ end_offset: start + annotation.end_offset,
835
+ node_id: new_annotation_node_id
836
+ };
837
+ })
838
+ .filter((annotation): annotation is Attachment => annotation !== null);
839
+ next_annotations = next_annotations.concat(restored_annotations);
840
+
841
+ this.set(path, { nodes: node_array, marks: next_marks, annotations: next_annotations });
842
+ return this;
843
+ }
844
+
845
+ /**
846
+ * Inserts text at the current text selection position.
847
+ *
848
+ * Handles mark and annotation adjustments when text is inserted, including:
849
+ * - Expanding ranges that contain the insertion point
850
+ * - Shifting ranges that come after the insertion point
851
+ * - Optionally applying new marks and annotations to the inserted text
852
+ *
853
+ * @param replaced_text - The text to insert
854
+ * @param marks - Optional marks to apply to the inserted text
855
+ * @param annotations - Optional annotations to apply to the inserted text
856
+ * @param nodes - Optional node definitions for mark and annotation nodes
857
+ * @returns This transaction instance for method chaining
858
+ */
859
+ insert_text(
860
+ replaced_text: string,
861
+ marks: Array<Mark> = [],
862
+ annotations: Array<Annotation> = [],
863
+ nodes: Record<NodeId, DocumentNode> = {}
864
+ ): this {
865
+ if (this.selection?.type !== 'text') return this;
866
+
867
+ // Unless selection is collapsed, delete the selected content
868
+ // NOTE: This makes sure wrapped marks and annotations are disposed correctly
869
+ if (!is_selection_collapsed(this.selection)) {
870
+ this.delete_selection();
871
+ }
872
+
873
+ const text_value = structuredClone($state.snapshot(this.get(this.selection.path)));
874
+ const range = get_selection_range(this.selection)!;
875
+
876
+ // Transform the plain text string using character-based operations
877
+ const current_text = text_value.content;
878
+ text_value.content =
879
+ char_slice(current_text, 0, range.start_offset) +
880
+ replaced_text +
881
+ char_slice(current_text, range.end_offset);
882
+
883
+ // Calculate the change in length
884
+ const delta = get_char_length(replaced_text);
885
+
886
+ text_value.marks = adjust_ranges_for_insertion(text_value.marks, range.start_offset, delta);
887
+ text_value.annotations = adjust_ranges_for_insertion(
888
+ text_value.annotations,
889
+ range.start_offset,
890
+ delta
891
+ );
892
+ this.set(this.selection.path, text_value); // this will update the current state and create a history entry
893
+
894
+ // Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
895
+ const new_selection: Selection = {
896
+ type: 'text',
897
+ path: this.selection.path,
898
+ anchor_offset: range.start_offset + get_char_length(replaced_text),
899
+ focus_offset: range.start_offset + get_char_length(replaced_text)
900
+ };
901
+ this.selection = new_selection;
902
+
903
+ const text_property_definition = this.inspect(this.selection.path);
904
+ const next_text = structuredClone(text_value);
905
+ let text_changed = false;
906
+
907
+ // Now we apply marks if there are any, but only if there's no active mark
908
+ // at the current collapsed caret
909
+ if (!this.active_mark && marks.length > 0 && are_ranges_exclusive(marks, delta)) {
910
+ const restored_marks = marks
911
+ .map((mark) => {
912
+ const original_mark_node = nodes[mark.node_id];
913
+ if (text_property_definition.mark_types?.includes(original_mark_node?.type)) {
914
+ const new_mark_node_id = this.build(mark.node_id, nodes);
915
+ return {
916
+ start_offset: range.start_offset + mark.start_offset,
917
+ end_offset: range.start_offset + mark.end_offset,
918
+ node_id: new_mark_node_id
919
+ };
920
+ }
921
+ return null;
922
+ })
923
+ .filter((mark): mark is Attachment => mark !== null);
924
+ const combined_marks = text_value.marks.concat(restored_marks);
925
+ if (are_ranges_exclusive(combined_marks)) {
926
+ next_text.marks = combined_marks;
927
+ text_changed = true;
928
+ } else {
929
+ for (const mark of restored_marks) {
930
+ this.delete(mark.node_id);
931
+ }
932
+ }
933
+ }
934
+
935
+ // Apply annotations to the inserted text; overlap is allowed
936
+ if (annotations.length > 0) {
937
+ const restored_annotations = annotations
938
+ .map((annotation) => {
939
+ if (!is_range_within_bounds(annotation, delta)) return null;
940
+ const original_annotation_node = nodes[annotation.node_id];
941
+ if (text_property_definition.annotation_types?.includes(original_annotation_node?.type)) {
942
+ const new_annotation_node_id = this.build(annotation.node_id, nodes);
943
+ return {
944
+ start_offset: range.start_offset + annotation.start_offset,
945
+ end_offset: range.start_offset + annotation.end_offset,
946
+ node_id: new_annotation_node_id
947
+ };
948
+ }
949
+ return null;
950
+ })
951
+ .filter((annotation): annotation is Attachment => annotation !== null);
952
+ if (restored_annotations.length > 0) {
953
+ next_text.annotations = text_value.annotations.concat(restored_annotations);
954
+ text_changed = true;
955
+ }
956
+ }
957
+
958
+ if (text_changed) {
959
+ this.set(this.selection.path, next_text);
960
+ }
961
+
962
+ return this;
963
+ }
964
+
965
+ /**
966
+ * Recursively deletes nodes that are no longer referenced in the document.
967
+ *
968
+ * This handles the cascade deletion of child nodes when their parent
969
+ * references are removed. Uses reference counting to determine which
970
+ * nodes are safe to delete.
971
+ *
972
+ * @param potentially_orphaned_nodes - Array of node IDs to check
973
+ */
974
+ private _cascade_delete_unreferenced_nodes(potentially_orphaned_nodes: NodeId[]): void {
975
+ if (potentially_orphaned_nodes.length === 0) return;
976
+
977
+ // Build reference counts in ONE full-document scan, then maintain them
978
+ // incrementally: marking a node deleted decrements the counts of every
979
+ // node it references (per occurrence). This replaces the previous
980
+ // per-candidate full-document re-scan, which made deleting M nodes
981
+ // O(M·N) instead of O(N + M).
982
+ const ref_counts = build_reference_counts(this.schema, this.doc);
983
+
984
+ const nodes_to_delete: Record<NodeId, boolean> = {};
985
+ const to_check = [...potentially_orphaned_nodes];
986
+
987
+ while (to_check.length > 0) {
988
+ const node_id = to_check.pop();
989
+ if (!node_id || nodes_to_delete[node_id]) continue;
990
+
991
+ if ((ref_counts.get(node_id) || 0) === 0) {
992
+ // No more references, safe to delete this node
993
+ nodes_to_delete[node_id] = true;
994
+
995
+ // Release this node's outgoing references and re-check the
996
+ // nodes it pointed at — they may have become unreferenced.
997
+ const node = this.doc.nodes[node_id];
998
+ if (node) {
999
+ visit_node_references(this.schema, node, (referenced_id) => {
1000
+ const count = ref_counts.get(referenced_id);
1001
+ if (count !== undefined) ref_counts.set(referenced_id, count - 1);
1002
+ to_check.push(referenced_id);
1003
+ });
1004
+ }
1005
+ }
1006
+ }
1007
+
1008
+ // Now perform the actual deletions
1009
+ for (const node_id of Object.keys(nodes_to_delete)) {
1010
+ const previous_value = this.get([node_id]);
1011
+ if (previous_value) {
1012
+ const op: DocumentOperation = ['delete', node_id];
1013
+ this.ops.push(op);
1014
+ this.inverse_ops.push(['create', previous_value]);
1015
+ this._apply_op(op);
1016
+ this._track_node_id(this.deleted_node_ids, node_id);
1017
+ }
1018
+ }
1019
+ }
1020
+ }