svedit 0.3.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,11 +64,12 @@ 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
69
71
  * tr.set(["list_1", "list_items"], [1, 2, 3]);
70
- * tr.set(["page_1", "body", "0", "description"], ["Hello world", []]);
72
+ * tr.set(["page_1", "body", "0", "description"], {text: "Hello world", annotations: []});
71
73
  * ```
72
74
  */
73
75
  set (path, value) {
@@ -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
  /**
@@ -152,10 +212,10 @@ export default class Transaction {
152
212
  *
153
213
  * @param {Selection} selection - The new selection object
154
214
  * @returns {Transaction} This transaction instance for method chaining
155
- * @todo Check if selection is valid and throw error if not
215
+ * @throws {Error} Throws if the selection is invalid or out of bounds
156
216
  */
157
217
  set_selection(selection) {
158
- // TODO: Check if selection is valid and throw error if not
218
+ this.doc._validate_selection(selection);
159
219
  this.doc.selection = selection;
160
220
  return this;
161
221
  }
@@ -168,7 +228,7 @@ export default class Transaction {
168
228
  * when conflicting types are applied.
169
229
  *
170
230
  * @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
171
- * @param {any} annotation_data - Additional data for the annotation (e.g., href for links)
231
+ * @param {any} annotation_properties - Additional data for the annotation (e.g., href for links)
172
232
  * @returns {Transaction} This transaction instance for method chaining
173
233
  *
174
234
  * @example
@@ -180,64 +240,25 @@ export default class Transaction {
180
240
  * tr.annotate_text('emphasis', {});
181
241
  * ```
182
242
  */
183
- annotate_text(annotation_type, annotation_data) {
243
+ annotate_text(annotation_type, annotation_properties) {
184
244
  if (this.doc.selection.type !== 'text') return this;
185
245
 
186
246
  const { start, end } = this.doc.get_selection_range();
187
- const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
188
- const annotations = annotated_string[1];
189
- const existing_annotations = this.doc.active_annotation();
190
-
191
- // Special annotation type handling should probably be done in a separate function.
192
- // The goal is to keep the core logic simple and allow developer to extend and pick only what they need.
193
- // It could also be abstracted to not check for type (e.g. "link") but for a special attribute
194
- // e.g. "zero-range-updatable" for annotations that are updatable without a range selection change.
195
-
196
- // Special handling for links when there's no selection range
197
- // Links should be updatable by just clicking on them without a range selection
198
- if (annotation_type === 'link' && start === end && existing_annotations) {
199
-
200
- // Use findIndex for deep comparison of annotation properties (comparison of annotation properties rather than object reference via indexOf)
201
- const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
202
- anno[0] === existing_annotations[0] &&
203
- anno[1] === existing_annotations[1] &&
204
- anno[2] === existing_annotations[2]
205
- );
206
- // const index = annotations.indexOf(existing_annotations);
207
-
208
- if (index !== -1) {
209
- if (annotation_data.href === '') {
210
- // Remove the annotation if the href is empty
211
- annotations.splice(index, 1);
212
- } else {
213
- annotations[index] = [
214
- existing_annotations[0],
215
- existing_annotations[1],
216
- 'link',
217
- { ...existing_annotations[3], ...annotation_data }
218
- ];
219
- }
220
-
221
- this.set(this.doc.selection.path, annotated_string);
222
- return this;
223
- }
224
- }
247
+ const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
248
+ const annotations = annotated_text.annotations;
249
+ const existing_annotation = this.doc.active_annotation();
250
+ const existing_annotation_same_type = this.doc.active_annotation(annotation_type);
225
251
 
226
- // Regular annotation handling
227
- if (start === end) {
228
- // For non-link annotations: You can not annotate text if the selection is collapsed.
229
- return this;
230
- }
231
-
232
- if (existing_annotations) {
252
+ if (existing_annotation) {
233
253
  // If there's an existing annotation of the same type, remove it
234
- if (existing_annotations[2] === annotation_type) {
254
+ if (existing_annotation_same_type) {
235
255
  const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
236
- anno[0] === existing_annotations[0] &&
237
- anno[1] === existing_annotations[1] &&
238
- anno[2] === existing_annotations[2]
256
+ anno.start_offset === existing_annotation.start_offset &&
257
+ anno.end_offset === existing_annotation.end_offset
239
258
  );
240
259
  if (index !== -1) {
260
+ // Remove the annotation node from the graph
261
+ this.delete(annotations[index].node_id);
241
262
  annotations.splice(index, 1);
242
263
  }
243
264
  } else {
@@ -245,14 +266,23 @@ export default class Transaction {
245
266
  return this;
246
267
  }
247
268
  } else {
269
+ if (!this.doc.available_annotation_types.includes(annotation_type)) {
270
+ console.log(`Annotation type ${annotation_type} is not allowed here.`);
271
+ return this;
272
+ }
273
+
274
+ const new_annotation_node = {
275
+ id: this.doc.generate_id(),
276
+ type: annotation_type,
277
+ ...annotation_properties,
278
+ }
279
+ this.create(new_annotation_node);
248
280
  // If there's no existing annotation, add the new one
249
- annotations.push([start, end, annotation_type, annotation_data]);
281
+ annotations.push({start_offset: start, end_offset: end, node_id: new_annotation_node.id});
250
282
  }
251
283
 
252
- // Update the annotated string
253
- this.set(this.doc.selection.path, annotated_string);
254
- // Not needed anymore as doc.apply makes sure selection is rerendered
255
- // this.doc.selection = { ...this.doc.selection };
284
+ // Update the annotated text
285
+ this.set(this.doc.selection.path, annotated_text);
256
286
  return this;
257
287
  }
258
288
 
@@ -262,23 +292,60 @@ export default class Transaction {
262
292
  * Behavior depends on selection type:
263
293
  * - For node selections: Removes selected nodes and cascades deletion of unreferenced nodes
264
294
  * - For text selections: Removes selected text and adjusts annotations accordingly
265
- * - For collapsed selections: Deletes the previous character/node
295
+ * - For collapsed selections: Deletes the previous character/node (backward) or next character/node (forward)
266
296
  *
297
+ * @param {'backward' | 'forward'} [direction] - Direction of deletion for collapsed selections
267
298
  * @returns {Transaction} This transaction instance for method chaining
268
299
  */
269
- delete_selection() {
300
+ delete_selection(direction = 'backward') {
270
301
  if (!this.doc.selection) return this;
271
302
  const path = this.doc.selection.path;
303
+
304
+ if (this.doc.selection.type === 'property') {
305
+ this.set(path, '');
306
+ return this;
307
+ }
308
+
272
309
  // Get the start and end indices for the selection
273
310
  let start = Math.min(this.doc.selection.anchor_offset, this.doc.selection.focus_offset);
274
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
+ }
275
321
 
276
- // 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)
277
324
  if (start === end) {
278
- if (start > 0) {
325
+ if (direction === 'backward' && start > 0) {
279
326
  start = start - 1;
280
- } else {
281
- 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;
282
349
  }
283
350
  }
284
351
 
@@ -309,13 +376,15 @@ export default class Transaction {
309
376
  let text = structuredClone($state.snapshot(this.doc.get(path)));
310
377
 
311
378
  // Update the text content using character-based operations
312
- const original_text = text[0];
313
- text[0] = char_slice(original_text, 0, start) + char_slice(original_text, end, get_char_length(original_text));
379
+ const original_text = text.text;
380
+ text.text = char_slice(original_text, 0, start) + char_slice(original_text, end, get_char_length(original_text));
314
381
 
382
+ // To mark annotation nodes for deletion.
383
+ const _deleted_nodes = [];
315
384
  // Update annotation offsets for deletion
316
385
  const deletion_length = end - start;
317
- const new_annotations = text[1].map(/** @param {any} annotation */ (annotation) => {
318
- const [annotation_start, annotation_end, type, annotation_data] = annotation;
386
+ const new_annotations = text.annotations.map(/** @param {any} annotation */ (annotation) => {
387
+ const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
319
388
 
320
389
  // Adjust start offset
321
390
  let new_start = annotation_start;
@@ -341,16 +410,24 @@ export default class Transaction {
341
410
 
342
411
  // Remove annotation if it becomes invalid (start >= end)
343
412
  if (new_start >= new_end) {
413
+ console.log('deleting annotation');
414
+ _deleted_nodes.push(node_id);
344
415
  return false;
345
416
  }
346
417
 
347
- return [new_start, new_end, type, annotation_data];
418
+ return {start_offset: new_start, end_offset: new_end, node_id};
348
419
  }).filter(Boolean);
349
420
 
350
- text[1] = new_annotations;
421
+ text.annotations = new_annotations;
351
422
 
352
423
  // Update the text in the entry (this implicitly records an op via this.set)
353
424
  this.set(path, text);
425
+
426
+ // Remove annotation nodes that were implicitly deleted.
427
+ for (const node_id of _deleted_nodes) {
428
+ this.delete(node_id);
429
+ }
430
+
354
431
  this.set_selection({
355
432
  type: 'text',
356
433
  path,
@@ -364,23 +441,17 @@ export default class Transaction {
364
441
  /**
365
442
  * Inserts an array of nodes at the current node selection.
366
443
  *
367
- * If there's a current selection, it will be deleted first. New nodes are
368
- * 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.
369
445
  *
370
- * @param {SerializedNode[]} nodes - Array of node objects to insert
446
+ * @param {NodeId[]} node_ids - Array of node IDs to insert
371
447
  * @returns {Transaction} This transaction instance for method chaining
372
448
  *
373
- * @note Assumes we only insert new nodes, not reference existing ones
374
- *
375
449
  * @example
376
450
  * ```js
377
- * tr.insert_nodes([
378
- * {id: 'para_1', type: 'paragraph', content: ['Hello', []]},
379
- * {id: 'para_2', type: 'paragraph', content: ['World', []]}
380
- * ]);
451
+ * tr.insert_nodes_new(['para_1', 'para_2']);
381
452
  * ```
382
453
  */
383
- insert_nodes(nodes) {
454
+ insert_nodes(node_ids) {
384
455
  if (this.doc.selection.type !== 'node') return this;
385
456
 
386
457
  // Unless cursor is collapsed, delete the selected nodes as a first step
@@ -402,13 +473,6 @@ export default class Transaction {
402
473
  node_array.splice(start, end - start);
403
474
  }
404
475
 
405
- // First create the new nodes (unless they already exists e.g. in case of cut+paste)
406
- nodes.forEach(node => {
407
- if (!this.doc.get(node.id)) {
408
- this.create(node);
409
- }
410
- });
411
- const node_ids = nodes.map(node => node.id);
412
476
  node_array.splice(start, 0, ...node_ids);
413
477
 
414
478
  // Update the node_array in the entry
@@ -418,7 +482,7 @@ export default class Transaction {
418
482
  type: 'node',
419
483
  path: [...this.doc.selection.path],
420
484
  anchor_offset: start,
421
- focus_offset: start + nodes.length
485
+ focus_offset: start + node_ids.length
422
486
  };
423
487
  return this;
424
488
  }
@@ -439,21 +503,21 @@ export default class Transaction {
439
503
  * tr.insert_text('Hello, world!');
440
504
  * ```
441
505
  */
442
- insert_text(replaced_text) {
506
+ insert_text(replaced_text, annotations = [], nodes = {}) {
443
507
  if (this.doc.selection.type !== 'text') return this;
444
508
 
445
- const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
509
+ const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
446
510
  const { start, end } = this.doc.get_selection_range();
447
511
 
448
512
  // Transform the plain text string using character-based operations
449
- const text = annotated_string[0];
450
- annotated_string[0] = char_slice(text, 0, start) + replaced_text + char_slice(text, end, get_char_length(text));
513
+ const text = annotated_text.text;
514
+ annotated_text.text = char_slice(text, 0, start) + replaced_text + char_slice(text, end, get_char_length(text));
451
515
 
452
- // Transform the annotations (annotated_string[1])
453
- // NOTE: Annotations are stored as [start_offset, end_offset, type]
516
+ // Transform the annotations (annotated_text.annotations)
517
+ // NOTE: Annotations are stored as {start_offset, end_offset, node_id}
454
518
  const delta = get_char_length(replaced_text) - (end - start);
455
- const new_annotations = annotated_string[1].map(/** @param {any} annotation */ (annotation) => {
456
- const [annotation_start, annotation_end, type, annotation_data] = annotation;
519
+ const new_annotations = annotated_text.annotations.map(/** @param {any} annotation */ (annotation) => {
520
+ const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
457
521
 
458
522
  // Case 1: annotation is wrapped in start and end (remove it)
459
523
  if (start <= annotation_start && end >= annotation_end) {
@@ -462,7 +526,7 @@ export default class Transaction {
462
526
 
463
527
  // Case 2: text inserted before the annotation
464
528
  if (end <= annotation_start) {
465
- return [annotation_start + delta, annotation_end + delta, type, annotation_data];
529
+ return {start_offset: annotation_start + delta, end_offset: annotation_end + delta, node_id};
466
530
  }
467
531
 
468
532
  // Case 3: text inserted after the annotation
@@ -474,19 +538,19 @@ export default class Transaction {
474
538
  // NOTE: replaced_text will not be part of the annotation, we treat it the same as
475
539
  // a cursor right after the annotation
476
540
  if (start > annotation_start && start < annotation_end && end >= annotation_end) {
477
- return [annotation_start, start, type, annotation_data];
541
+ return {start_offset: annotation_start, end_offset: start, node_id};
478
542
  }
479
543
 
480
544
  // Case 5: text inserted inside an annotation
481
545
  if (start >= annotation_start && start <= annotation_end && end < annotation_end && end >= annotation_start) {
482
- return [annotation_start, annotation_end + delta, type, annotation_data];
546
+ return {start_offset: annotation_start, end_offset: annotation_end + delta, node_id};
483
547
  }
484
548
 
485
549
  // Case 6: annotation is partly selected towards left
486
550
  // NOTE: replaced_text will not be part of the annotation, we treat it the same as
487
551
  // a cursor right before the annotation
488
552
  if (start < annotation_start && end > annotation_start && end < annotation_end) {
489
- return [start + get_char_length(replaced_text), annotation_end + delta, type, annotation_data];
553
+ return {start_offset: start + get_char_length(replaced_text), end_offset: annotation_end + delta, node_id};
490
554
  }
491
555
 
492
556
  // Unhandled edge case:
@@ -496,7 +560,8 @@ export default class Transaction {
496
560
  // return annotation;
497
561
  }).filter(Boolean);
498
562
 
499
- this.set(this.doc.selection.path, [annotated_string[0], new_annotations]); // this will update the current state and create a history entry
563
+ annotated_text.annotations = new_annotations;
564
+ this.set(this.doc.selection.path, annotated_text); // this will update the current state and create a history entry
500
565
 
501
566
  // Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
502
567
  const new_selection = {
@@ -506,6 +571,32 @@ export default class Transaction {
506
571
  focus_offset: start + get_char_length(replaced_text),
507
572
  };
508
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
+
509
600
  return this;
510
601
  }
511
602
 
@@ -578,6 +669,8 @@ export default class Transaction {
578
669
  count += value.filter(id => id === target_node_id).length;
579
670
  } else if (prop_type === 'node' && value === target_node_id) {
580
671
  count += 1;
672
+ } else if (prop_type === 'annotated_text' && value && value.annotations) {
673
+ count += value.annotations.filter(annotation => annotation.node_id === target_node_id).length;
581
674
  }
582
675
  }
583
676
  }
@@ -1,4 +1,4 @@
1
- import { split_annotated_string, join_annotated_string, get_char_length } from './util.js';
1
+ import { split_annotated_text, join_annotated_text, get_char_length } from './util.js';
2
2
  import { get_default_node_type } from './Document.svelte.js';
3
3
 
4
4
  export function break_text_node(tr) {
@@ -30,7 +30,7 @@ export function break_text_node(tr) {
30
30
 
31
31
  const split_at_position = tr.doc.selection.anchor_offset;
32
32
  const content = tr.doc.get(selection.path);
33
- const [left_text, right_text] = split_annotated_string(content, split_at_position);
33
+ const [left_text, right_text] = split_annotated_text(content, split_at_position);
34
34
 
35
35
  tr.set([node.id, 'content'], left_text);
36
36
 
@@ -42,7 +42,7 @@ export function break_text_node(tr) {
42
42
  };
43
43
 
44
44
  // TODO: Only use default_node_type when cursor is at the end of
45
- const node_array_property_definition = doc.schema[node_array_node.type][node_array_prop];
45
+ const node_array_property_definition = doc.schema[node_array_node.type].properties[node_array_prop];
46
46
  const target_node_type = get_default_node_type(node_array_property_definition);
47
47
 
48
48
  if (!target_node_type) {
@@ -82,7 +82,7 @@ export function join_text_node(tr) {
82
82
  }
83
83
 
84
84
  // Special behavior: if we can't join and current node is empty, delete it
85
- if (!can_join && node.content[0] === '') {
85
+ if (!can_join && node.content.text === '') {
86
86
  tr.set_selection({
87
87
  type: 'node',
88
88
  path: doc.selection.path.slice(0, -2),
@@ -100,8 +100,15 @@ export function join_text_node(tr) {
100
100
 
101
101
  // Normal joining logic - both nodes are text nodes
102
102
  const previous_text_path = [...doc.selection.path.slice(0, -2), node_index - 1];
103
- const joined_text = join_annotated_string(predecessor_node.content, node.content);
103
+ const joined_text = join_annotated_text(predecessor_node.content, node.content);
104
104
 
105
+ // Calculate cursor position based on original predecessor content length
106
+ const cursor_position = get_char_length(predecessor_node.content.text);
107
+
108
+ // First set the joined content on the predecessor node (preserves annotations)
109
+ tr.set([predecessor_node.id, 'content'], joined_text);
110
+
111
+ // Then delete the current node
105
112
  tr.set_selection({
106
113
  type: 'node',
107
114
  path: doc.selection.path.slice(0, -2),
@@ -110,13 +117,14 @@ export function join_text_node(tr) {
110
117
  });
111
118
 
112
119
  tr.delete_selection();
120
+
121
+ // Finally set the cursor position at the join point using the pre-calculated position
113
122
  tr.set_selection({
114
123
  type: 'text',
115
124
  path: [...previous_text_path, 'content'],
116
- anchor_offset: get_char_length(predecessor_node.content[0]),
117
- focus_offset: get_char_length(predecessor_node.content[0]),
125
+ anchor_offset: cursor_position,
126
+ focus_offset: cursor_position,
118
127
  });
119
- tr.set([predecessor_node.id, 'content'], joined_text);
120
128
  return true;
121
129
  }
122
130
 
@@ -134,14 +142,9 @@ export function insert_default_node(tr) {
134
142
  const property_name = path.at(-1);
135
143
 
136
144
  // Get the definition for this property
137
- const property_definition = doc.schema[node_array_node.type][property_name];
145
+ const property_definition = doc.schema[node_array_node.type].properties[property_name];
138
146
  const default_type = get_default_node_type(property_definition);
139
147
 
140
- // Only proceed if there's exactly one allowed node_type
141
- // if (!default_type || property_schema.node_types.length !== 1) {
142
- // return false;
143
- // }
144
-
145
148
  // Use the inserter function if available
146
149
  if (doc.config?.inserters?.[default_type]) {
147
150
  doc.config.inserters[default_type](tr);
@@ -161,7 +164,7 @@ export function select_all(tr) {
161
164
 
162
165
  if (selection.type === 'text') {
163
166
  const text_content = doc.get(selection.path);
164
- const text_length = get_char_length(text_content[0]);
167
+ const text_length = get_char_length(text_content.text);
165
168
 
166
169
  // Check if all text is already selected
167
170
  const is_all_text_selected =
package/src/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Main exports for the svedit library
2
2
  export { default as Svedit } from './Svedit.svelte';
3
- export { default as AnnotatedStringProperty } from './AnnotatedStringProperty.svelte';
3
+ export { default as AnnotatedTextProperty } from './AnnotatedTextProperty.svelte';
4
4
  export { default as CustomProperty } from './CustomProperty.svelte';
5
5
  export { default as Node } from './Node.svelte';
6
6
  export { default as NodeArrayProperty } from './NodeArrayProperty.svelte';