svedit 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/dist/AnnotatedStringProperty.svelte +126 -0
  4. package/dist/AnnotatedStringProperty.svelte.d.ts +7 -0
  5. package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -0
  6. package/dist/CustomProperty.svelte +40 -0
  7. package/dist/CustomProperty.svelte.d.ts +8 -0
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -0
  9. package/dist/Document.svelte.d.ts +191 -0
  10. package/dist/Document.svelte.d.ts.map +1 -0
  11. package/dist/Document.svelte.js +681 -0
  12. package/dist/Node.svelte +41 -0
  13. package/dist/Node.svelte.d.ts +7 -0
  14. package/dist/Node.svelte.d.ts.map +1 -0
  15. package/dist/NodeArrayProperty.svelte +45 -0
  16. package/dist/NodeArrayProperty.svelte.d.ts +7 -0
  17. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -0
  18. package/dist/Svedit.svelte +936 -0
  19. package/dist/Svedit.svelte.d.ts +12 -0
  20. package/dist/Svedit.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +198 -0
  22. package/dist/Transaction.svelte.d.ts.map +1 -0
  23. package/dist/Transaction.svelte.js +577 -0
  24. package/dist/UnknownNode.svelte +12 -0
  25. package/dist/UnknownNode.svelte.d.ts +12 -0
  26. package/dist/UnknownNode.svelte.d.ts.map +1 -0
  27. package/dist/commands.svelte.d.ts +5 -0
  28. package/dist/commands.svelte.d.ts.map +1 -0
  29. package/dist/commands.svelte.js +261 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +14 -0
  33. package/dist/types.d.ts +264 -0
  34. package/dist/util.d.ts +4 -0
  35. package/dist/util.d.ts.map +1 -0
  36. package/dist/util.js +78 -0
  37. package/package.json +65 -0
  38. package/src/lib/AnnotatedStringProperty.svelte +126 -0
  39. package/src/lib/CustomProperty.svelte +40 -0
  40. package/src/lib/Document.svelte.js +681 -0
  41. package/src/lib/Node.svelte +41 -0
  42. package/src/lib/NodeArrayProperty.svelte +45 -0
  43. package/src/lib/Svedit.svelte +936 -0
  44. package/src/lib/Transaction.svelte.js +577 -0
  45. package/src/lib/UnknownNode.svelte +12 -0
  46. package/src/lib/commands.svelte.js +261 -0
  47. package/src/lib/index.js +14 -0
  48. package/src/lib/types.d.ts +264 -0
  49. package/src/lib/util.js +78 -0
@@ -0,0 +1,681 @@
1
+ import Transaction from './Transaction.svelte.js';
2
+
3
+ /**
4
+ * @import {
5
+ * NodeId,
6
+ * DocumentPath,
7
+ * Selection,
8
+ * Annotation,
9
+ * PrimitiveType,
10
+ * DocumentSchemaPrimitive,
11
+ * NodeProperty,
12
+ * NodeArrayProperty,
13
+ * DocumentSchema,
14
+ * SerializedDocument
15
+ * } from './types.d.ts';
16
+ */
17
+
18
+ /**
19
+ * Identity function — keeps schema at runtime & makes IDE infer types.
20
+ * Similar to your define_schema pattern but for document schemas.
21
+ *
22
+ * @template {Record<string, Record<string, {type: DocumentSchemaPrimitive}>>} S
23
+ * @param {S} schema - The document schema to validate
24
+ * @returns {S} The same schema, but with type information preserved
25
+ */
26
+ export function define_document_schema(schema) {
27
+ return schema;
28
+ }
29
+
30
+ /**
31
+ * Check if a string represents a valid primitive type.
32
+ * @param {string} type - The type string to check
33
+ * @returns {type is PrimitiveType} True if it's a valid primitive type
34
+ */
35
+ export function is_primitive_type(type) {
36
+ return [
37
+ 'string', 'number', 'boolean', 'integer', 'datetime',
38
+ 'annotated_string', 'string_array', 'number_array',
39
+ 'boolean_array', 'integer_array'
40
+ ].includes(type);
41
+ }
42
+
43
+ /**
44
+ * Get the default node type for a property that references nodes.
45
+ * @param {NodeProperty | NodeArrayProperty} property_definition - The property definition
46
+ * @returns {string | null} The default node type, or null if none specified
47
+ */
48
+ export function get_default_node_type(property_definition) {
49
+ if (!property_definition || !property_definition.node_types) {
50
+ return null;
51
+ }
52
+
53
+ return property_definition.default_node_type ||
54
+ (property_definition.node_types.length === 1 ? property_definition.node_types[0] : null);
55
+ }
56
+
57
+ /**
58
+ * Validate a document schema to ensure all referenced node types exist.
59
+ * @param {DocumentSchema} document_schema - The document schema to validate
60
+ * @throws {Error} Throws if the document schema is invalid
61
+ */
62
+ export function validate_document_schema(document_schema) {
63
+ // Check that all referenced node types exist
64
+ for (const [node_type, node_schema] of Object.entries(document_schema)) {
65
+ for (const [prop_name, prop_def] of Object.entries(node_schema)) {
66
+ if (prop_def.type === 'node' || prop_def.type === 'node_array') {
67
+ const missing_types = prop_def.node_types.filter(ref_type => !(ref_type in document_schema));
68
+ if (missing_types.length > 0) {
69
+ throw new Error(`Node type "${node_type}" property "${prop_name}" references unknown node types: ${missing_types.join(', ')}. Available node types: ${Object.keys(document_schema).join(', ')}`);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Validate a primitive value against its schema type.
78
+ * @param {PrimitiveType} type - The expected type
79
+ * @param {any} value - The value to validate
80
+ * @returns {boolean} True if the value matches the type
81
+ */
82
+ function validate_primitive_value(type, value) {
83
+ switch (type) {
84
+ case 'string':
85
+ return typeof value === 'string';
86
+ case 'number':
87
+ return typeof value === 'number' && !isNaN(value);
88
+ case 'boolean':
89
+ return typeof value === 'boolean';
90
+ case 'integer':
91
+ return Number.isInteger(value);
92
+ case 'datetime':
93
+ return typeof value === 'string' && !isNaN(Date.parse(value));
94
+ case 'annotated_string':
95
+ return Array.isArray(value) &&
96
+ value.length === 2 &&
97
+ typeof value[0] === 'string' &&
98
+ Array.isArray(value[1]);
99
+ case 'string_array':
100
+ return Array.isArray(value) && value.every(v => typeof v === 'string');
101
+ case 'number_array':
102
+ return Array.isArray(value) && value.every(v => typeof v === 'number' && !isNaN(v));
103
+ case 'boolean_array':
104
+ return Array.isArray(value) && value.every(v => typeof v === 'boolean');
105
+ case 'integer_array':
106
+ return Array.isArray(value) && value.every(v => Number.isInteger(v));
107
+ default:
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {String} id
114
+ * @returns {boolean}
115
+ */
116
+ function is_id_valid(id) {
117
+ return typeof id === 'string' && id.length > 0
118
+ }
119
+
120
+ /**
121
+ * Validate a node against its schema.
122
+ * @param {any} node - The node to validate
123
+ * @param {DocumentSchema} schema - The document schema
124
+ * @param {Record<string, any>} all_nodes - All nodes in the document to check references
125
+ * @throws {Error} Throws if the node is invalid
126
+ */
127
+ function validate_node(node, schema, all_nodes = {}) {
128
+ if (!is_id_valid(node.id)) {
129
+ throw new Error(`Node ${node.id} has an invalid id.`);
130
+ }
131
+
132
+ if (!node.type || !schema[node.type]) {
133
+ throw new Error(`Node ${node.id} has an invalid type: ${node.type}`);
134
+ }
135
+
136
+ const node_schema = schema[node.type];
137
+
138
+ for (const [prop_name, prop_def] of Object.entries(node_schema)) {
139
+ const value = node[prop_name];
140
+
141
+ // Check primitive types
142
+ if (is_primitive_type(prop_def.type)) {
143
+ if (!validate_primitive_value(prop_def.type, value)) {
144
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be of type ${prop_def.type}.`);
145
+ }
146
+ }
147
+ // Check node references
148
+ if (prop_def.type === 'node') {
149
+ if (!is_id_valid(value)) {
150
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be a valid node id.`);
151
+ }
152
+ // Check if referenced node exists and is of allowed type
153
+ const referenced_node = all_nodes[value];
154
+ if (
155
+ referenced_node &&
156
+ !prop_def.node_types.includes(referenced_node.type)
157
+ ) {
158
+ throw new Error(`Node ${node.id} property ${prop_name} references node ${value} of type ${referenced_node.type}, but only types [${(/** @type {NodeProperty} */ (prop_def)).node_types.join(', ')}] are allowed.`);
159
+ }
160
+ }
161
+ // Check node arrays
162
+ else if (prop_def.type === 'node_array') {
163
+ if (!Array.isArray(value) || !value.every(id => typeof id === 'string' && is_id_valid(id))) {
164
+ throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be an array of node ids.`);
165
+ }
166
+ // Check if all referenced nodes are of allowed types
167
+ for (const ref_id of value) {
168
+ const referenced_node = all_nodes[ref_id];
169
+ if (referenced_node && !prop_def.node_types.includes(referenced_node.type)) {
170
+ throw new Error(`Node ${node.id} property ${prop_name} references node ${ref_id} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ export default class Document {
178
+ /** @type {Selection | undefined} */
179
+ selection = $state();
180
+ config = $state();
181
+ document_id = $state();
182
+ nodes = $state();
183
+ history = $state([]);
184
+ history_index = $state(-1);
185
+
186
+ // Reactive helpers for UI state
187
+ can_undo = $derived(this.history_index >= 0);
188
+ can_redo = $derived(this.history_index < this.history.length - 1);
189
+
190
+ // Reactive variable for selected node
191
+ selected_node = $derived(this.get_selected_node());
192
+
193
+ /**
194
+ * @param {DocumentSchema} schema - The document schema
195
+ * @param {SerializedDocument} serialized_doc - The serialized document array
196
+ * @param {object} [options] - Optional configuration
197
+ * @param {Selection} [options.selection] - Initial selection state
198
+ * @param {any} [options.config] - Document configuration
199
+ */
200
+ constructor(schema, serialized_doc, { selection, config } = {}) {
201
+ // Validate the schema first
202
+ validate_document_schema(schema);
203
+
204
+ this.schema = schema;
205
+ this.selection = selection;
206
+ this.config = config;
207
+ this.nodes = {};
208
+
209
+ // Initialize and validate nodes one by one
210
+ // This ensures references only point to already-loaded nodes
211
+ for (const node of serialized_doc) {
212
+ this.validate_node(node);
213
+ this.nodes[node.id] = node;
214
+ }
215
+
216
+ // The last element in the serialized_doc is the document itself (the root node)
217
+ this.document_id = serialized_doc.at(-1)?.id;
218
+ }
219
+
220
+ generate_id() {
221
+ if (this.config.generate_id) {
222
+ return this.config.generate_id();
223
+ } else {
224
+ return crypto.randomUUID();
225
+ }
226
+ }
227
+
228
+ // Helper function to get the currently selected node
229
+ get_selected_node() {
230
+ if (!this.selection) return null;
231
+
232
+ if (this.selection.type === 'node') {
233
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
234
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
235
+ // Only consider selection of a single node
236
+ if (end - start !== 1) return null;
237
+ const node_array = this.get(this.selection.path);
238
+ const node_id = node_array[start];
239
+ return node_id ? this.get(node_id) : null;
240
+ } else {
241
+ // we are assuming we are either in a text or property (=custom) selection
242
+ const owner_node = this.get(this.selection.path.slice(0, -1));
243
+ return owner_node;
244
+ }
245
+ }
246
+
247
+ // Internal unsafe function: Never call this directly
248
+ _apply_op(op) {
249
+ const [type, ...args] = op;
250
+ if (type === 'set') {
251
+ const node = this.get(args[0].slice(0, -1));
252
+ node[args[0].at(-1)] = args[1];
253
+ } else if (type === 'create') {
254
+
255
+ this.nodes[args[0].id] = args[0];
256
+ } else if (type === 'delete') {
257
+ delete this.nodes[args[0]];
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Validate a node against the document schema.
263
+ * @param {any} node - The node to validate
264
+ * @throws {Error} Throws if the node is invalid
265
+ */
266
+ validate_node(node) {
267
+ validate_node(node, this.schema, this.nodes);
268
+ }
269
+
270
+ /**
271
+ * Creates a new transaction for making atomic changes to the document.
272
+ *
273
+ * @returns {Transaction} A new transaction instance
274
+ */
275
+ get tr() {
276
+ // We create a copy of the current document to avoid modifying the original
277
+ const transaction_doc = new Document(this.schema, this.to_json(), {
278
+ config: this.config,
279
+ selection: this.selection
280
+ });
281
+ return new Transaction(transaction_doc);
282
+ }
283
+
284
+ /**
285
+ * Applies a transaction to the document.
286
+ *
287
+ * @param {Transaction} transaction - The transaction to apply
288
+ */
289
+ apply(transaction) {
290
+ this.nodes = transaction.doc.nodes; // No deep copy, trust transaction's evolved state
291
+ // Make sure selection gets a new reference (is rerendered)
292
+ this.selection = { ...transaction.doc.selection };
293
+ if (this.history_index < this.history.length - 1) {
294
+ this.history = this.history.slice(0, this.history_index + 1);
295
+ }
296
+ this.history.push({
297
+ ops: transaction.ops,
298
+ inverse_ops: transaction.inverse_ops,
299
+ selection_before: transaction.selection_before,
300
+ selection_after: this.selection
301
+ });
302
+ this.history_index++;
303
+
304
+ return this;
305
+ }
306
+
307
+ undo() {
308
+ if (this.history_index < 0) {
309
+ return;
310
+ }
311
+ const change = this.history[this.history_index];
312
+ const tr = this.tr;
313
+ change.inverse_ops.slice().reverse().forEach(op => tr.doc._apply_op(op));
314
+ this.nodes = tr.doc.nodes;
315
+ this.selection = change.selection_before;
316
+ this.history_index--;
317
+ return this;
318
+ }
319
+
320
+ redo() {
321
+ if (this.history_index >= this.history.length - 1) {
322
+ return;
323
+ }
324
+ this.history_index++;
325
+ const change = this.history[this.history_index];
326
+ const tr = this.tr;
327
+ change.ops.forEach(op => tr.doc._apply_op(op));
328
+ tr.set_selection(change.selection_after);
329
+
330
+ this.nodes = tr.doc.nodes;
331
+ this.selection = change.selection_after;
332
+ return this;
333
+ }
334
+
335
+ /**
336
+ * Gets a node instance or property value at the specified path.
337
+ * @param {DocumentPath|string} path - Path to the node or property
338
+ * @returns {any} Either a node instance object or the value of a property
339
+ * @example
340
+ * // Get a node by ID
341
+ * doc.get('list_1') // => { type: 'list', id: 'list_1', ... }
342
+ *
343
+ * @example
344
+ * // Get a node array property
345
+ * doc.get(['list_1', 'list_items']) // => [ 'list_item_1', 'list_item_2' ]
346
+ *
347
+ * @example
348
+ * // Get a specific node from an array
349
+ * doc.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
350
+ *
351
+ * @example
352
+ * // Get an annotated string property
353
+ * doc.get(['page_1', 'cover', 'title']) // => ['Hello world', []]
354
+ */
355
+ get(path) {
356
+ if (typeof path === 'string') {
357
+ path = [ path ];
358
+ }
359
+ if (!(Array.isArray(path) && path.length >= 1)) {
360
+ throw new Error(`Invalid path provided ${JSON.stringify(path)}`);
361
+ }
362
+
363
+ let val = this.nodes[path[0]];
364
+ let val_type = 'node';
365
+
366
+ for (let i = 1; i < path.length; i++) {
367
+ const path_segment = path[i];
368
+ if (val_type === 'node') {
369
+ if (this.property_type(val.type, path_segment) === 'node_array') {
370
+ val = val[path_segment]; // e.g. for the page body ['list_1', 'paragraph_1']
371
+ val_type = 'node_array';
372
+ } else if (this.property_type(val.type, path_segment) === 'node') {
373
+ val = this.nodes[val[path_segment]];
374
+ val_type = 'node';
375
+ } else if (['string_array', 'integer_array'].includes(this.property_type(val.type, path_segment))) {
376
+ val = val[path_segment];
377
+ val_type = 'value_array';
378
+ } else {
379
+ // Resolve value properties sucha as string, integer, datetime
380
+ val = val[path_segment];
381
+ val_type = 'value';
382
+ }
383
+ } else if (val_type === 'node_array') {
384
+ // We expect the val to be an array of node ids and the path_segment to be an array index
385
+ val = this.nodes[val[path_segment]];
386
+ val_type = 'node';
387
+ } else if (val_type === 'value_array') {
388
+ val = val[path_segment];
389
+ val_type = 'value';
390
+ }
391
+ }
392
+ return val;
393
+ }
394
+
395
+ /**
396
+ * While .get gives you the value of a path, inspect gives you
397
+ * the type info of that value.
398
+ *
399
+ * @todo The layout of these should be improved and more explictly typed
400
+ *
401
+ * @example
402
+ * doc.inspect(['page_1', 'body']) => {
403
+ * kind: 'property',
404
+ * name: 'body',
405
+ * type: 'node_array',
406
+ * node_types: ['text', 'story', 'list'],
407
+ * default_node_type: 'text'
408
+ * }
409
+ *
410
+ * @example
411
+ * doc.inspect(['page_1', 'body', 1]) => {
412
+ * kind: 'node',
413
+ * id: 'paragraph_234',
414
+ * type: 'paragraph',
415
+ * properties: {...}
416
+ * }
417
+ *
418
+ * @param {DocumentPath} path
419
+ * @returns {{kind: 'property'|'node', [key: string]: any}}
420
+ */
421
+ inspect(path) {
422
+ const parent = path.length > 1 ? this.get(path.slice(0, -1)) : undefined;
423
+ if (parent?.type) {
424
+ // Parent is a node, so we are dealing with a property.
425
+ const property_name = path.at(-1);
426
+ return {
427
+ kind: 'property',
428
+ name: property_name,
429
+ // Merge property spec from schema
430
+ ...this.schema[parent.type][property_name]
431
+ };
432
+ } else {
433
+ // Parent is a property (or we are at the root), so we are dealing with a node.
434
+ const node = this.get(path);
435
+ return {
436
+ kind: 'node',
437
+ id: node.id,
438
+ type: node.type,
439
+ properties: this.schema[node.type]
440
+ }
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Determines the kind of a node ('text' for pure text nodes or 'node' for anything else)
446
+ * NOTE: currently we assume a 'content' property for pure text nodes
447
+ * @param {any} node
448
+ * @returns {'text'|'node'}
449
+ */
450
+ kind(node) {
451
+ if (['annotated_string', 'string'].includes(this.schema[node.type]?.content?.type)) {
452
+ return 'text'
453
+ } else {
454
+ return 'node';
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Returns the annotation object that is currently "under the cursor".
460
+ * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
461
+ *
462
+ * @param {string} annotation_type
463
+ * @returns {Annotation|null}
464
+ */
465
+ active_annotation(annotation_type) {
466
+ if (this.selection?.type !== 'text') return null;
467
+
468
+ const { start, end } = this.get_selection_range();
469
+ const annotated_string = this.get(this.selection.path);
470
+ const annotations = annotated_string[1];
471
+
472
+ const active_annotation = annotations.find(([annotation_start, annotation_end]) =>
473
+ (annotation_start <= start && annotation_end > start) ||
474
+ (annotation_start < end && annotation_end >= end) ||
475
+ (annotation_start >= start && annotation_end <= end)
476
+ ) || null;
477
+
478
+ if (annotation_type) {
479
+ return active_annotation?.[2] === annotation_type ? active_annotation : null;
480
+ } else {
481
+ return active_annotation;
482
+ }
483
+ }
484
+
485
+ // TODO: think about ways how we can also turn a node selection into plain text.
486
+ get_selected_plain_text() {
487
+ if (this.selection?.type !== 'text') return null;
488
+
489
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
490
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
491
+ const annotated_string = this.get(this.selection.path);
492
+ return annotated_string[0].slice(start, end);
493
+ }
494
+
495
+ get_selected_nodes() {
496
+ if (this.selection?.type !== 'node') return null;
497
+
498
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
499
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
500
+ const node_array = this.get(this.selection.path);
501
+ return $state.snapshot(node_array.slice(start, end));
502
+ }
503
+
504
+ move_node_cursor(direction) {
505
+ if (this.selection?.type !== 'node') return;
506
+ const node_array = this.get(this.selection.path);
507
+
508
+ const { start, end } = this.get_selection_range();
509
+
510
+ if (this.selection.anchor_offset !== this.selection.focus_offset) {
511
+ // If selection is not collapsed, collapse it to the right or the left
512
+ if (direction === 'forward') {
513
+ this.selection.focus_offset = end;
514
+ this.selection.anchor_offset = end;
515
+ } else if (direction === 'backward') {
516
+ this.selection.focus_offset = start;
517
+ this.selection.anchor_offset = start;
518
+ }
519
+ } else if (direction === 'forward' && end < node_array.length) {
520
+ this.selection.focus_offset = end + 1;
521
+ this.selection.anchor_offset = end + 1;
522
+ } else if (direction === 'backward' && start > 0) {
523
+ this.selection.focus_offset = start - 1;
524
+ this.selection.anchor_offset = start - 1;
525
+ }
526
+ }
527
+
528
+ expand_node_selection(direction) {
529
+ if (this.selection.type !== 'node') return;
530
+ const node_array = this.get(this.selection.path);
531
+
532
+ if (direction === 'forward') {
533
+ this.selection.focus_offset = Math.min(this.selection.focus_offset + 1, node_array.length);
534
+ } else if (direction === 'backward') {
535
+ this.selection.focus_offset = Math.max(this.selection.focus_offset - 1, 0);
536
+ }
537
+ }
538
+
539
+ select_parent() {
540
+ if (!this.selection) return;
541
+ if (['text', 'property'].includes(this.selection?.type)) {
542
+ // For text and property selections (e.g. ['page_1', 'body', 0, 'image']), we need to go up two levels
543
+ // in the path
544
+ if (this.selection.path.length > 3) {
545
+ const parent_path = this.selection.path.slice(0, -2);
546
+ const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
547
+ this.selection = {
548
+ type: 'node',
549
+ path: parent_path,
550
+ anchor_offset: current_index,
551
+ focus_offset: current_index + 1
552
+ };
553
+ } else {
554
+ this.selection = undefined;
555
+ }
556
+ } else if (this.selection.type === 'node') {
557
+ // For node selections, we go up one level
558
+ if (this.selection.path.length > 3) {
559
+ const parent_path = this.selection.path.slice(0, -2);
560
+ const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
561
+
562
+ this.selection = {
563
+ type: 'node',
564
+ path: parent_path,
565
+ anchor_offset: current_index,
566
+ focus_offset: current_index + 1
567
+ };
568
+ } else {
569
+ this.selection = undefined;
570
+ }
571
+ } else {
572
+ this.selection = undefined;
573
+ }
574
+ }
575
+
576
+ get_selection_range() {
577
+ if (this.selection && this.selection.type !== 'property') {
578
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
579
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
580
+
581
+ return {
582
+ start,
583
+ end,
584
+ length: end - start
585
+ };
586
+ } else {
587
+ return null;
588
+ }
589
+ }
590
+
591
+ // Traverses the document and returns a JSON representation.
592
+ // IMPORTANT: Leaf nodes must go first, branches second and the root node (entry point) last (depth-first traversal)
593
+ // NOTE: Nodes that are not reachable from the entry point node will not be included in the serialization
594
+ traverse(node_id) {
595
+ const json = [];
596
+ const visited = {};
597
+ const visit = (node) => {
598
+ if (!node || visited[node.id]) {
599
+ return;
600
+ }
601
+ visited[node.id] = true;
602
+ for (const [, value] of Object.entries(node)) {
603
+ // TODO: Use schema inspection and do this only for properties of type `node_array`
604
+ if (Array.isArray(value)) {
605
+ for (const v of value) {
606
+ if (typeof v === 'string') {
607
+ visit($state.snapshot(this.get(v)));
608
+ }
609
+ }
610
+ } else if (typeof value === 'string') {
611
+ // TODO: Use schema inspection and do this only for properties of type `ref`
612
+ visit($state.snapshot(this.get(value)));
613
+ }
614
+ }
615
+ // Finally add the node to the result.
616
+ // We use a deep clone, so we make sure nothing of the original document is referenced.
617
+ json.push(structuredClone(node));
618
+ }
619
+ // Start with the root node (document_id)
620
+ visit($state.snapshot(this.get(node_id)));
621
+ return json;
622
+ }
623
+
624
+ /**
625
+ * Convert the document to serialized format.
626
+ * @returns {SerializedDocument} The serialized document array
627
+ */
628
+ to_json() {
629
+ return this.traverse(this.document_id);
630
+ }
631
+
632
+ // property_type('page', 'body') => 'node_array'
633
+ // property_type('paragraph', 'content') => 'annotated_string'
634
+ property_type(type, property) {
635
+ if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
636
+ if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
637
+ // NOTE: Not sure if we should treat type and id as properties
638
+ if (property === 'type') return 'string';
639
+ if (property === 'id') return 'string';
640
+
641
+ if (!this.schema[type]) throw new Error(`Type ${type} not found in schema`);
642
+ if (!this.schema[type][property]) throw new Error(`Property ${property} not found in type ${type}`);
643
+
644
+ return this.schema[type][property].type;
645
+ }
646
+
647
+ // Count how many times a node is referenced in the document
648
+ count_references(node_id) {
649
+ let count = 0;
650
+
651
+ for (const node of Object.values(this.nodes)) {
652
+ for (const [property, value] of Object.entries(node)) {
653
+ if (property === 'id' || property === 'type') continue;
654
+
655
+ const prop_type = this.property_type(node.type, property);
656
+
657
+ if (prop_type === 'node_array' && Array.isArray(value)) {
658
+ count += value.filter(id => id === node_id).length;
659
+ } else if (prop_type === 'node' && value === node_id) {
660
+ count += 1;
661
+ }
662
+ }
663
+ }
664
+
665
+ return count;
666
+ }
667
+
668
+ // Get all nodes referenced by a given node (recursively)
669
+ /**
670
+ * @param {NodeId} node_id
671
+ * @returns {NodeId[]}
672
+ */
673
+ get_referenced_nodes(node_id) {
674
+ const traversed_nodes = this.traverse(node_id);
675
+
676
+ // Extract IDs and exclude the last element (root node)
677
+ return traversed_nodes
678
+ .slice(0, -1)
679
+ .map(node => node.id);
680
+ }
681
+ }