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,12 +1,12 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
- import { char_slice, get_char_length } from './util.js';
3
+ import { char_slice, get_char_length, snake_to_pascal } from './util.js';
4
4
 
5
- /** @import { AnnotatedStringPropertyProps, Annotation, AnnotationFragment } from './types.d.ts'; */
5
+ /** @import { AnnotatedTextPropertyProps, Annotation, AnnotationFragment } from './types.d.ts'; */
6
6
 
7
7
  const svedit = getContext('svedit');
8
8
 
9
- /** @type {AnnotatedStringPropertyProps} */
9
+ /** @type {AnnotatedTextPropertyProps} */
10
10
  let {
11
11
  path,
12
12
  class: css_class,
@@ -20,32 +20,36 @@
20
20
  /**
21
21
  * Converts text with annotations into renderable fragments for display.
22
22
  * @param {string} text - The plain text content
23
- * @param {Array<Annotation>} annotations - Array of annotations where each is [start_offset, end_offset, type, options?] (minimum 3 elements)
23
+ * @param {Array<Annotation>} annotations - Array of annotations where each is {start_offset, end_offset, node_id}
24
24
  * @returns {Array<string|AnnotationFragment>} Array of fragments - strings for plain text, AnnotationFragment objects for annotated content
25
25
  */
26
- function render_annotated_string(text, annotations) {
26
+ function get_fragments(text, annotations) {
27
27
  let fragments = [];
28
28
  let last_index = 0;
29
29
 
30
30
  // Sort annotations by start_offset
31
- const sorted_annotations = [...annotations].sort((a, b) => a[0] - b[0]);
31
+ const sorted_annotations = [...annotations].sort((a, b) => a.start_offset - b.start_offset);
32
32
 
33
- for (let [index, annotation] of sorted_annotations.entries()) {
33
+ for (let [, annotation] of sorted_annotations.entries()) {
34
34
  // Add text before the annotation using character-aware slicing
35
- if (annotation[0] > last_index) {
36
- fragments.push(char_slice(text, last_index, annotation[0]));
35
+ if (annotation.start_offset > last_index) {
36
+ fragments.push(char_slice(text, last_index, annotation.start_offset));
37
37
  }
38
38
 
39
39
  // Add the annotated string using character-aware slicing
40
- const annotated_content = char_slice(text, annotation[0], annotation[1]);
40
+ const annotated_content = char_slice(text, annotation.start_offset, annotation.end_offset);
41
+ const node = svedit.doc.get(annotation.node_id);
42
+ if (!node) throw new Error(`Node not found for annotation ${annotation.node_id}`);
43
+
41
44
  fragments.push({
42
- type: annotation[2],
45
+ node,
43
46
  content: annotated_content,
44
- annotation_index: index,
45
- data: annotation[3]
47
+ // NOTE: We need to provide the original index here, because the source data
48
+ // is the address space.
49
+ annotation_index: annotations.indexOf(annotation),
46
50
  });
47
51
 
48
- last_index = annotation[1];
52
+ last_index = annotation.end_offset;
49
53
  }
50
54
 
51
55
  // Add any remaining text after the last annotation using character-aware slicing
@@ -56,15 +60,9 @@
56
60
  return fragments;
57
61
  }
58
62
 
59
- let fragments = $derived(render_annotated_string(svedit.doc.get(path)[0], svedit.doc.get(path)[1]));
60
- let plain_text = $derived(svedit.doc.get(path)[0]);
63
+ let fragments = $derived(get_fragments(svedit.doc.get(path).text, svedit.doc.get(path).annotations));
64
+ let plain_text = $derived(svedit.doc.get(path).text);
61
65
 
62
- /**
63
- * @param {MouseEvent} e - The click event
64
- */
65
- function handle_link_click(e) {
66
- e.preventDefault();
67
- }
68
66
  </script>
69
67
 
70
68
  {#key plain_text}
@@ -79,21 +77,10 @@
79
77
  placeholder={placeholder}
80
78
  >
81
79
  {#each fragments as fragment, index (index)}
82
- {#if typeof fragment === 'string'}<!--
83
- -->{fragment}<!--
84
- -->{:else if fragment.type === 'emphasis'}<!--
85
- --><em>{fragment.content}</em><!--
86
- -->{:else if fragment.type === 'strong'}<!--
87
- --><strong>{fragment.content}</strong><!--
88
- -->{:else if fragment.type === 'link'}<!--
89
- --><a
90
- onclick={handle_link_click}
91
- style="anchor-name: --{path.join('-') + '-' + fragment.annotation_index};"
92
- href={fragment.data.href}
93
- target={fragment.data.target || '_self'}>{fragment.content}</a><!--
94
- -->{:else}<!--
95
- -->{fragment.content}<!--
96
- -->{/if}
80
+ {#if typeof fragment === 'string'}{fragment}{:else}
81
+ {@const AnnotationComponent = svedit.doc.config.node_components[snake_to_pascal(fragment.node.type)]}
82
+ <AnnotationComponent path={[...path, 'annotations', fragment.annotation_index, 'node_id']} content={fragment.content} />
83
+ {/if}
97
84
  {/each}<!--
98
85
  --><br>
99
86
  </div>
@@ -118,12 +105,13 @@
118
105
 
119
106
  /* We can safely hide the <br> element when the placeholder is empty and focused. */
120
107
  /* No longer the cursor will be rendered after the placeholder when focused. */
108
+ /* ATTENTION: This hack has the side effect that in Safari Desktop the cursor sometimes disappears. */
121
109
  [placeholder].empty.focused br {
122
110
  display:none;
123
111
  }
124
112
 
125
- .text.focused {
113
+ /*.text.focused {
126
114
  background: none;
127
- outline: 1px dashed var(--editing-stroke-color);
128
- }
115
+ outline: 1px solid var(--editing-stroke-color);
116
+ }*/
129
117
  </style>
@@ -1,5 +1,5 @@
1
1
  import Transaction from './Transaction.svelte.js';
2
- import { char_slice } from './util.js';
2
+ import { char_slice, get_char_length, traverse } from './util.js';
3
3
 
4
4
  /**
5
5
  * @import {
@@ -8,9 +8,10 @@ import { char_slice } from './util.js';
8
8
  * Selection,
9
9
  * Annotation,
10
10
  * PrimitiveType,
11
- * DocumentSchemaPrimitive,
12
11
  * NodeProperty,
13
12
  * NodeArrayProperty,
13
+ * NodeKind,
14
+ * NodeSchema,
14
15
  * DocumentSchema,
15
16
  * SerializedDocument
16
17
  * } from './types.d.ts';
@@ -20,7 +21,7 @@ import { char_slice } from './util.js';
20
21
  * Identity function — keeps schema at runtime & makes IDE infer types.
21
22
  * Similar to your define_schema pattern but for document schemas.
22
23
  *
23
- * @template {Record<string, Record<string, {type: DocumentSchemaPrimitive}>>} S
24
+ * @template {Record<string, NodeSchema>} S
24
25
  * @param {S} schema - The document schema to validate
25
26
  * @returns {S} The same schema, but with type information preserved
26
27
  */
@@ -36,7 +37,7 @@ export function define_document_schema(schema) {
36
37
  export function is_primitive_type(type) {
37
38
  return [
38
39
  'string', 'number', 'boolean', 'integer', 'datetime',
39
- 'annotated_string', 'string_array', 'number_array',
40
+ 'annotated_text', 'string_array', 'number_array',
40
41
  'boolean_array', 'integer_array'
41
42
  ].includes(type);
42
43
  }
@@ -63,7 +64,7 @@ export function get_default_node_type(property_definition) {
63
64
  export function validate_document_schema(document_schema) {
64
65
  // Check that all referenced node types exist
65
66
  for (const [node_type, node_schema] of Object.entries(document_schema)) {
66
- for (const [prop_name, prop_def] of Object.entries(node_schema)) {
67
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
67
68
  if (prop_def.type === 'node' || prop_def.type === 'node_array') {
68
69
  const missing_types = prop_def.node_types.filter(ref_type => !(ref_type in document_schema));
69
70
  if (missing_types.length > 0) {
@@ -92,11 +93,11 @@ function validate_primitive_value(type, value) {
92
93
  return Number.isInteger(value);
93
94
  case 'datetime':
94
95
  return typeof value === 'string' && !isNaN(Date.parse(value));
95
- case 'annotated_string':
96
- return Array.isArray(value) &&
97
- value.length === 2 &&
98
- typeof value[0] === 'string' &&
99
- Array.isArray(value[1]);
96
+ case 'annotated_text':
97
+ return typeof value === 'object' &&
98
+ value !== null &&
99
+ typeof value.text === 'string' &&
100
+ Array.isArray(value.annotations);
100
101
  case 'string_array':
101
102
  return Array.isArray(value) && value.every(v => typeof v === 'string');
102
103
  case 'number_array':
@@ -136,7 +137,7 @@ function validate_node(node, schema, all_nodes = {}) {
136
137
 
137
138
  const node_schema = schema[node.type];
138
139
 
139
- for (const [prop_name, prop_def] of Object.entries(node_schema)) {
140
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
140
141
  const value = node[prop_name];
141
142
 
142
143
  // Check primitive types
@@ -177,7 +178,7 @@ function validate_node(node, schema, all_nodes = {}) {
177
178
 
178
179
  export default class Document {
179
180
  /** @type {Selection | undefined} */
180
- selection = $state();
181
+ #selection = $state();
181
182
  config = $state();
182
183
  document_id = $state();
183
184
  nodes = $state();
@@ -190,6 +191,86 @@ export default class Document {
190
191
 
191
192
  // Reactive variable for selected node
192
193
  selected_node = $derived(this.get_selected_node());
194
+ layout_node = $derived(this.get_layout_node());
195
+ available_annotation_types = $derived(this.get_available_annotation_types());
196
+
197
+ /**
198
+ * Gets the current selection
199
+ * @returns {Selection | undefined}
200
+ */
201
+ get selection() {
202
+ return this.#selection;
203
+ }
204
+
205
+ /**
206
+ * Sets the selection with validation
207
+ * @param {Selection | undefined} value - The new selection
208
+ * @throws {Error} Throws if the selection is invalid
209
+ */
210
+ set selection(value) {
211
+ // if (value !== undefined) {
212
+ // this._validate_selection(value);
213
+ // }
214
+ this.#selection = value;
215
+ }
216
+
217
+ /**
218
+ * Validates that a selection is within bounds and refers to valid paths.
219
+ *
220
+ * @param {Selection} selection - The selection to validate
221
+ * @throws {Error} Throws if the selection is invalid
222
+ * @private
223
+ */
224
+ _validate_selection(selection) {
225
+ if (!selection) return; // no selection is a valid selection
226
+ if (selection && !Array.isArray(selection.path)) {
227
+ throw new Error('Selection must have a valid path');
228
+ }
229
+
230
+ const selection_type = selection.type;
231
+ if (selection_type === 'node') {
232
+ // For node selections, path should point to a node_array
233
+ if (this.inspect(selection.path).type !== 'node_array') {
234
+ throw new Error(`Node selection path does not point to a node array: ${selection.path.join('.')}`);
235
+ }
236
+
237
+ const node_array = this.get(selection.path);
238
+
239
+ // Validate anchor_offset and focus_offset are within bounds
240
+ const max_offset = node_array.length;
241
+
242
+ if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
243
+ throw new Error(`Node selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${max_offset})`);
244
+ }
245
+
246
+ if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
247
+ throw new Error(`Node selection focus_offset ${selection.focus_offset} is out of bounds (0-${max_offset})`);
248
+ }
249
+
250
+ } else if (selection_type === 'text') {
251
+ // For text selections, path should point to an annotated_text property
252
+ const annotated_text = this.get(selection.path);
253
+
254
+ // Validate anchor_offset and focus_offset are within text bounds
255
+ const char_length = get_char_length(annotated_text.text);
256
+
257
+ if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
258
+ throw new Error(`Text selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${char_length})`);
259
+ }
260
+
261
+ if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
262
+ throw new Error(`Text selection focus_offset ${selection.focus_offset} is out of bounds (0-${char_length})`);
263
+ }
264
+ } else if (selection_type === 'property') {
265
+ // For property selections, just validate the path exists
266
+ if (!this.inspect(selection.path)) {
267
+ throw new Error(`Property selection path not found: ${selection.path.join('.')}`);
268
+ }
269
+
270
+ } else {
271
+ throw new Error(`Unknown selection type: ${selection_type}`);
272
+ }
273
+ }
193
274
 
194
275
  /**
195
276
  * @param {DocumentSchema} schema - The document schema
@@ -203,7 +284,6 @@ export default class Document {
203
284
  validate_document_schema(schema);
204
285
 
205
286
  this.schema = schema;
206
- this.selection = selection;
207
287
  this.config = config;
208
288
  this.nodes = {};
209
289
 
@@ -216,6 +296,9 @@ export default class Document {
216
296
 
217
297
  // The last element in the serialized_doc is the document itself (the root node)
218
298
  this.document_id = serialized_doc.at(-1)?.id;
299
+
300
+ // Set selection after nodes are initialized so validation can work properly
301
+ this.selection = selection;
219
302
  }
220
303
 
221
304
  generate_id() {
@@ -226,6 +309,13 @@ export default class Document {
226
309
  }
227
310
  }
228
311
 
312
+ get_available_annotation_types() {
313
+ if (this.selection?.type !== 'text') return [];
314
+ const path = this.selection.path;
315
+ const property_definition = this.inspect(path);
316
+ return property_definition.node_types || [];
317
+ }
318
+
229
319
  // Helper function to get the currently selected node
230
320
  get_selected_node() {
231
321
  if (!this.selection) return null;
@@ -240,13 +330,37 @@ export default class Document {
240
330
  return node_id ? this.get(node_id) : null;
241
331
  } else {
242
332
  // we are assuming we are either in a text or property (=custom) selection
243
- const node_id = this.selection?.path?.slice(0, -1);
244
- if (!node_id) return null;
245
- const owner_node = this.get(node_id);
333
+ const owner_node_path = this.selection?.path?.slice(0, -1);
334
+ if (!owner_node_path) return null;
335
+ const owner_node = this.get(owner_node_path);
246
336
  return owner_node;
247
337
  }
248
338
  }
249
339
 
340
+ // NOTE: This code is a bit whacky, but works for now.
341
+ // TODO: Refactor as we settle on a final API
342
+ get_layout_node() {
343
+ if (!this.selected_node) return null;
344
+
345
+ // The selected node already is a layout node
346
+ if (this.selected_node.layout) {
347
+ return this.selected_node;
348
+ }
349
+
350
+ // We resolve the parent node if available, and return it if it's a layout node.
351
+ // NOTE: We only support one level atm, we may want to implement this recursively
352
+ if (this.selection.type === 'node') {
353
+ const parent_node = this.get(this.selection.path.slice(0, -1));
354
+ return parent_node.layout ? parent_node : null;
355
+ } else {
356
+ // We are either in a text or property (=custom) selection
357
+ const parent_node_path = this.selection?.path?.slice(0, -3);
358
+ if (!parent_node_path) return null;
359
+ const parent_node = this.get(parent_node_path);
360
+ return parent_node.layout ? parent_node : null;
361
+ }
362
+ }
363
+
250
364
  // Internal unsafe function: Never call this directly
251
365
  _apply_op(op) {
252
366
  const [type, ...args] = op;
@@ -352,8 +466,8 @@ export default class Document {
352
466
  * doc.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
353
467
  *
354
468
  * @example
355
- * // Get an annotated string property
356
- * doc.get(['page_1', 'cover', 'title']) // => ['Hello world', []]
469
+ * // Get an annotated text property
470
+ * doc.get(['page_1', 'cover', 'title']) // => {text: 'Hello world', annotations: []}
357
471
  */
358
472
  get(path) {
359
473
  if (typeof path === 'string') {
@@ -372,6 +486,9 @@ export default class Document {
372
486
  if (this.property_type(val.type, path_segment) === 'node_array') {
373
487
  val = val[path_segment]; // e.g. for the page body ['list_1', 'paragraph_1']
374
488
  val_type = 'node_array';
489
+ } else if (this.property_type(val.type, path_segment) === 'annotated_text') {
490
+ val = val[path_segment];
491
+ val_type = 'annotated_text';
375
492
  } else if (this.property_type(val.type, path_segment) === 'node') {
376
493
  val = this.nodes[val[path_segment]];
377
494
  val_type = 'node';
@@ -379,7 +496,7 @@ export default class Document {
379
496
  val = val[path_segment];
380
497
  val_type = 'value_array';
381
498
  } else {
382
- // Resolve value properties sucha as string, integer, datetime
499
+ // Resolve value properties such as string, integer, datetime
383
500
  val = val[path_segment];
384
501
  val_type = 'value';
385
502
  }
@@ -390,6 +507,32 @@ export default class Document {
390
507
  } else if (val_type === 'value_array') {
391
508
  val = val[path_segment];
392
509
  val_type = 'value';
510
+ } else if (val_type === 'annotated_text') {
511
+ if (path_segment === 'text') {
512
+ val = val.text;
513
+ val_type = 'value';
514
+ } else if (path_segment === 'annotations') {
515
+ val = val.annotations;
516
+ val_type = 'annotation_array';
517
+ } else {
518
+ throw new Error(`Invalid path segment "${path_segment}" for annotated_text. Use "text" or "annotations".`);
519
+ }
520
+ } else if (val_type === 'annotation_array') {
521
+ val = val[path_segment];
522
+ val_type = 'annotation';
523
+ } else if (val_type === 'annotation') {
524
+ if (path_segment === 'node_id') {
525
+ val = this.nodes[val.node_id];
526
+ val_type = 'node';
527
+ } else if (path_segment === 'start_offset') {
528
+ val = val.start_offset;
529
+ val_type = 'value';
530
+ } else if (path_segment === 'end_offset') {
531
+ val = val.end_offset;
532
+ val_type = 'value';
533
+ } else {
534
+ throw new Error(`Invalid path segment "${path_segment}" for annotation. Use "start_offset", "end_offset", or "node_id".`);
535
+ }
393
536
  }
394
537
  }
395
538
  return val;
@@ -430,7 +573,7 @@ export default class Document {
430
573
  kind: 'property',
431
574
  name: property_name,
432
575
  // Merge property spec from schema
433
- ...this.schema[parent.type][property_name]
576
+ ...this.schema[parent.type].properties[property_name]
434
577
  };
435
578
  } else {
436
579
  // Parent is a property (or we are at the root), so we are dealing with a node.
@@ -445,17 +588,54 @@ export default class Document {
445
588
  }
446
589
 
447
590
  /**
448
- * Determines the kind of a node ('text' for pure text nodes or 'node' for anything else)
449
- * NOTE: currently we assume a 'content' property for pure text nodes
591
+ * Determines the kind of a node ('block' for structured blocks, 'text' for pure
592
+ * text nodes or 'annotation' for annotation nodes.
450
593
  * @param {any} node
451
- * @returns {'text'|'node'}
594
+ * @returns {NodeKind}
452
595
  */
453
596
  kind(node) {
454
- if (['annotated_string', 'string'].includes(this.schema[node.type]?.content?.type)) {
455
- return 'text'
456
- } else {
457
- return 'node';
597
+ return this.schema[node.type].kind;
598
+ }
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;
458
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
+ };
459
639
  }
460
640
 
461
641
  /**
@@ -469,30 +649,60 @@ export default class Document {
469
649
  if (this.selection?.type !== 'text') return null;
470
650
 
471
651
  const { start, end } = this.get_selection_range();
472
- const annotated_string = this.get(this.selection.path);
473
- const annotations = annotated_string[1];
652
+ const annotated_text = this.get(this.selection.path);
653
+ const annotations = annotated_text.annotations;
474
654
 
475
- const active_annotation = annotations.find(([annotation_start, annotation_end]) =>
476
- (annotation_start <= start && annotation_end > start) ||
477
- (annotation_start < end && annotation_end >= end) ||
478
- (annotation_start >= start && annotation_end <= end)
655
+ const active_annotation = annotations.find(({start_offset, end_offset}) =>
656
+ (start_offset <= start && end_offset > start) ||
657
+ (start_offset < end && end_offset >= end) ||
658
+ (start_offset >= start && end_offset <= end)
479
659
  ) || null;
480
660
 
481
- if (annotation_type) {
482
- return active_annotation?.[2] === annotation_type ? active_annotation : null;
661
+ if (annotation_type && active_annotation) {
662
+ const annotation_node = this.get(active_annotation?.node_id);
663
+ return annotation_node?.type === annotation_type ? active_annotation : null;
483
664
  } else {
484
665
  return active_annotation;
485
666
  }
486
667
  }
487
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
+
488
698
  // TODO: think about ways how we can also turn a node selection into plain text.
489
699
  get_selected_plain_text() {
490
700
  if (this.selection?.type !== 'text') return null;
491
701
 
492
702
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
493
703
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
494
- const annotated_string = this.get(this.selection.path);
495
- return char_slice(annotated_string[0], start, end);
704
+ const annotated_text = this.get(this.selection.path);
705
+ return char_slice(annotated_text.text, start, end);
496
706
  }
497
707
 
498
708
  get_selected_nodes() {
@@ -504,41 +714,6 @@ export default class Document {
504
714
  return $state.snapshot(node_array.slice(start, end));
505
715
  }
506
716
 
507
- move_node_cursor(direction) {
508
- if (this.selection?.type !== 'node') return;
509
- const node_array = this.get(this.selection.path);
510
-
511
- const { start, end } = this.get_selection_range();
512
-
513
- if (this.selection.anchor_offset !== this.selection.focus_offset) {
514
- // If selection is not collapsed, collapse it to the right or the left
515
- if (direction === 'forward') {
516
- this.selection.focus_offset = end;
517
- this.selection.anchor_offset = end;
518
- } else if (direction === 'backward') {
519
- this.selection.focus_offset = start;
520
- this.selection.anchor_offset = start;
521
- }
522
- } else if (direction === 'forward' && end < node_array.length) {
523
- this.selection.focus_offset = end + 1;
524
- this.selection.anchor_offset = end + 1;
525
- } else if (direction === 'backward' && start > 0) {
526
- this.selection.focus_offset = start - 1;
527
- this.selection.anchor_offset = start - 1;
528
- }
529
- }
530
-
531
- expand_node_selection(direction) {
532
- if (this.selection.type !== 'node') return;
533
- const node_array = this.get(this.selection.path);
534
-
535
- if (direction === 'forward') {
536
- this.selection.focus_offset = Math.min(this.selection.focus_offset + 1, node_array.length);
537
- } else if (direction === 'backward') {
538
- this.selection.focus_offset = Math.max(this.selection.focus_offset - 1, 0);
539
- }
540
- }
541
-
542
717
  select_parent() {
543
718
  if (!this.selection) return;
544
719
  if (['text', 'property'].includes(this.selection?.type)) {
@@ -595,33 +770,7 @@ export default class Document {
595
770
  // IMPORTANT: Leaf nodes must go first, branches second and the root node (entry point) last (depth-first traversal)
596
771
  // NOTE: Nodes that are not reachable from the entry point node will not be included in the serialization
597
772
  traverse(node_id) {
598
- const json = [];
599
- const visited = {};
600
- const visit = (node) => {
601
- if (!node || visited[node.id]) {
602
- return;
603
- }
604
- visited[node.id] = true;
605
- for (const [, value] of Object.entries(node)) {
606
- // TODO: Use schema inspection and do this only for properties of type `node_array`
607
- if (Array.isArray(value)) {
608
- for (const v of value) {
609
- if (typeof v === 'string') {
610
- visit($state.snapshot(this.get(v)));
611
- }
612
- }
613
- } else if (typeof value === 'string') {
614
- // TODO: Use schema inspection and do this only for properties of type `ref`
615
- visit($state.snapshot(this.get(value)));
616
- }
617
- }
618
- // Finally add the node to the result.
619
- // We use a deep clone, so we make sure nothing of the original document is referenced.
620
- json.push(structuredClone(node));
621
- }
622
- // Start with the root node (document_id)
623
- visit($state.snapshot(this.get(node_id)));
624
- return json;
773
+ return traverse(node_id, this.schema, $state.snapshot(this.nodes));
625
774
  }
626
775
 
627
776
  /**
@@ -633,7 +782,7 @@ export default class Document {
633
782
  }
634
783
 
635
784
  // property_type('page', 'body') => 'node_array'
636
- // property_type('paragraph', 'content') => 'annotated_string'
785
+ // property_type('paragraph', 'content') => 'annotated_text'
637
786
  property_type(type, property) {
638
787
  if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
639
788
  if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
@@ -642,9 +791,9 @@ export default class Document {
642
791
  if (property === 'id') return 'string';
643
792
 
644
793
  if (!this.schema[type]) throw new Error(`Type ${type} not found in schema`);
645
- if (!this.schema[type][property]) throw new Error(`Property ${property} not found in type ${type}`);
794
+ if (!this.schema[type].properties[property]) throw new Error(`Property ${property} not found in type ${type}`);
646
795
 
647
- return this.schema[type][property].type;
796
+ return this.schema[type].properties[property].type;
648
797
  }
649
798
 
650
799
  // Count how many times a node is referenced in the document