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.
@@ -47,6 +47,7 @@ export default class Transaction {
47
47
  *
48
48
  * @param {DocumentPath} path - Array path to the property (e.g., ["node_1", "title"])
49
49
  * @param {any} value - The new value to set
50
+ * @returns {Transaction} This transaction instance for method chaining
50
51
  *
51
52
  * @example
52
53
  * ```js
@@ -54,7 +55,8 @@ export default class Transaction {
54
55
  * tr.set(["page_1", "body", "0", "description"], {text: "Hello world", annotations: []});
55
56
  * ```
56
57
  */
57
- set(path: DocumentPath, value: any): void;
58
+ set(path: DocumentPath, value: any): Transaction;
59
+ build(node_id: any, nodes: any): any;
58
60
  /**
59
61
  * Creates a new node in the document.
60
62
  *
@@ -124,31 +126,26 @@ export default class Transaction {
124
126
  * Behavior depends on selection type:
125
127
  * - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
126
128
  * - For text selections: Removes selected text and adjusts annotations accordingly
127
- * - For collapsed selections: Deletes the previous character/node
129
+ * - For collapsed selections: Deletes the previous character/node (backward) or next character/node (forward)
128
130
  *
131
+ * @param {'backward' | 'forward'} [direction] - Direction of deletion for collapsed selections
129
132
  * @returns {Transaction} This transaction instance for method chaining
130
133
  */
131
- delete_selection(): Transaction;
134
+ delete_selection(direction?: "backward" | "forward"): Transaction;
132
135
  /**
133
136
  * Inserts an array of nodes at the current node selection.
134
137
  *
135
- * If there's a current selection, it will be deleted first. New nodes are
136
- * created if they don't already exist (e.g., for cut+paste scenarios).
138
+ * If there's a current selection, it will be deleted first.
137
139
  *
138
- * @param {SerializedNode[]} nodes - Array of node objects to insert
140
+ * @param {NodeId[]} node_ids - Array of node IDs to insert
139
141
  * @returns {Transaction} This transaction instance for method chaining
140
142
  *
141
- * @note Assumes we only insert new nodes, not reference existing ones
142
- *
143
143
  * @example
144
144
  * ```js
145
- * tr.insert_nodes([
146
- * {id: 'para_1', type: 'paragraph', content: ['Hello', []]},
147
- * {id: 'para_2', type: 'paragraph', content: ['World', []]}
148
- * ]);
145
+ * tr.insert_nodes_new(['para_1', 'para_2']);
149
146
  * ```
150
147
  */
151
- insert_nodes(nodes: SerializedNode[]): Transaction;
148
+ insert_nodes(node_ids: NodeId[]): Transaction;
152
149
  /**
153
150
  * Inserts text at the current text selection, replacing any selected content.
154
151
  *
@@ -165,7 +162,7 @@ export default class Transaction {
165
162
  * tr.insert_text('Hello, world!');
166
163
  * ```
167
164
  */
168
- insert_text(replaced_text: string): Transaction;
165
+ insert_text(replaced_text: string, annotations?: any[], nodes?: {}): Transaction;
169
166
  /**
170
167
  * Recursively deletes nodes that become unreferenced after the initial deletion.
171
168
  *
@@ -191,5 +188,5 @@ export default class Transaction {
191
188
  }
192
189
  import type { DocumentPath } from './types.js';
193
190
  import type { Selection } from './types.js';
194
- import type { SerializedNode } from './types.js';
191
+ import type { NodeId } from './types.js';
195
192
  //# sourceMappingURL=Transaction.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;GAcG;AACH;IACE;;;;OAIG;IACH,iBAFW,GAAG,EAWb;IARC,SAAc;IAEd,oCAAoC;IACpC,KADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACnB;IACb,oCAAoC;IACpC,aADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACX;IAErB,sBAA0C;IAG5C;;;;;OAKG;IACH,UAHW,YAAY,GACV,GAAG,CAIf;IAED;;;;OAIG;IACH,iBAFa,SAAS,CAIrB;IAED;;;;;;;;;;;;;;OAcG;IACH,UATW,YAAY,SACZ,GAAG,QA2Bb;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAbW,GAAG,GACD,WAAW,CAyBvB;IAED;;;;;;;;;;;;OAYG;IACH,WARW,GAAG,GACD,WAAW,CAcvB;IAED;;;;;;OAMG;IACH,yBAJW,SAAS,GACP,WAAW,CAOvB;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,+BAbW,GAAG,yBACH,GAAG,GACD,WAAW,CAuDvB;IAED;;;;;;;;;OASG;IACH,oBAFa,WAAW,CAyGvB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,oBAbW,cAAc,EAAE,GACd,WAAW,CAqDvB;IAED;;;;;;;;;;;;;;;OAeG;IACH,2BAVW,MAAM,GACJ,WAAW,CA8EvB;IAED;;;;;;;;OAQG;IACH,2CA+BC;IAED;;;;;;;;;;OAUG;IACH,4CAuBC;CACF;kCAxjBmE,YAAY;+BAAZ,YAAY;oCAAZ,YAAY"}
1
+ {"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;;GAcG;AACH;IACE;;;;OAIG;IACH,iBAFW,GAAG,EAWb;IARC,SAAc;IAEd,oCAAoC;IACpC,KADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACnB;IACb,oCAAoC;IACpC,aADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACX;IAErB,sBAA0C;IAG5C;;;;;OAKG;IACH,UAHW,YAAY,GACV,GAAG,CAIf;IAED;;;;OAIG;IACH,iBAFa,SAAS,CAIrB;IAED;;;;;;;;;;;;;;;OAeG;IACH,UAVW,YAAY,SACZ,GAAG,GACD,WAAW,CA4BvB;IAKD,qCAoDC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAbW,GAAG,GACD,WAAW,CAyBvB;IAED;;;;;;;;;;;;OAYG;IACH,WARW,GAAG,GACD,WAAW,CAcvB;IAED;;;;;;OAMG;IACH,yBAJW,SAAS,GACP,WAAW,CAOvB;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,+BAbW,GAAG,yBACH,GAAG,GACD,WAAW,CAuDvB;IAED;;;;;;;;;;OAUG;IACH,6BAHW,UAAU,GAAG,SAAS,GACpB,WAAW,CA6IvB;IAED;;;;;;;;;;;;OAYG;IACH,uBARW,MAAM,EAAE,GACN,WAAW,CAyCvB;IAED;;;;;;;;;;;;;;;OAeG;IACH,2BAVW,MAAM,oCACJ,WAAW,CAwGvB;IAED;;;;;;;;OAQG;IACH,2CA+BC;IAED;;;;;;;;;;OAUG;IACH,4CAuBC;CACF;kCAtqBmD,YAAY;+BAAZ,YAAY;4BAAZ,YAAY"}
@@ -1,8 +1,9 @@
1
1
  /**
2
- * @import { NodeId, SerializedNode, Selection, DocumentPath } from './types.js'
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
- return this; // cursor is at the very beginning, do nothing.
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. New nodes are
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 {SerializedNode[]} nodes - Array of node objects to insert
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.insert_nodes([
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(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 + nodes.length
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
 
@@ -1 +1 @@
1
- {"version":3,"file":"commands.svelte.d.ts","sourceRoot":"","sources":["../src/lib/commands.svelte.js"],"names":[],"mappings":"AAGA,kDAqDC;AAED,iDAsEC;AAED,sDA6BC;AAED,6CA6GC"}
1
+ {"version":3,"file":"commands.svelte.d.ts","sourceRoot":"","sources":["../src/lib/commands.svelte.js"],"names":[],"mappings":"AAGA,kDAqDC;AAED,iDAsEC;AAED,sDAwBC;AAED,6CA6GC"}
@@ -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/dist/util.d.ts CHANGED
@@ -122,5 +122,6 @@ export function join_annotated_text(first_text: AnnotatedText, second_text: Anno
122
122
  * snake_to_pascal('list_item') // Returns: 'ListItem'
123
123
  */
124
124
  export function snake_to_pascal(str: string): string;
125
+ export function traverse(node_id: any, schema: any, nodes: any): any[];
125
126
  import type { AnnotatedText } from './types.d.ts';
126
127
  //# sourceMappingURL=util.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/lib/util.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;;GAaG;AACH,qCARW,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;;GAcG;AACH,iCATW,MAAM,SACN,MAAM,GACJ,MAAM,CAWlB;AAED;;;;;;;;;;;;;;GAcG;AACH,gCATW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,gBACN,MAAM,GACJ,MAAM,CAoBlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,eACN,MAAM,GACJ,MAAM,CAgBlB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,4DAZW,aAAa,eACb,MAAM,GACJ,CAAC,aAAa,EAAE,aAAa,CAAC,CAyC1C;AAGD;;;;;;;;;;;;;GAaG;AACH,gDARW,aAAa,eACb,aAAa,GACX,aAAa,CAqCzB;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;mCAzO6C,cAAc"}
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/lib/util.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;;GAaG;AACH,qCARW,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;;GAcG;AACH,iCATW,MAAM,SACN,MAAM,GACJ,MAAM,CAWlB;AAED;;;;;;;;;;;;;;GAcG;AACH,gCATW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,gBACN,MAAM,GACJ,MAAM,CAoBlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,eACN,MAAM,GACJ,MAAM,CAgBlB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,4DAZW,aAAa,eACb,MAAM,GACJ,CAAC,aAAa,EAAE,aAAa,CAAC,CAyC1C;AAGD;;;;;;;;;;;;;GAaG;AACH,gDARW,aAAa,eACb,aAAa,GACX,aAAa,CAqCzB;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;AAED,uEAgCC;mCA3Q6C,cAAc"}
package/dist/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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -39,27 +39,27 @@
39
39
  "svelte": "^5.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@eslint/compat": "^1.2.5",
43
- "@eslint/js": "^9.18.0",
44
- "@sveltejs/adapter-auto": "^6.0.0",
45
- "@sveltejs/kit": "^2.21.2",
46
- "@sveltejs/package": "^2.0.0",
47
- "@sveltejs/vite-plugin-svelte": "^6.0.0",
48
- "@typescript-eslint/eslint-plugin": "^8.39.1",
49
- "@typescript-eslint/parser": "^8.39.1",
42
+ "@eslint/compat": "^1.4.0",
43
+ "@eslint/js": "^9.36.0",
44
+ "@sveltejs/adapter-auto": "^6.1.0",
45
+ "@sveltejs/kit": "^2.43.7",
46
+ "@sveltejs/package": "^2.5.4",
47
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
48
+ "@typescript-eslint/eslint-plugin": "^8.45.0",
49
+ "@typescript-eslint/parser": "^8.45.0",
50
50
  "@vitest/browser": "^3.2.4",
51
- "eslint": "^9.18.0",
52
- "eslint-config-prettier": "^10.0.1",
53
- "eslint-plugin-svelte": "^3.0.0",
54
- "globals": "^16.0.0",
55
- "prettier": "^3.4.2",
56
- "prettier-plugin-svelte": "^3.3.3",
57
- "publint": "^0.3.2",
58
- "svelte": "^5.0.0",
59
- "svelte-check": "^4.0.0",
60
- "vite": "^7.0.4",
61
- "vitest": "^3.2.3",
51
+ "eslint": "^9.36.0",
52
+ "eslint-config-prettier": "^10.1.8",
53
+ "eslint-plugin-svelte": "^3.12.4",
54
+ "globals": "^16.4.0",
55
+ "prettier": "^3.6.2",
56
+ "prettier-plugin-svelte": "^3.4.0",
57
+ "publint": "^0.3.13",
58
+ "svelte": "^5.39.8",
59
+ "svelte-check": "^4.3.2",
60
+ "vite": "^7.1.7",
61
+ "vitest": "^3.2.4",
62
62
  "vitest-browser-svelte": "^1.1.0",
63
- "nanoid": "^5.1.5"
63
+ "nanoid": "^5.1.6"
64
64
  }
65
65
  }
@@ -1,5 +1,5 @@
1
1
  import Transaction from './Transaction.svelte.js';
2
- import { char_slice, get_char_length } from './util.js';
2
+ import { char_slice, get_char_length, traverse } from './util.js';
3
3
 
4
4
  /**
5
5
  * @import {
@@ -8,13 +8,10 @@ import { char_slice, get_char_length } from './util.js';
8
8
  * Selection,
9
9
  * Annotation,
10
10
  * PrimitiveType,
11
- * DocumentSchemaPrimitive,
12
11
  * NodeProperty,
13
12
  * NodeArrayProperty,
14
13
  * NodeKind,
15
14
  * NodeSchema,
16
- * TextNodeSchema,
17
- * NonTextNodeSchema,
18
15
  * DocumentSchema,
19
16
  * SerializedDocument
20
17
  * } from './types.d.ts';
@@ -315,8 +312,8 @@ export default class Document {
315
312
  get_available_annotation_types() {
316
313
  if (this.selection?.type !== 'text') return [];
317
314
  const path = this.selection.path;
318
- const property_schema = this.inspect(path);
319
- return property_schema.node_types || [];
315
+ const property_definition = this.inspect(path);
316
+ return property_definition.node_types || [];
320
317
  }
321
318
 
322
319
  // Helper function to get the currently selected node
@@ -600,6 +597,47 @@ export default class Document {
600
597
  return this.schema[node.type].kind;
601
598
  }
602
599
 
600
+ /**
601
+ * Determines whether a node type can be inserted at a given selection.
602
+ * @param {string} node_type - The type of node to insert.
603
+ * @param {Selection} [selection] - The selection at which to insert the node.
604
+ * @returns {boolean} True if the node type can be inserted, false otherwise.
605
+ */
606
+ can_insert(node_type, selection = this.selection) {
607
+ if (selection?.type === 'node') {
608
+ const property_definition = this.inspect(selection.path);
609
+ if (property_definition.node_types.includes(node_type)) {
610
+ return true;
611
+ }
612
+ }
613
+
614
+ // No insert position found yet, and root not reached, try one level up if possible
615
+ let next_node_insert_cursor = this.get_next_node_insert_cursor(selection);
616
+ if (!next_node_insert_cursor) return false;
617
+ return this.can_insert(node_type, next_node_insert_cursor);
618
+ }
619
+
620
+ /**
621
+ * Compute next possible insert position from a given selection
622
+ *
623
+ * @param {Selection} [selection] - Reference selection
624
+ * @returns {Selection} True if the paste operation was successful, false otherwise
625
+ */
626
+ get_next_node_insert_cursor(selection = this.selection) {
627
+ // There's no parent path to insert into
628
+ if (!selection || selection.path.length <= 2) {
629
+ return null;
630
+ }
631
+
632
+ const node_offset = parseInt(String(selection.path.at(-2)), 10) + 1;
633
+ return {
634
+ type: 'node',
635
+ path: selection.path.slice(0, -2),
636
+ anchor_offset: node_offset,
637
+ focus_offset: node_offset,
638
+ };
639
+ }
640
+
603
641
  /**
604
642
  * Returns the annotation object that is currently "under the cursor".
605
643
  * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
@@ -628,6 +666,35 @@ export default class Document {
628
666
  }
629
667
  }
630
668
 
669
+ get_selected_annotated_text() {
670
+ if (this.selection?.type !== 'text') return null;
671
+
672
+ const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
673
+ const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
674
+ const annotated_text = this.get(this.selection.path);
675
+ const text = char_slice(annotated_text.text, selection_start, selection_end);
676
+ const nodes = {};
677
+ const annotations = annotated_text.annotations.map(a => {
678
+ if (selection_start < a.end_offset && selection_end > a.start_offset) {
679
+ const sub_graph = this.traverse(a.node_id);
680
+ for (const node of sub_graph) {
681
+ if (!nodes[node.id]) {
682
+ nodes[node.id] = node;
683
+ }
684
+ }
685
+ return {
686
+ start_offset: Math.max(a.start_offset - selection_start, 0),
687
+ end_offset: Math.min(a.end_offset - selection_start, selection_end - selection_start),
688
+ node_id: a.node_id
689
+ };
690
+ } else {
691
+ return null;
692
+ }
693
+ }).filter(Boolean);
694
+
695
+ return { text, annotations, nodes };
696
+ }
697
+
631
698
  // TODO: think about ways how we can also turn a node selection into plain text.
632
699
  get_selected_plain_text() {
633
700
  if (this.selection?.type !== 'text') return null;
@@ -703,37 +770,7 @@ export default class Document {
703
770
  // IMPORTANT: Leaf nodes must go first, branches second and the root node (entry point) last (depth-first traversal)
704
771
  // NOTE: Nodes that are not reachable from the entry point node will not be included in the serialization
705
772
  traverse(node_id) {
706
- const json = [];
707
- const visited = {};
708
- const visit = (node) => {
709
- if (!node || visited[node.id]) {
710
- return;
711
- }
712
- visited[node.id] = true;
713
- for (const [property_name, value] of Object.entries(node)) {
714
- const property_schema = this.schema[node.type].properties[property_name];
715
-
716
- if (property_schema?.type === 'node_array') {
717
- for (const v of value) {
718
- if (typeof v === 'string') {
719
- visit($state.snapshot(this.get(v)));
720
- }
721
- }
722
- } else if (property_schema?.type === 'node') {
723
- visit($state.snapshot(this.get(value)));
724
- } else if (property_schema?.type === 'annotated_text') {
725
- for (const annotation of value.annotations) {
726
- visit($state.snapshot(this.get(annotation.node_id)));
727
- }
728
- }
729
- }
730
- // Finally add the node to the result.
731
- // We use a deep clone, so we make sure nothing of the original document is referenced.
732
- json.push(structuredClone(node));
733
- }
734
- // Start with the root node (document_id)
735
- visit($state.snapshot(this.get(node_id)));
736
- return json;
773
+ return traverse(node_id, this.schema, $state.snapshot(this.nodes));
737
774
  }
738
775
 
739
776
  /**