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