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