svedit 0.4.0 → 0.5.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 +4 -3
- package/dist/Document.svelte.d.ts +20 -4
- package/dist/Document.svelte.d.ts.map +1 -1
- package/dist/Document.svelte.js +74 -37
- package/dist/Svedit.svelte +239 -192
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +12 -15
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +137 -27
- package/dist/commands.svelte.d.ts.map +1 -1
- package/dist/commands.svelte.js +1 -6
- package/dist/util.d.ts +1 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +34 -0
- package/package.json +21 -21
- package/src/lib/Document.svelte.js +74 -37
- package/src/lib/Svedit.svelte +239 -192
- package/src/lib/Transaction.svelte.js +137 -27
- package/src/lib/commands.svelte.js +1 -6
- package/src/lib/util.js +34 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { NodeId,
|
|
2
|
+
* @import { NodeId, Selection, DocumentPath } from './types.js'
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { get_char_length, char_slice } from './util.js';
|
|
5
|
+
import { get_char_length, char_slice, traverse } from './util.js';
|
|
6
|
+
import { join_text_node } from './commands.svelte.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Transaction class for managing atomic document operations with undo/redo support.
|
|
@@ -63,6 +64,7 @@ export default class Transaction {
|
|
|
63
64
|
*
|
|
64
65
|
* @param {DocumentPath} path - Array path to the property (e.g., ["node_1", "title"])
|
|
65
66
|
* @param {any} value - The new value to set
|
|
67
|
+
* @returns {Transaction} This transaction instance for method chaining
|
|
66
68
|
*
|
|
67
69
|
* @example
|
|
68
70
|
* ```js
|
|
@@ -89,6 +91,64 @@ export default class Transaction {
|
|
|
89
91
|
this.ops.push(op);
|
|
90
92
|
this.inverse_ops.push(['set', path, previous_value]);
|
|
91
93
|
this.doc._apply_op(op);
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Takes a subgraph and constructs new nodes from it
|
|
98
|
+
// NOTE: all ids will be mapped to new unique ids.
|
|
99
|
+
// NOTE: Omitted properties will be populated with default values.
|
|
100
|
+
build(node_id, nodes) {
|
|
101
|
+
const depth_first_nodes = traverse(node_id, this.doc.schema, nodes);
|
|
102
|
+
// This maps original ids to newly generated ids
|
|
103
|
+
const id_map = {};
|
|
104
|
+
|
|
105
|
+
for (const node of depth_first_nodes) {
|
|
106
|
+
const new_id = this.doc.generate_id();
|
|
107
|
+
id_map[node.id] = new_id;
|
|
108
|
+
const new_node = { ...node, id: new_id };
|
|
109
|
+
const node_schema = this.doc.schema[node.type];
|
|
110
|
+
|
|
111
|
+
// Update all property references to use new IDs
|
|
112
|
+
for (const [property_name, property_definition] of Object.entries(node_schema.properties)) {
|
|
113
|
+
const property_type = property_definition.type;
|
|
114
|
+
const value = node[property_name];
|
|
115
|
+
|
|
116
|
+
// Apply default values
|
|
117
|
+
if (property_type === 'node_array') {
|
|
118
|
+
// [] is the default value for node arrays
|
|
119
|
+
new_node[property_name] = Array.isArray(value) ? value.map(ref_id => id_map[ref_id]) : [];
|
|
120
|
+
} else if (property_type === 'node') {
|
|
121
|
+
// null is the default value for node references
|
|
122
|
+
new_node[property_name] = typeof value === 'string' ? id_map[value] : null;
|
|
123
|
+
} else if (property_type === 'annotated_text') {
|
|
124
|
+
if (value) {
|
|
125
|
+
const annotations = value.annotations.map(annotation => {
|
|
126
|
+
const {start_offset, end_offset, node_id} = annotation;
|
|
127
|
+
return {start_offset, end_offset, node_id: id_map[node_id] || node_id};
|
|
128
|
+
});
|
|
129
|
+
new_node[property_name] = {text: value.text, annotations};
|
|
130
|
+
} else {
|
|
131
|
+
new_node[property_name] = {text: '', annotations: []};
|
|
132
|
+
}
|
|
133
|
+
} else if (property_type === 'string') {
|
|
134
|
+
new_node[property_name] = value ?? property_definition.default ?? '';
|
|
135
|
+
} else if (property_type === 'integer') {
|
|
136
|
+
new_node[property_name] = value ?? property_definition.default ?? 0;
|
|
137
|
+
} else if (property_type === 'number') {
|
|
138
|
+
new_node[property_name] = value ?? property_definition.default ?? 0;
|
|
139
|
+
} else if (property_type === 'boolean') {
|
|
140
|
+
new_node[property_name] = value ?? property_definition.default ?? false;
|
|
141
|
+
} else if (['integer_array', 'number_array'].includes(property_type)) {
|
|
142
|
+
new_node[property_name] = value ?? property_definition.default ?? [];
|
|
143
|
+
} else if (property_type === 'string_array') {
|
|
144
|
+
new_node[property_name] = value ?? property_definition.default ?? [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.create(new_node);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return id_map[depth_first_nodes.at(-1).id];
|
|
92
152
|
}
|
|
93
153
|
|
|
94
154
|
/**
|
|
@@ -232,23 +292,60 @@ export default class Transaction {
|
|
|
232
292
|
* Behavior depends on selection type:
|
|
233
293
|
* - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
|
|
234
294
|
* - For text selections: Removes selected text and adjusts annotations accordingly
|
|
235
|
-
* - For collapsed selections: Deletes the previous character/node
|
|
295
|
+
* - For collapsed selections: Deletes the previous character/node (backward) or next character/node (forward)
|
|
236
296
|
*
|
|
297
|
+
* @param {'backward' | 'forward'} [direction] - Direction of deletion for collapsed selections
|
|
237
298
|
* @returns {Transaction} This transaction instance for method chaining
|
|
238
299
|
*/
|
|
239
|
-
delete_selection() {
|
|
300
|
+
delete_selection(direction = 'backward') {
|
|
240
301
|
if (!this.doc.selection) return this;
|
|
241
302
|
const path = this.doc.selection.path;
|
|
303
|
+
|
|
304
|
+
if (this.doc.selection.type === 'property') {
|
|
305
|
+
this.set(path, '');
|
|
306
|
+
return this;
|
|
307
|
+
}
|
|
308
|
+
|
|
242
309
|
// Get the start and end indices for the selection
|
|
243
310
|
let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
|
|
244
311
|
let end = Math.max(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
|
|
312
|
+
let length;
|
|
313
|
+
|
|
314
|
+
if (this.doc.selection?.type === 'text') {
|
|
315
|
+
const text_content = this.doc.get(this.doc.selection.path).text;
|
|
316
|
+
length = get_char_length(text_content);
|
|
317
|
+
} else if (this.doc.selection?.type === 'node') {
|
|
318
|
+
const node_array = this.doc.get(this.doc.selection.path);
|
|
319
|
+
length = node_array.length;
|
|
320
|
+
}
|
|
245
321
|
|
|
246
|
-
// If selection is collapsed we delete the previous node
|
|
322
|
+
// If selection is collapsed we delete the previous char/node (backward)
|
|
323
|
+
// or the next char/node (forward)
|
|
247
324
|
if (start === end) {
|
|
248
|
-
if (start > 0) {
|
|
325
|
+
if (direction === 'backward' && start > 0) {
|
|
249
326
|
start = start - 1;
|
|
250
|
-
} else {
|
|
251
|
-
|
|
327
|
+
} else if (direction === 'forward' && end < length) {
|
|
328
|
+
end = end + 1;
|
|
329
|
+
} else if (direction === 'backward' && start === 0) {
|
|
330
|
+
join_text_node(this);
|
|
331
|
+
return this;
|
|
332
|
+
} else if (direction === 'forward' && end === length) {
|
|
333
|
+
// At end of text - try to join with next text node
|
|
334
|
+
const node_index = parseInt(String(this.doc.selection.path.at(-2)), 10);
|
|
335
|
+
const successor_node = this.doc.get([...this.doc.selection.path.slice(0, -2), node_index + 1]);
|
|
336
|
+
// Check if next node is a text node
|
|
337
|
+
if (successor_node && this.doc.kind(successor_node) === 'text') {
|
|
338
|
+
// Set selection to beginning of next text node
|
|
339
|
+
this.set_selection({
|
|
340
|
+
type: 'text',
|
|
341
|
+
path: [...this.doc.selection.path.slice(0, -2), node_index + 1, 'content'],
|
|
342
|
+
anchor_offset: 0,
|
|
343
|
+
focus_offset: 0
|
|
344
|
+
});
|
|
345
|
+
// Use join_text_node to merge with previous node
|
|
346
|
+
join_text_node(this);
|
|
347
|
+
}
|
|
348
|
+
return this;
|
|
252
349
|
}
|
|
253
350
|
}
|
|
254
351
|
|
|
@@ -344,23 +441,17 @@ export default class Transaction {
|
|
|
344
441
|
/**
|
|
345
442
|
* Inserts an array of nodes at the current node selection.
|
|
346
443
|
*
|
|
347
|
-
* If there's a current selection, it will be deleted first.
|
|
348
|
-
* created if they don't already exist (e.g., for cut+paste scenarios).
|
|
444
|
+
* If there's a current selection, it will be deleted first.
|
|
349
445
|
*
|
|
350
|
-
* @param {
|
|
446
|
+
* @param {NodeId[]} node_ids - Array of node IDs to insert
|
|
351
447
|
* @returns {Transaction} This transaction instance for method chaining
|
|
352
448
|
*
|
|
353
|
-
* @note Assumes we only insert new nodes, not reference existing ones
|
|
354
|
-
*
|
|
355
449
|
* @example
|
|
356
450
|
* ```js
|
|
357
|
-
* tr.
|
|
358
|
-
* {id: 'para_1', type: 'paragraph', content: ['Hello', []]},
|
|
359
|
-
* {id: 'para_2', type: 'paragraph', content: ['World', []]}
|
|
360
|
-
* ]);
|
|
451
|
+
* tr.insert_nodes_new(['para_1', 'para_2']);
|
|
361
452
|
* ```
|
|
362
453
|
*/
|
|
363
|
-
insert_nodes(
|
|
454
|
+
insert_nodes(node_ids) {
|
|
364
455
|
if (this.doc.selection.type !== 'node') return this;
|
|
365
456
|
|
|
366
457
|
// Unless cursor is collapsed, delete the selected nodes as a first step
|
|
@@ -382,13 +473,6 @@ export default class Transaction {
|
|
|
382
473
|
node_array.splice(start, end - start);
|
|
383
474
|
}
|
|
384
475
|
|
|
385
|
-
// First create the new nodes (unless they already exists e.g. in case of cut+paste)
|
|
386
|
-
nodes.forEach(node => {
|
|
387
|
-
if (!this.doc.get(node.id)) {
|
|
388
|
-
this.create(node);
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
const node_ids = nodes.map(node => node.id);
|
|
392
476
|
node_array.splice(start, 0, ...node_ids);
|
|
393
477
|
|
|
394
478
|
// Update the node_array in the entry
|
|
@@ -398,7 +482,7 @@ export default class Transaction {
|
|
|
398
482
|
type: 'node',
|
|
399
483
|
path: [...this.doc.selection.path],
|
|
400
484
|
anchor_offset: start,
|
|
401
|
-
focus_offset: start +
|
|
485
|
+
focus_offset: start + node_ids.length
|
|
402
486
|
};
|
|
403
487
|
return this;
|
|
404
488
|
}
|
|
@@ -419,7 +503,7 @@ export default class Transaction {
|
|
|
419
503
|
* tr.insert_text('Hello, world!');
|
|
420
504
|
* ```
|
|
421
505
|
*/
|
|
422
|
-
insert_text(replaced_text) {
|
|
506
|
+
insert_text(replaced_text, annotations = [], nodes = {}) {
|
|
423
507
|
if (this.doc.selection.type !== 'text') return this;
|
|
424
508
|
|
|
425
509
|
const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
|
|
@@ -487,6 +571,32 @@ export default class Transaction {
|
|
|
487
571
|
focus_offset: start + get_char_length(replaced_text),
|
|
488
572
|
};
|
|
489
573
|
this.doc.selection = new_selection;
|
|
574
|
+
|
|
575
|
+
// Now we apply annotations if there are any, but only if there's no active annotation
|
|
576
|
+
// at the current collapsed cursor
|
|
577
|
+
if (!this.doc.active_annotation() && annotations.length > 0) {
|
|
578
|
+
const new_annotations = annotations.map(annotation => {
|
|
579
|
+
const original_annotation_node = nodes[annotation.node_id];
|
|
580
|
+
const text_property_definition = this.doc.inspect(this.doc.selection.path);
|
|
581
|
+
// console.log('original_annotation_node.type', original_annotation_node.type);
|
|
582
|
+
// console.log('text_property_definition', text_property_definition);
|
|
583
|
+
if (text_property_definition.node_types.includes(original_annotation_node.type)) {
|
|
584
|
+
const new_annotation_node_id = this.build(annotation.node_id, nodes);
|
|
585
|
+
return {
|
|
586
|
+
start_offset: start + annotation.start_offset,
|
|
587
|
+
end_offset: start + annotation.end_offset,
|
|
588
|
+
node_id: new_annotation_node_id
|
|
589
|
+
};
|
|
590
|
+
} else {
|
|
591
|
+
// Annotation type not allowed in new context, skip it.
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}).filter(Boolean);
|
|
595
|
+
const next_annotated_text = structuredClone(annotated_text);
|
|
596
|
+
next_annotated_text.annotations = next_annotated_text.annotations.concat(new_annotations);
|
|
597
|
+
this.set(this.doc.selection.path, next_annotated_text); // this will update the current state and create a history entry
|
|
598
|
+
}
|
|
599
|
+
|
|
490
600
|
return this;
|
|
491
601
|
}
|
|
492
602
|
|
|
@@ -117,7 +117,7 @@ export function join_text_node(tr) {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
tr.delete_selection();
|
|
120
|
-
|
|
120
|
+
|
|
121
121
|
// Finally set the cursor position at the join point using the pre-calculated position
|
|
122
122
|
tr.set_selection({
|
|
123
123
|
type: 'text',
|
|
@@ -145,11 +145,6 @@ export function insert_default_node(tr) {
|
|
|
145
145
|
const property_definition = doc.schema[node_array_node.type].properties[property_name];
|
|
146
146
|
const default_type = get_default_node_type(property_definition);
|
|
147
147
|
|
|
148
|
-
// Only proceed if there's exactly one allowed node_type
|
|
149
|
-
// if (!default_type || property_schema.node_types.length !== 1) {
|
|
150
|
-
// return false;
|
|
151
|
-
// }
|
|
152
|
-
|
|
153
148
|
// Use the inserter function if available
|
|
154
149
|
if (doc.config?.inserters?.[default_type]) {
|
|
155
150
|
doc.config.inserters[default_type](tr);
|
package/src/lib/util.js
CHANGED
|
@@ -233,3 +233,37 @@ export function snake_to_pascal(str) {
|
|
|
233
233
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
234
234
|
.join('');
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
export function traverse(node_id, schema, nodes) {
|
|
238
|
+
const json = [];
|
|
239
|
+
const visited = {};
|
|
240
|
+
const visit = (node) => {
|
|
241
|
+
if (!node || visited[node.id]) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
visited[node.id] = true;
|
|
245
|
+
for (const [property_name, value] of Object.entries(node)) {
|
|
246
|
+
const property_definition = schema[node.type].properties[property_name];
|
|
247
|
+
|
|
248
|
+
if (property_definition?.type === 'node_array') {
|
|
249
|
+
for (const v of value) {
|
|
250
|
+
if (typeof v === 'string') {
|
|
251
|
+
visit(nodes[v]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} else if (property_definition?.type === 'node') {
|
|
255
|
+
visit(nodes[value]);
|
|
256
|
+
} else if (property_definition?.type === 'annotated_text') {
|
|
257
|
+
for (const annotation of value.annotations) {
|
|
258
|
+
visit(nodes[annotation.node_id]);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Finally add the node to the result.
|
|
263
|
+
// Deep clone, to make sure nothing of the original document is referenced.
|
|
264
|
+
json.push(structuredClone(node));
|
|
265
|
+
}
|
|
266
|
+
// Start with the root node (document_id)
|
|
267
|
+
visit(nodes[node_id]);
|
|
268
|
+
return json;
|
|
269
|
+
}
|