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,846 +0,0 @@
1
- /**
2
- * @import { NodeId, Selection, Document, DocumentSchema, DocumentPath, Annotation } from './types.d.js'
3
- */
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
-
27
- /**
28
- * Transaction class for managing atomic document operations with undo/redo support.
29
- *
30
- * A Transaction provides a way to group multiple document operations (create, delete, set)
31
- * into a single atomic unit that can be applied or rolled back as one. It maintains
32
- * both forward operations and their inverse operations for undo functionality.
33
- *
34
- * @example
35
- * ```js
36
- * const tr = session.tr;
37
- * tr.set(['node_1', 'title'], 'New Title');
38
- * tr.create({id: 'node_2', type: 'paragraph', content: 'Hello'});
39
- * session.apply(tr); // Applies all operations atomically
40
- * ```
41
- */
42
- 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
- }
846
- }