svedit 0.1.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/LICENSE +21 -0
- package/README.md +323 -0
- package/dist/AnnotatedStringProperty.svelte +126 -0
- package/dist/AnnotatedStringProperty.svelte.d.ts +7 -0
- package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -0
- package/dist/CustomProperty.svelte +40 -0
- package/dist/CustomProperty.svelte.d.ts +8 -0
- package/dist/CustomProperty.svelte.d.ts.map +1 -0
- package/dist/Document.svelte.d.ts +191 -0
- package/dist/Document.svelte.d.ts.map +1 -0
- package/dist/Document.svelte.js +681 -0
- package/dist/Node.svelte +41 -0
- package/dist/Node.svelte.d.ts +7 -0
- package/dist/Node.svelte.d.ts.map +1 -0
- package/dist/NodeArrayProperty.svelte +45 -0
- package/dist/NodeArrayProperty.svelte.d.ts +7 -0
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -0
- package/dist/Svedit.svelte +936 -0
- package/dist/Svedit.svelte.d.ts +12 -0
- package/dist/Svedit.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +198 -0
- package/dist/Transaction.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.js +577 -0
- package/dist/UnknownNode.svelte +12 -0
- package/dist/UnknownNode.svelte.d.ts +12 -0
- package/dist/UnknownNode.svelte.d.ts.map +1 -0
- package/dist/commands.svelte.d.ts +5 -0
- package/dist/commands.svelte.d.ts.map +1 -0
- package/dist/commands.svelte.js +261 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/types.d.ts +264 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +78 -0
- package/package.json +65 -0
- package/src/lib/AnnotatedStringProperty.svelte +126 -0
- package/src/lib/CustomProperty.svelte +40 -0
- package/src/lib/Document.svelte.js +681 -0
- package/src/lib/Node.svelte +41 -0
- package/src/lib/NodeArrayProperty.svelte +45 -0
- package/src/lib/Svedit.svelte +936 -0
- package/src/lib/Transaction.svelte.js +577 -0
- package/src/lib/UnknownNode.svelte +12 -0
- package/src/lib/commands.svelte.js +261 -0
- package/src/lib/index.js +14 -0
- package/src/lib/types.d.ts +264 -0
- package/src/lib/util.js +78 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { NodeId, SerializedNode, Selection, DocumentPath } from './types.js'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transaction class for managing atomic document operations with undo/redo support.
|
|
7
|
+
*
|
|
8
|
+
* A Transaction provides a way to group multiple document operations (create, delete, set)
|
|
9
|
+
* into a single atomic unit that can be applied or rolled back as one. It maintains
|
|
10
|
+
* both forward operations and their inverse operations for undo functionality.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```js
|
|
14
|
+
* const tr = doc.tr;
|
|
15
|
+
* tr.set(['node_1', 'title'], 'New Title');
|
|
16
|
+
* tr.create({id: 'node_2', type: 'paragraph', content: 'Hello'});
|
|
17
|
+
* doc.apply(tr); // Applies all operations atomically
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export default class Transaction {
|
|
21
|
+
/**
|
|
22
|
+
* Creates a new Transaction for the given document.
|
|
23
|
+
*
|
|
24
|
+
* @param {any} doc - The document instance this transaction operates on
|
|
25
|
+
*/
|
|
26
|
+
constructor(doc) {
|
|
27
|
+
this.doc = doc;
|
|
28
|
+
// Here we track the ops during the transaction
|
|
29
|
+
/** @type {Record<string, any>[]} */
|
|
30
|
+
this.ops = [];
|
|
31
|
+
/** @type {Record<string, any>[]} */
|
|
32
|
+
this.inverse_ops = [];
|
|
33
|
+
// Remember the selection before the transaction started
|
|
34
|
+
this.selection_before = this.doc.selection;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Gets a value from the document at the specified path.
|
|
39
|
+
*
|
|
40
|
+
* @param {DocumentPath} path - The path to the value in the document
|
|
41
|
+
* @returns {any} The value at the specified path
|
|
42
|
+
*/
|
|
43
|
+
get(path) {
|
|
44
|
+
return this.doc.get(path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Gets the current selection state of the document.
|
|
49
|
+
*
|
|
50
|
+
* @returns {Selection} The current selection object
|
|
51
|
+
*/
|
|
52
|
+
get selection() {
|
|
53
|
+
return this.doc.selection;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sets a property of a node to a new value.
|
|
58
|
+
*
|
|
59
|
+
* This is the core operation for modifying document properties. It records
|
|
60
|
+
* both the forward operation and its inverse for undo support.
|
|
61
|
+
*
|
|
62
|
+
* @param {DocumentPath} path - Array path to the property (e.g., ["node_1", "title"])
|
|
63
|
+
* @param {any} value - The new value to set
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```js
|
|
67
|
+
* tr.set(["list_1", "list_items"], [1, 2, 3]);
|
|
68
|
+
* tr.set(["page_1", "body", "0", "description"], ["Hello world", []]);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
set (path, value) {
|
|
72
|
+
const node = this.doc.get(path.slice(0, -1));
|
|
73
|
+
|
|
74
|
+
// Turns ["page_1", "body", "0", "description"]
|
|
75
|
+
// into ["paragraph_1", "description"].
|
|
76
|
+
// Important to keep changes of multiple ops invertible.
|
|
77
|
+
const normalized_path = [node.id, path.at(-1)];
|
|
78
|
+
|
|
79
|
+
// Just to be sure, make a deep copy of the old value
|
|
80
|
+
const property_key = path.at(-1);
|
|
81
|
+
if (property_key === undefined) {
|
|
82
|
+
throw new Error('Invalid path: cannot get property key');
|
|
83
|
+
}
|
|
84
|
+
const previous_value = structuredClone($state.snapshot(node[property_key]));
|
|
85
|
+
|
|
86
|
+
const op = ['set', normalized_path, value];
|
|
87
|
+
this.ops.push(op);
|
|
88
|
+
this.inverse_ops.push(['set', path, previous_value]);
|
|
89
|
+
this.doc._apply_op(op);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates a new node in the document.
|
|
94
|
+
*
|
|
95
|
+
* The node must have a valid id and must not already exist in the document.
|
|
96
|
+
* The node is validated against the document schema before creation.
|
|
97
|
+
*
|
|
98
|
+
* @param {any} node - The node object to create (must include id, type, and other properties)
|
|
99
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
100
|
+
* @throws {Error} If the node ID is invalid or if the node already exists
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```js
|
|
104
|
+
* tr.create({
|
|
105
|
+
* id: 'para_123',
|
|
106
|
+
* type: 'paragraph',
|
|
107
|
+
* content: ['Hello world', []]
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
create(node) {
|
|
112
|
+
// Validate node against schema
|
|
113
|
+
this.doc.validate_node(node);
|
|
114
|
+
|
|
115
|
+
if (this.doc.get(node.id)) {
|
|
116
|
+
throw new Error('Node with id ' + node.id + ' already exists');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const op = ['create', node];
|
|
120
|
+
this.ops.push(op);
|
|
121
|
+
this.inverse_ops.push(['delete', node.id]);
|
|
122
|
+
this.doc._apply_op(op);
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Deletes a node from the document by its ID.
|
|
128
|
+
*
|
|
129
|
+
* The node's current state is captured for undo support before deletion.
|
|
130
|
+
*
|
|
131
|
+
* @param {any} id - The ID of the node to delete
|
|
132
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```js
|
|
136
|
+
* tr.delete('node_123');
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
delete(id) {
|
|
140
|
+
const previous_value = $state.snapshot(this.doc.get(id));
|
|
141
|
+
const op = ['delete', id];
|
|
142
|
+
this.ops.push(op);
|
|
143
|
+
this.inverse_ops.push(['create', previous_value]);
|
|
144
|
+
this.doc._apply_op(op);
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Sets the document selection.
|
|
150
|
+
*
|
|
151
|
+
* @param {Selection} selection - The new selection object
|
|
152
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
153
|
+
* @todo Check if selection is valid and throw error if not
|
|
154
|
+
*/
|
|
155
|
+
set_selection(selection) {
|
|
156
|
+
// TODO: Check if selection is valid and throw error if not
|
|
157
|
+
this.doc.selection = selection;
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Adds, updates, or removes text annotations in the current selection.
|
|
163
|
+
*
|
|
164
|
+
* Handles various annotation scenarios including adding new annotations,
|
|
165
|
+
* updating existing ones (especially for links), and removing annotations
|
|
166
|
+
* when conflicting types are applied.
|
|
167
|
+
*
|
|
168
|
+
* @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
|
|
169
|
+
* @param {any} annotation_data - Additional data for the annotation (e.g., href for links)
|
|
170
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```js
|
|
174
|
+
* // Add a link annotation
|
|
175
|
+
* tr.annotate_text('link', { href: 'https://example.com' });
|
|
176
|
+
*
|
|
177
|
+
* // Add emphasis
|
|
178
|
+
* tr.annotate_text('emphasis', {});
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
annotate_text(annotation_type, annotation_data) {
|
|
182
|
+
if (this.doc.selection.type !== 'text') return this;
|
|
183
|
+
|
|
184
|
+
const { start, end } = this.doc.get_selection_range();
|
|
185
|
+
const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
|
|
186
|
+
const annotations = annotated_string[1];
|
|
187
|
+
const existing_annotations = this.doc.active_annotation();
|
|
188
|
+
|
|
189
|
+
// Special annotation type handling should probably be done in a separate function.
|
|
190
|
+
// The goal is to keep the core logic simple and allow developer to extend and pick only what they need.
|
|
191
|
+
// It could also be abstracted to not check for type (e.g. "link") but for a special attribute
|
|
192
|
+
// e.g. "zero-range-updatable" for annotations that are updatable without a range selection change.
|
|
193
|
+
|
|
194
|
+
// Special handling for links when there's no selection range
|
|
195
|
+
// Links should be updatable by just clicking on them without a range selection
|
|
196
|
+
if (annotation_type === 'link' && start === end && existing_annotations) {
|
|
197
|
+
|
|
198
|
+
// Use findIndex for deep comparison of annotation properties (comparison of annotation properties rather than object reference via indexOf)
|
|
199
|
+
const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
|
|
200
|
+
anno[0] === existing_annotations[0] &&
|
|
201
|
+
anno[1] === existing_annotations[1] &&
|
|
202
|
+
anno[2] === existing_annotations[2]
|
|
203
|
+
);
|
|
204
|
+
// const index = annotations.indexOf(existing_annotations);
|
|
205
|
+
|
|
206
|
+
if (index !== -1) {
|
|
207
|
+
if (annotation_data.href === '') {
|
|
208
|
+
// Remove the annotation if the href is empty
|
|
209
|
+
annotations.splice(index, 1);
|
|
210
|
+
} else {
|
|
211
|
+
annotations[index] = [
|
|
212
|
+
existing_annotations[0],
|
|
213
|
+
existing_annotations[1],
|
|
214
|
+
'link',
|
|
215
|
+
{ ...existing_annotations[3], ...annotation_data }
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.set(this.doc.selection.path, annotated_string);
|
|
220
|
+
return this;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Regular annotation handling
|
|
225
|
+
if (start === end) {
|
|
226
|
+
// For non-link annotations: You can not annotate text if the selection is collapsed.
|
|
227
|
+
return this;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (existing_annotations) {
|
|
231
|
+
// If there's an existing annotation of the same type, remove it
|
|
232
|
+
if (existing_annotations[2] === annotation_type) {
|
|
233
|
+
const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
|
|
234
|
+
anno[0] === existing_annotations[0] &&
|
|
235
|
+
anno[1] === existing_annotations[1] &&
|
|
236
|
+
anno[2] === existing_annotations[2]
|
|
237
|
+
);
|
|
238
|
+
if (index !== -1) {
|
|
239
|
+
annotations.splice(index, 1);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// If there's an annotation of a different type, don't add a new one
|
|
243
|
+
return this;
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
// If there's no existing annotation, add the new one
|
|
247
|
+
annotations.push([start, end, annotation_type, annotation_data]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Update the annotated string
|
|
251
|
+
this.set(this.doc.selection.path, annotated_string);
|
|
252
|
+
// Not needed anymore as doc.apply makes sure selection is rerendered
|
|
253
|
+
// this.doc.selection = { ...this.doc.selection };
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Deletes the currently selected text or nodes.
|
|
259
|
+
*
|
|
260
|
+
* Behavior depends on selection type:
|
|
261
|
+
* - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
|
|
262
|
+
* - For text selections: Removes selected text and adjusts annotations accordingly
|
|
263
|
+
* - For collapsed selections: Deletes the previous character/node
|
|
264
|
+
*
|
|
265
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
266
|
+
*/
|
|
267
|
+
delete_selection() {
|
|
268
|
+
if (!this.doc.selection) return this;
|
|
269
|
+
const path = this.doc.selection.path;
|
|
270
|
+
// Get the start and end indices for the selection
|
|
271
|
+
let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
|
|
272
|
+
let end = Math.max(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
|
|
273
|
+
|
|
274
|
+
// If selection is collapsed we delete the previous node
|
|
275
|
+
if (start === end) {
|
|
276
|
+
if (start > 0) {
|
|
277
|
+
start = start - 1;
|
|
278
|
+
} else {
|
|
279
|
+
return this; // cursor is at the very beginning, do nothing.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (this.doc.selection.type === 'node') {
|
|
284
|
+
const node_array = [...this.doc.get(path)];
|
|
285
|
+
|
|
286
|
+
// Get the node IDs that will be removed
|
|
287
|
+
const removed_node_ids = node_array.slice(start, end);
|
|
288
|
+
|
|
289
|
+
// Remove the selected nodes from the node_array
|
|
290
|
+
node_array.splice(start, end - start);
|
|
291
|
+
|
|
292
|
+
// Update the node_array in the entry (this implicitly records an op via this.set)
|
|
293
|
+
this.set(path, node_array);
|
|
294
|
+
|
|
295
|
+
// Now check which nodes should be deleted based on reference counting
|
|
296
|
+
this._cascade_delete_unreferenced_nodes(removed_node_ids);
|
|
297
|
+
|
|
298
|
+
// Update the selection to point to the start of the deleted range
|
|
299
|
+
this.doc.selection = {
|
|
300
|
+
type: 'node',
|
|
301
|
+
path,
|
|
302
|
+
anchor_offset: start,
|
|
303
|
+
focus_offset: start
|
|
304
|
+
};
|
|
305
|
+
} else if (this.doc.selection.type === 'text') {
|
|
306
|
+
const path = this.doc.selection.path;
|
|
307
|
+
let text = structuredClone($state.snapshot(this.doc.get(path)));
|
|
308
|
+
|
|
309
|
+
// Update the text content
|
|
310
|
+
text[0] = text[0].slice(0, start) + text[0].slice(end);
|
|
311
|
+
|
|
312
|
+
// Update annotation offsets for deletion
|
|
313
|
+
const deletion_length = end - start;
|
|
314
|
+
const new_annotations = text[1].map(/** @param {any} annotation */ (annotation) => {
|
|
315
|
+
const [annotation_start, annotation_end, type, annotation_data] = annotation;
|
|
316
|
+
|
|
317
|
+
// Adjust start offset
|
|
318
|
+
let new_start = annotation_start;
|
|
319
|
+
if (annotation_start >= end) {
|
|
320
|
+
// Start is after deletion - shift back
|
|
321
|
+
new_start = annotation_start - deletion_length;
|
|
322
|
+
} else if (annotation_start >= start) {
|
|
323
|
+
// Start is within deletion - move to deletion point
|
|
324
|
+
new_start = start;
|
|
325
|
+
}
|
|
326
|
+
// If start is before deletion, keep unchanged
|
|
327
|
+
|
|
328
|
+
// Adjust end offset
|
|
329
|
+
let new_end = annotation_end;
|
|
330
|
+
if (annotation_end >= end) {
|
|
331
|
+
// End is after deletion - shift back
|
|
332
|
+
new_end = annotation_end - deletion_length;
|
|
333
|
+
} else if (annotation_end > start) {
|
|
334
|
+
// End is within deletion - move to deletion point
|
|
335
|
+
new_end = start;
|
|
336
|
+
}
|
|
337
|
+
// If end is before deletion, keep unchanged
|
|
338
|
+
|
|
339
|
+
// Remove annotation if it becomes invalid (start >= end)
|
|
340
|
+
if (new_start >= new_end) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return [new_start, new_end, type, annotation_data];
|
|
345
|
+
}).filter(Boolean);
|
|
346
|
+
|
|
347
|
+
text[1] = new_annotations;
|
|
348
|
+
|
|
349
|
+
// Update the text in the entry (this implicitly records an op via this.set)
|
|
350
|
+
this.set(path, text);
|
|
351
|
+
this.set_selection({
|
|
352
|
+
type: 'text',
|
|
353
|
+
path,
|
|
354
|
+
anchor_offset: start,
|
|
355
|
+
focus_offset: start
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Inserts an array of nodes at the current node selection.
|
|
363
|
+
*
|
|
364
|
+
* If there's a current selection, it will be deleted first. New nodes are
|
|
365
|
+
* created if they don't already exist (e.g., for cut+paste scenarios).
|
|
366
|
+
*
|
|
367
|
+
* @param {SerializedNode[]} nodes - Array of node objects to insert
|
|
368
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
369
|
+
*
|
|
370
|
+
* @note Assumes we only insert new nodes, not reference existing ones
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* ```js
|
|
374
|
+
* tr.insert_nodes([
|
|
375
|
+
* {id: 'para_1', type: 'paragraph', content: ['Hello', []]},
|
|
376
|
+
* {id: 'para_2', type: 'paragraph', content: ['World', []]}
|
|
377
|
+
* ]);
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
insert_nodes(nodes) {
|
|
381
|
+
if (this.doc.selection.type !== 'node') return this;
|
|
382
|
+
|
|
383
|
+
// Unless cursor is collapsed, delete the selected nodes as a first step
|
|
384
|
+
if (this.doc.selection.anchor_offset !== this.doc.selection.focus_offset) {
|
|
385
|
+
this.delete_selection();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const path = this.doc.selection.path;
|
|
389
|
+
const node_array = [...this.doc.get(path)];
|
|
390
|
+
|
|
391
|
+
// Get the start and end indices for the selection
|
|
392
|
+
let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
|
|
393
|
+
let end = Math.max(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
|
|
394
|
+
|
|
395
|
+
if (start !== end) {
|
|
396
|
+
// Remove the currently selected nodes from the node_array
|
|
397
|
+
// NOTE: We are okay that only the refernces are removed and nodes potentially become orphaned.
|
|
398
|
+
// They'll be purged on doc.to_json() anyways.
|
|
399
|
+
node_array.splice(start, end - start);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// First create the new nodes (unless they already exists e.g. in case of cut+paste)
|
|
403
|
+
nodes.forEach(node => {
|
|
404
|
+
if (!this.doc.get(node.id)) {
|
|
405
|
+
this.create(node);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
const node_ids = nodes.map(node => node.id);
|
|
409
|
+
node_array.splice(start, 0, ...node_ids);
|
|
410
|
+
|
|
411
|
+
// Update the node_array in the entry
|
|
412
|
+
this.set(path, node_array);
|
|
413
|
+
|
|
414
|
+
this.doc.selection = {
|
|
415
|
+
type: 'node',
|
|
416
|
+
path: [...this.doc.selection.path],
|
|
417
|
+
anchor_offset: start,
|
|
418
|
+
focus_offset: start + nodes.length
|
|
419
|
+
};
|
|
420
|
+
return this;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Inserts text at the current text selection, replacing any selected content.
|
|
425
|
+
*
|
|
426
|
+
* Updates both the text content and adjusts annotation offsets to maintain
|
|
427
|
+
* annotation integrity after the text insertion.
|
|
428
|
+
*
|
|
429
|
+
* @param {string} replaced_text - The text to insert
|
|
430
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
431
|
+
*
|
|
432
|
+
* @todo Support annotations attached to replaced_text for full copy&paste support
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* ```js
|
|
436
|
+
* tr.insert_text('Hello, world!');
|
|
437
|
+
* ```
|
|
438
|
+
*/
|
|
439
|
+
insert_text(replaced_text) {
|
|
440
|
+
if (this.doc.selection.type !== 'text') return this;
|
|
441
|
+
|
|
442
|
+
const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
|
|
443
|
+
const { start, end } = this.doc.get_selection_range();
|
|
444
|
+
|
|
445
|
+
// Transform the plain text string.
|
|
446
|
+
annotated_string[0] = annotated_string[0].slice(0, start) + replaced_text + annotated_string[0].slice(end);
|
|
447
|
+
|
|
448
|
+
// Transform the annotations (annotated_string[1])
|
|
449
|
+
// NOTE: Annotations are stored as [start_offset, end_offset, type]
|
|
450
|
+
const delta = replaced_text.length - (end - start);
|
|
451
|
+
const new_annotations = annotated_string[1].map(/** @param {any} annotation */ (annotation) => {
|
|
452
|
+
const [annotation_start, annotation_end, type, annotation_data] = annotation;
|
|
453
|
+
|
|
454
|
+
// Case 1: annotation is wrapped in start and end (remove it)
|
|
455
|
+
if (start <= annotation_start && end >= annotation_end) {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Case 2: text inserted before the annotation
|
|
460
|
+
if (end <= annotation_start) {
|
|
461
|
+
return [annotation_start + delta, annotation_end + delta, type, annotation_data];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Case 3: text inserted after the annotation
|
|
465
|
+
if (start >= annotation_end) {
|
|
466
|
+
return annotation;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Case 4: annotation is partly selected towards right
|
|
470
|
+
// NOTE: replaced_text will not be part of the annotation, we treat it the same as
|
|
471
|
+
// a cursor right after the annotation
|
|
472
|
+
if (start > annotation_start && start < annotation_end && end === annotation_end) {
|
|
473
|
+
return [annotation_start, start, type, annotation_data];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Case 5: text inserted inside an annotation
|
|
477
|
+
// NOTE: This also covers the case when a annotation is partly selected towards left
|
|
478
|
+
if (start >= annotation_start && start <= annotation_end && end < annotation_end && end >= annotation_start) {
|
|
479
|
+
return [annotation_start, annotation_end + delta, type, annotation_data];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Unhandled edge case:
|
|
483
|
+
console.error('annotation could not be transformed: ', annotation);
|
|
484
|
+
throw new Error('Case for transforming annotation not covered');
|
|
485
|
+
|
|
486
|
+
// return annotation;
|
|
487
|
+
}).filter(Boolean);
|
|
488
|
+
|
|
489
|
+
this.set(this.doc.selection.path, [annotated_string[0], new_annotations]); // this will update the current state and create a history entry
|
|
490
|
+
|
|
491
|
+
// Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
|
|
492
|
+
const new_selection = {
|
|
493
|
+
type: 'text',
|
|
494
|
+
path: this.doc.selection.path,
|
|
495
|
+
anchor_offset: start + replaced_text.length,
|
|
496
|
+
focus_offset: start + replaced_text.length,
|
|
497
|
+
};
|
|
498
|
+
this.doc.selection = new_selection;
|
|
499
|
+
return this;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Recursively deletes nodes that become unreferenced after the initial deletion.
|
|
504
|
+
*
|
|
505
|
+
* This method implements cascade deletion by checking reference counts and
|
|
506
|
+
* removing nodes that are no longer referenced by any other nodes in the document.
|
|
507
|
+
*
|
|
508
|
+
* @param {NodeId[]} removed_node_ids - Array of node IDs that were initially removed
|
|
509
|
+
* @private
|
|
510
|
+
*/
|
|
511
|
+
_cascade_delete_unreferenced_nodes(removed_node_ids) {
|
|
512
|
+
/** @type {Record<NodeId, boolean>} */
|
|
513
|
+
const nodes_to_delete = {};
|
|
514
|
+
const to_check = [...removed_node_ids];
|
|
515
|
+
|
|
516
|
+
while (to_check.length > 0) {
|
|
517
|
+
const node_id = to_check.pop();
|
|
518
|
+
|
|
519
|
+
// Skip if node_id is undefined (shouldn't happen since we check length)
|
|
520
|
+
if (!node_id) continue;
|
|
521
|
+
|
|
522
|
+
// Skip if already marked for deletion
|
|
523
|
+
if (nodes_to_delete[node_id]) continue;
|
|
524
|
+
|
|
525
|
+
// Count references excluding nodes already marked for deletion
|
|
526
|
+
const ref_count = this._count_references_excluding_deleted(node_id, nodes_to_delete);
|
|
527
|
+
|
|
528
|
+
if (ref_count === 0) {
|
|
529
|
+
// No more references, safe to delete this node
|
|
530
|
+
nodes_to_delete[node_id] = true;
|
|
531
|
+
|
|
532
|
+
// Also check all nodes referenced by this node
|
|
533
|
+
const referenced_nodes = this.doc.get_referenced_nodes(node_id);
|
|
534
|
+
to_check.push(...referenced_nodes);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Delete all unreferenced nodes
|
|
539
|
+
for (const node_id in nodes_to_delete) {
|
|
540
|
+
this.delete(node_id);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Counts references to a target node, excluding nodes that are marked for deletion.
|
|
546
|
+
*
|
|
547
|
+
* This is used during cascade deletion to determine if a node can be safely
|
|
548
|
+
* deleted without breaking document integrity.
|
|
549
|
+
*
|
|
550
|
+
* @param {NodeId} target_node_id - The node ID to count references for
|
|
551
|
+
* @param {Record<NodeId, boolean>} nodes_to_delete - Nodes already marked for deletion
|
|
552
|
+
* @returns {number} The number of references to the target node
|
|
553
|
+
* @private
|
|
554
|
+
*/
|
|
555
|
+
_count_references_excluding_deleted(target_node_id, nodes_to_delete) {
|
|
556
|
+
let count = 0;
|
|
557
|
+
|
|
558
|
+
for (const node of Object.values(this.doc.nodes)) {
|
|
559
|
+
// Skip nodes that are marked for deletion
|
|
560
|
+
if (nodes_to_delete[node.id]) continue;
|
|
561
|
+
|
|
562
|
+
for (const [property, value] of Object.entries(node)) {
|
|
563
|
+
if (property === 'id' || property === 'type') continue;
|
|
564
|
+
|
|
565
|
+
const prop_type = this.doc.property_type(node.type, property);
|
|
566
|
+
|
|
567
|
+
if (prop_type === 'node_array' && Array.isArray(value)) {
|
|
568
|
+
count += value.filter(id => id === target_node_id).length;
|
|
569
|
+
} else if (prop_type === 'node' && value === target_node_id) {
|
|
570
|
+
count += 1;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return count;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getContext } from 'svelte';
|
|
3
|
+
import Node from './Node.svelte';
|
|
4
|
+
|
|
5
|
+
const svedit = getContext('svedit');
|
|
6
|
+
let { path } = $props();
|
|
7
|
+
let node = $derived(svedit.doc.get(path));
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<Node {path}>
|
|
11
|
+
Unknown: {node.type}.
|
|
12
|
+
</Node>
|