svedit 0.4.0 → 0.6.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 (63) hide show
  1. package/README.md +762 -120
  2. package/dist/AnnotatedTextProperty.svelte +122 -75
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +118 -0
  5. package/dist/Command.svelte.d.ts.map +1 -0
  6. package/dist/Command.svelte.js +314 -0
  7. package/dist/CustomProperty.svelte +31 -42
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  9. package/dist/KeyMapper.svelte.d.ts +36 -0
  10. package/dist/KeyMapper.svelte.d.ts.map +1 -0
  11. package/dist/KeyMapper.svelte.js +141 -0
  12. package/dist/Node.svelte +33 -35
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +40 -37
  15. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  16. package/dist/Session.svelte.d.ts +223 -0
  17. package/dist/Session.svelte.d.ts.map +1 -0
  18. package/dist/Session.svelte.js +594 -0
  19. package/dist/Svedit.svelte +1352 -1465
  20. package/dist/Svedit.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.d.ts +122 -51
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +845 -551
  24. package/dist/UnknownNode.svelte +6 -6
  25. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  26. package/dist/doc_utils.d.ts +127 -0
  27. package/dist/doc_utils.d.ts.map +1 -0
  28. package/dist/doc_utils.js +482 -0
  29. package/dist/index.d.ts +7 -3
  30. package/dist/index.js +20 -4
  31. package/dist/{commands.svelte.d.ts → transforms.svelte.d.ts} +1 -2
  32. package/dist/transforms.svelte.d.ts.map +1 -0
  33. package/dist/transforms.svelte.js +155 -0
  34. package/dist/types.d.ts +256 -110
  35. package/dist/{util.d.ts → utils.d.ts} +23 -2
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +338 -0
  38. package/package.json +25 -23
  39. package/src/lib/AnnotatedTextProperty.svelte +122 -75
  40. package/src/lib/Command.svelte.js +314 -0
  41. package/src/lib/CustomProperty.svelte +31 -42
  42. package/src/lib/KeyMapper.svelte.js +141 -0
  43. package/src/lib/Node.svelte +33 -35
  44. package/src/lib/NodeArrayProperty.svelte +40 -37
  45. package/src/lib/Session.svelte.js +594 -0
  46. package/src/lib/Svedit.svelte +1352 -1465
  47. package/src/lib/Transaction.svelte.js +845 -551
  48. package/src/lib/UnknownNode.svelte +6 -6
  49. package/src/lib/doc_utils.js +482 -0
  50. package/src/lib/index.js +20 -4
  51. package/src/lib/transforms.svelte.js +155 -0
  52. package/src/lib/types.d.ts +256 -110
  53. package/src/lib/utils.js +338 -0
  54. package/dist/Document.svelte.d.ts +0 -214
  55. package/dist/Document.svelte.d.ts.map +0 -1
  56. package/dist/Document.svelte.js +0 -796
  57. package/dist/commands.svelte.d.ts.map +0 -1
  58. package/dist/commands.svelte.js +0 -271
  59. package/dist/util.d.ts.map +0 -1
  60. package/dist/util.js +0 -235
  61. package/src/lib/Document.svelte.js +0 -796
  62. package/src/lib/commands.svelte.js +0 -271
  63. package/src/lib/util.js +0 -235
@@ -0,0 +1,594 @@
1
+ import Transaction from './Transaction.svelte.js';
2
+ import { char_slice, get_char_length, traverse } from './utils.js';
3
+ import {
4
+ get as doc_get,
5
+ property_type as doc_property_type,
6
+ kind as doc_kind,
7
+ inspect as doc_inspect,
8
+ apply_op,
9
+ count_references as doc_count_references,
10
+ validate_document_schema,
11
+ validate_node,
12
+ get_active_annotation
13
+ } from './doc_utils.js';
14
+
15
+ /**
16
+ * @import {
17
+ * NodeId,
18
+ * DocumentPath,
19
+ * Selection,
20
+ * Annotation,
21
+ * NodeKind,
22
+ * DocumentSchema,
23
+ * DocumentNode,
24
+ * Document
25
+ * } from './types.d.ts';
26
+ */
27
+
28
+ const BATCH_WINDOW_MS = 1000; // 1 second
29
+
30
+ /**
31
+ * @typedef {Object} SessionOptions
32
+ * @property {Selection} [selection] - Initial selection state
33
+ */
34
+
35
+ export default class Session {
36
+ /** @type {Selection | undefined} */
37
+ #selection = $state.raw(null);
38
+
39
+ /** @type {DocumentSchema} */
40
+ schema = $state.raw();
41
+
42
+ /** @type {Document} */
43
+ doc = $state.raw();
44
+
45
+ /** @type {any} */
46
+ config = $state.raw();
47
+
48
+ history = $state.raw([]);
49
+ history_index = $state.raw(-1);
50
+ last_batch_started = $state.raw(undefined); // Timestamp for debounced batching
51
+
52
+ // Commands and keymap - initialized by Svedit when ready
53
+ // NOTE: Assumes single Svedit instance per session
54
+ commands = $state.raw({});
55
+ keymap = $state.raw({});
56
+
57
+ // Reactive helpers for UI state
58
+ can_undo = $derived(this.history_index >= 0);
59
+ can_redo = $derived(this.history_index < this.history.length - 1);
60
+
61
+ // Reactive variable for selected node
62
+ selected_node = $derived(this.get_selected_node());
63
+ available_annotation_types = $derived(this.get_available_annotation_types());
64
+
65
+ /**
66
+ * @param {DocumentSchema} schema - The document schema
67
+ * @param {Document} doc - The document
68
+ * @param {object} config - configuration object
69
+ * @param {Object} [options]
70
+ * @param {Selection} [options.selection] - Initial selection state
71
+ */
72
+ constructor(schema, doc, config, options = {}) {
73
+ // Validate the schema first
74
+ validate_document_schema(schema);
75
+
76
+ this.schema = schema;
77
+ this.doc = doc;
78
+ this.config = config;
79
+
80
+ // Set selection after doc is initialized so validation can work properly
81
+ this.selection = options.selection ?? null;
82
+ }
83
+
84
+ /**
85
+ * Gets the current selection
86
+ * @returns {Selection | undefined}
87
+ */
88
+ get selection() {
89
+ return this.#selection;
90
+ }
91
+
92
+ /**
93
+ * Sets the selection with validation
94
+ * @param {Selection | undefined} value - The new selection
95
+ * @throws {Error} Throws if the selection is invalid
96
+ */
97
+ set selection(value) {
98
+ // if (value !== undefined) {
99
+ // this._validate_selection(value);
100
+ // }
101
+ this.#selection = value;
102
+ }
103
+
104
+ /**
105
+ * Validates that a selection is within bounds and refers to valid paths.
106
+ *
107
+ * @param {Selection} selection - The selection to validate
108
+ * @throws {Error} Throws if the selection is invalid
109
+ * @private
110
+ */
111
+ _validate_selection(selection) {
112
+ if (!selection) return; // no selection is a valid selection
113
+ if (selection && !Array.isArray(selection.path)) {
114
+ throw new Error('Selection must have a valid path');
115
+ }
116
+
117
+ const selection_type = selection.type;
118
+ if (selection_type === 'node') {
119
+ // For node selections, path should point to a node_array
120
+ if (this.inspect(selection.path).type !== 'node_array') {
121
+ throw new Error(
122
+ `Node selection path does not point to a node array: ${selection.path.join('.')}`
123
+ );
124
+ }
125
+
126
+ const node_array = this.get(selection.path);
127
+
128
+ // Validate anchor_offset and focus_offset are within bounds
129
+ const max_offset = node_array.length;
130
+
131
+ if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
132
+ throw new Error(
133
+ `Node selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${max_offset})`
134
+ );
135
+ }
136
+
137
+ if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
138
+ throw new Error(
139
+ `Node selection focus_offset ${selection.focus_offset} is out of bounds (0-${max_offset})`
140
+ );
141
+ }
142
+ } else if (selection_type === 'text') {
143
+ // For text selections, path should point to an annotated_text property
144
+ const annotated_text = this.get(selection.path);
145
+
146
+ // Validate anchor_offset and focus_offset are within text bounds
147
+ const char_length = get_char_length(annotated_text.text);
148
+
149
+ if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
150
+ throw new Error(
151
+ `Text selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${char_length})`
152
+ );
153
+ }
154
+
155
+ if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
156
+ throw new Error(
157
+ `Text selection focus_offset ${selection.focus_offset} is out of bounds (0-${char_length})`
158
+ );
159
+ }
160
+ } else if (selection_type === 'property') {
161
+ // For property selections, just validate the path exists
162
+ if (!this.inspect(selection.path)) {
163
+ throw new Error(`Property selection path not found: ${selection.path.join('.')}`);
164
+ }
165
+ } else {
166
+ throw new Error(`Unknown selection type: ${selection_type}`);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Gets the document_id from the doc
172
+ * @returns {string}
173
+ */
174
+ get document_id() {
175
+ return this.doc.document_id;
176
+ }
177
+
178
+ validate_doc() {
179
+ for (const node of Object.values(this.doc.nodes)) {
180
+ validate_node(node, this.schema, this.doc.nodes);
181
+ }
182
+ }
183
+
184
+ generate_id() {
185
+ if (this.config?.generate_id) {
186
+ return this.config.generate_id();
187
+ } else {
188
+ return crypto.randomUUID();
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Initialize commands and keymap for this session.
194
+ * Called by Svedit component when it has the necessary context.
195
+ *
196
+ * NOTE: This assumes a single Svedit instance per session.
197
+ * For multiple editors on the same document, this architecture would need
198
+ * to be refactored to support multiple sessions per document.
199
+ *
200
+ * @param {object} context - The svedit context with session, editable, canvas, etc.
201
+ */
202
+ initialize_commands(context) {
203
+ if (this.config?.create_commands_and_keymap) {
204
+ const { commands, keymap } = this.config.create_commands_and_keymap(context);
205
+ this.commands = commands;
206
+ this.keymap = keymap;
207
+ }
208
+ }
209
+
210
+ get_available_annotation_types() {
211
+ if (this.selection?.type !== 'text') return [];
212
+ const path = this.selection.path;
213
+ const property_definition = this.inspect(path);
214
+ return property_definition.node_types || [];
215
+ }
216
+
217
+ // Helper function to get the currently selected node
218
+ get_selected_node() {
219
+ if (!this.selection) return null;
220
+
221
+ if (this.selection.type === 'node') {
222
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
223
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
224
+ // Only consider selection of a single node
225
+ if (end - start !== 1) return null;
226
+ const node_array = this.get(this.selection.path);
227
+ const node_id = node_array[start];
228
+ return node_id ? this.get(node_id) : null;
229
+ } else {
230
+ // we are assuming we are either in a text or property (=custom) selection
231
+ const owner_node_path = this.selection?.path?.slice(0, -1);
232
+ if (!owner_node_path) return null;
233
+ const owner_node = this.get(owner_node_path);
234
+ return owner_node;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Creates a new transaction for making atomic changes to the document.
240
+ *
241
+ * @returns {Transaction} A new transaction instance
242
+ */
243
+ get tr() {
244
+ // We create a copy of the current state to avoid modifying the original
245
+ return new Transaction(this.schema, this.doc, this.selection, this.config);
246
+ }
247
+
248
+ /**
249
+ * Applies a transaction to the document.
250
+ * Auto-batches history entries with debounced behavior (max one entry per 2 seconds) when batch is true.
251
+ *
252
+ * @param {Transaction} transaction - The transaction to apply
253
+ * @param {object} [options] - Optional configuration
254
+ * @param {boolean} [options.batch=false] - Whether to allow batching with previous transaction
255
+ */
256
+ apply(transaction, { batch = false } = {}) {
257
+ this.doc = transaction.doc;
258
+ // Make sure selection gets a new reference (is rerendered)
259
+ this.selection = structuredClone(transaction.selection);
260
+ if (this.history_index < this.history.length - 1) {
261
+ this.history = this.history.slice(0, this.history_index + 1);
262
+ }
263
+
264
+ const now = Date.now();
265
+ const should_batch =
266
+ batch &&
267
+ this.last_batch_started !== undefined &&
268
+ now - this.last_batch_started < BATCH_WINDOW_MS;
269
+
270
+ if (should_batch) {
271
+ // Append to existing history entry (within 2s of batch start)
272
+ const last_entry = this.history[this.history_index];
273
+ last_entry.ops.push(...transaction.ops);
274
+ last_entry.inverse_ops.push(...transaction.inverse_ops);
275
+ last_entry.selection_after = this.selection;
276
+ // Trigger update
277
+ this.history = [...this.history];
278
+ } else {
279
+ // Create new history entry (more than 2s since batch started, or first edit, or batch not requested)
280
+ this.history = [
281
+ ...this.history,
282
+ {
283
+ ops: transaction.ops,
284
+ inverse_ops: transaction.inverse_ops,
285
+ selection_before: transaction.selection_before,
286
+ selection_after: this.selection
287
+ }
288
+ ];
289
+ this.history_index = this.history_index + 1;
290
+ // Only set last_batch_started if batching was requested
291
+ if (batch) {
292
+ this.last_batch_started = now;
293
+ } else {
294
+ this.last_batch_started = undefined;
295
+ }
296
+ }
297
+
298
+ return this;
299
+ }
300
+
301
+ undo() {
302
+ if (this.history_index < 0) {
303
+ return;
304
+ }
305
+ const change = this.history[this.history_index];
306
+ let doc = this.doc;
307
+ change.inverse_ops
308
+ .slice()
309
+ .reverse()
310
+ .forEach((op) => {
311
+ doc = apply_op(doc, op);
312
+ });
313
+ this.doc = doc;
314
+ this.selection = change.selection_before;
315
+ this.history_index = this.history_index - 1;
316
+ return this;
317
+ }
318
+
319
+ redo() {
320
+ if (this.history_index >= this.history.length - 1) {
321
+ return;
322
+ }
323
+ this.history_index = this.history_index + 1;
324
+ const change = this.history[this.history_index];
325
+ let doc = this.doc;
326
+ change.ops.forEach((op) => {
327
+ doc = apply_op(doc, op);
328
+ });
329
+ this.doc = doc;
330
+ this.selection = change.selection_after;
331
+ return this;
332
+ }
333
+
334
+ /**
335
+ * Gets a node instance or property value at the specified path.
336
+ * @param {DocumentPath|string} path - Path to the node or property
337
+ * @returns {any} Either a node instance object or the value of a property
338
+ * @example
339
+ * // Get a node by ID
340
+ * session.get('list_1') // => { type: 'list', id: 'list_1', ... }
341
+ *
342
+ * @example
343
+ * // Get a node array property
344
+ * session.get(['list_1', 'list_items']) // => [ 'list_item_1', 'list_item_2' ]
345
+ *
346
+ * @example
347
+ * // Get a specific node from an array
348
+ * session.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
349
+ *
350
+ * @example
351
+ * // Get an annotated text property
352
+ * session.get(['page_1', 'cover', 'title']) // => {text: 'Hello world', annotations: []}
353
+ */
354
+ get(path) {
355
+ return doc_get(this.schema, this.doc, path);
356
+ }
357
+
358
+ /**
359
+ * While .get gives you the value of a path, inspect gives you
360
+ * the type info of that value.
361
+ *
362
+ * @todo The layout of these should be improved and more explictly typed
363
+ *
364
+ * @example
365
+ * session.inspect(['page_1', 'body']) => {
366
+ * kind: 'property',
367
+ * name: 'body',
368
+ * type: 'node_array',
369
+ * node_types: ['text', 'story', 'list'],
370
+ * default_node_type: 'text'
371
+ * }
372
+ *
373
+ * @example
374
+ * session.inspect(['page_1', 'body', 1]) => {
375
+ * kind: 'node',
376
+ * id: 'paragraph_234',
377
+ * type: 'paragraph',
378
+ * properties: {...}
379
+ * }
380
+ *
381
+ * @param {DocumentPath} path
382
+ * @returns {{kind: 'property'|'node', [key: string]: any}}
383
+ */
384
+ inspect(path) {
385
+ return doc_inspect(this.schema, this.doc, path);
386
+ }
387
+
388
+ /**
389
+ * Determines the kind of a node ('block' for structured blocks, 'text' for pure
390
+ * text nodes or 'annotation' for annotation nodes.
391
+ * @param {any} node
392
+ * @returns {NodeKind}
393
+ */
394
+ kind(node) {
395
+ return doc_kind(this.schema, node);
396
+ }
397
+
398
+ /**
399
+ * Determines whether a node type can be inserted at a given selection.
400
+ * @param {string} node_type - The type of node to insert.
401
+ * @param {Selection} [selection] - The selection at which to insert the node.
402
+ * @returns {boolean} True if the node type can be inserted, false otherwise.
403
+ */
404
+ can_insert(node_type, selection = this.selection) {
405
+ if (selection?.type === 'node') {
406
+ const property_definition = this.inspect(selection.path);
407
+ if (property_definition.node_types.includes(node_type)) {
408
+ return true;
409
+ }
410
+ }
411
+
412
+ // No insert position found yet, and root not reached, try one level up if possible
413
+ let next_node_insert_cursor = this.get_next_node_insert_cursor(selection);
414
+ if (!next_node_insert_cursor) return false;
415
+ return this.can_insert(node_type, next_node_insert_cursor);
416
+ }
417
+
418
+ /**
419
+ * Compute next possible insert position from a given selection
420
+ *
421
+ * @param {Selection} [selection] - Reference selection
422
+ * @returns {Selection} True if the paste operation was successful, false otherwise
423
+ */
424
+ get_next_node_insert_cursor(selection = this.selection) {
425
+ // There's no parent path to insert into
426
+ if (!selection || selection.path.length <= 2) {
427
+ return null;
428
+ }
429
+
430
+ const node_offset = parseInt(String(selection.path.at(-2)), 10) + 1;
431
+ return {
432
+ type: 'node',
433
+ path: selection.path.slice(0, -2),
434
+ anchor_offset: node_offset,
435
+ focus_offset: node_offset
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Returns the annotation object that is currently "under the cursor".
441
+ * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
442
+ *
443
+ * @param {string} [annotation_type] Optional annotation type to filter by
444
+ * @returns {Annotation|null}
445
+ */
446
+ active_annotation(annotation_type) {
447
+ return get_active_annotation(this.schema, this.doc, this.selection, annotation_type);
448
+ }
449
+
450
+ get_selected_annotated_text() {
451
+ if (this.selection?.type !== 'text') return null;
452
+
453
+ const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
454
+ const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
455
+ const annotated_text = this.get(this.selection.path);
456
+ const text = char_slice(annotated_text.text, selection_start, selection_end);
457
+ const nodes = {};
458
+ const annotations = annotated_text.annotations
459
+ .map((a) => {
460
+ if (selection_start < a.end_offset && selection_end > a.start_offset) {
461
+ const sub_graph = this.traverse(a.node_id);
462
+ for (const node of sub_graph) {
463
+ if (!nodes[node.id]) {
464
+ nodes[node.id] = node;
465
+ }
466
+ }
467
+ return {
468
+ start_offset: Math.max(a.start_offset - selection_start, 0),
469
+ end_offset: Math.min(a.end_offset - selection_start, selection_end - selection_start),
470
+ node_id: a.node_id
471
+ };
472
+ } else {
473
+ return null;
474
+ }
475
+ })
476
+ .filter(Boolean);
477
+
478
+ return { text, annotations, nodes };
479
+ }
480
+
481
+ // TODO: think about ways how we can also turn a node selection into plain text.
482
+ get_selected_plain_text() {
483
+ if (this.selection?.type !== 'text') return null;
484
+
485
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
486
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
487
+ const annotated_text = this.get(this.selection.path);
488
+ return char_slice(annotated_text.text, start, end);
489
+ }
490
+
491
+ get_selected_nodes() {
492
+ if (this.selection?.type !== 'node') return null;
493
+
494
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
495
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
496
+ const node_array = this.get(this.selection.path);
497
+ return $state.snapshot(node_array.slice(start, end));
498
+ }
499
+
500
+ select_parent() {
501
+ if (!this.selection) return;
502
+ if (['text', 'property'].includes(this.selection?.type)) {
503
+ // For text and property selections (e.g. ['page_1', 'body', 0, 'image']), we need to go up two levels
504
+ // in the path
505
+ if (this.selection.path.length > 3) {
506
+ const parent_path = this.selection.path.slice(0, -2);
507
+ const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
508
+ this.selection = {
509
+ type: 'node',
510
+ path: parent_path,
511
+ anchor_offset: current_index,
512
+ focus_offset: current_index + 1
513
+ };
514
+ } else {
515
+ this.selection = null;
516
+ }
517
+ } else if (this.selection.type === 'node') {
518
+ // For node selections, we go up one level
519
+ if (this.selection.path.length > 3) {
520
+ const parent_path = this.selection.path.slice(0, -2);
521
+ const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
522
+
523
+ this.selection = {
524
+ type: 'node',
525
+ path: parent_path,
526
+ anchor_offset: current_index,
527
+ focus_offset: current_index + 1
528
+ };
529
+ } else {
530
+ this.selection = null;
531
+ }
532
+ } else {
533
+ this.selection = null;
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Traverses the document and returns a list of nodes in depth-first order.
539
+ *
540
+ * The traversal order is:
541
+ * 1. Leaf nodes first
542
+ * 2. Branch nodes second
543
+ * 3. Root node (entry point) last
544
+ *
545
+ * @param {string} node_id - The ID of the node to start traversing from
546
+ * @returns {Array<DocumentNode>} Array of nodes in depth-first order
547
+ * @note Nodes that are not reachable from the entry point node will not be included
548
+ */
549
+ traverse(node_id) {
550
+ return traverse(node_id, this.schema, $state.snapshot(this.doc.nodes));
551
+ }
552
+
553
+ /**
554
+ * Convert the document to a clean format for persistence.
555
+ *
556
+ * We make a traversal to ensure that orphaned nodes are not included,
557
+ * and that leaf nodes go first, followed by branches and the root node at last.
558
+ *
559
+ * @returns {Document} The document
560
+ */
561
+ to_json() {
562
+ // this will order the nodes (depth-first traversal)
563
+ const nodes_array = this.traverse(this.document_id);
564
+ // convert nodes array to object with node IDs as keys
565
+ const nodes = Object.fromEntries(nodes_array.map((node) => [node.id, node]));
566
+ return {
567
+ document_id: this.document_id,
568
+ nodes
569
+ };
570
+ }
571
+
572
+ // property_type('page', 'body') => 'node_array'
573
+ // property_type('paragraph', 'content') => 'annotated_text'
574
+ property_type(type, property) {
575
+ return doc_property_type(this.schema, type, property);
576
+ }
577
+
578
+ // Count how many times a node is referenced in the document
579
+ count_references(node_id) {
580
+ return doc_count_references(this.schema, this.doc, node_id);
581
+ }
582
+
583
+ // Get all nodes referenced by a given node (recursively)
584
+ /**
585
+ * @param {NodeId} node_id
586
+ * @returns {NodeId[]}
587
+ */
588
+ get_referenced_nodes(node_id) {
589
+ const traversed_nodes = this.traverse(node_id);
590
+
591
+ // Extract IDs and exclude the last element (root node)
592
+ return traversed_nodes.slice(0, -1).map((node) => node.id);
593
+ }
594
+ }