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