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