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.
@@ -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
 
@@ -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
+ }