svedit 0.5.0 → 0.6.1

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 (62) hide show
  1. package/README.md +761 -120
  2. package/dist/AnnotatedTextProperty.svelte +129 -76
  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/{Document.svelte.d.ts → Session.svelte.d.ts} +76 -83
  17. package/dist/Session.svelte.d.ts.map +1 -0
  18. package/dist/Session.svelte.js +594 -0
  19. package/dist/Svedit.svelte +1338 -1512
  20. package/dist/Svedit.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.d.ts +114 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +845 -661
  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} +22 -2
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +338 -0
  38. package/package.json +20 -18
  39. package/src/lib/AnnotatedTextProperty.svelte +129 -76
  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 +1338 -1512
  47. package/src/lib/Transaction.svelte.js +845 -661
  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.map +0 -1
  55. package/dist/Document.svelte.js +0 -833
  56. package/dist/commands.svelte.d.ts.map +0 -1
  57. package/dist/commands.svelte.js +0 -266
  58. package/dist/util.d.ts.map +0 -1
  59. package/dist/util.js +0 -269
  60. package/src/lib/Document.svelte.js +0 -833
  61. package/src/lib/commands.svelte.js +0 -266
  62. package/src/lib/util.js +0 -269
@@ -1,833 +0,0 @@
1
- import Transaction from './Transaction.svelte.js';
2
- import { char_slice, get_char_length, traverse } from './util.js';
3
-
4
- /**
5
- * @import {
6
- * NodeId,
7
- * DocumentPath,
8
- * Selection,
9
- * Annotation,
10
- * PrimitiveType,
11
- * NodeProperty,
12
- * NodeArrayProperty,
13
- * NodeKind,
14
- * NodeSchema,
15
- * DocumentSchema,
16
- * SerializedDocument
17
- * } from './types.d.ts';
18
- */
19
-
20
- /**
21
- * Identity function — keeps schema at runtime & makes IDE infer types.
22
- * Similar to your define_schema pattern but for document schemas.
23
- *
24
- * @template {Record<string, NodeSchema>} S
25
- * @param {S} schema - The document schema to validate
26
- * @returns {S} The same schema, but with type information preserved
27
- */
28
- export function define_document_schema(schema) {
29
- return schema;
30
- }
31
-
32
- /**
33
- * Check if a string represents a valid primitive type.
34
- * @param {string} type - The type string to check
35
- * @returns {type is PrimitiveType} True if it's a valid primitive type
36
- */
37
- export function is_primitive_type(type) {
38
- return [
39
- 'string', 'number', 'boolean', 'integer', 'datetime',
40
- 'annotated_text', 'string_array', 'number_array',
41
- 'boolean_array', 'integer_array'
42
- ].includes(type);
43
- }
44
-
45
- /**
46
- * Get the default node type for a property that references nodes.
47
- * @param {NodeProperty | NodeArrayProperty} property_definition - The property definition
48
- * @returns {string | null} The default node type, or null if none specified
49
- */
50
- export function get_default_node_type(property_definition) {
51
- if (!property_definition || !property_definition.node_types) {
52
- return null;
53
- }
54
-
55
- return property_definition.default_node_type ||
56
- (property_definition.node_types.length === 1 ? property_definition.node_types[0] : null);
57
- }
58
-
59
- /**
60
- * Validate a document schema to ensure all referenced node types exist.
61
- * @param {DocumentSchema} document_schema - The document schema to validate
62
- * @throws {Error} Throws if the document schema is invalid
63
- */
64
- export function validate_document_schema(document_schema) {
65
- // Check that all referenced node types exist
66
- for (const [node_type, node_schema] of Object.entries(document_schema)) {
67
- for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
68
- if (prop_def.type === 'node' || prop_def.type === 'node_array') {
69
- const missing_types = prop_def.node_types.filter(ref_type => !(ref_type in document_schema));
70
- if (missing_types.length > 0) {
71
- 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(', ')}`);
72
- }
73
- }
74
- }
75
- }
76
- }
77
-
78
- /**
79
- * Validate a primitive value against its schema type.
80
- * @param {PrimitiveType} type - The expected type
81
- * @param {any} value - The value to validate
82
- * @returns {boolean} True if the value matches the type
83
- */
84
- function validate_primitive_value(type, value) {
85
- switch (type) {
86
- case 'string':
87
- return typeof value === 'string';
88
- case 'number':
89
- return typeof value === 'number' && !isNaN(value);
90
- case 'boolean':
91
- return typeof value === 'boolean';
92
- case 'integer':
93
- return Number.isInteger(value);
94
- case 'datetime':
95
- return typeof value === 'string' && !isNaN(Date.parse(value));
96
- case 'annotated_text':
97
- return typeof value === 'object' &&
98
- value !== null &&
99
- typeof value.text === 'string' &&
100
- Array.isArray(value.annotations);
101
- case 'string_array':
102
- return Array.isArray(value) && value.every(v => typeof v === 'string');
103
- case 'number_array':
104
- return Array.isArray(value) && value.every(v => typeof v === 'number' && !isNaN(v));
105
- case 'boolean_array':
106
- return Array.isArray(value) && value.every(v => typeof v === 'boolean');
107
- case 'integer_array':
108
- return Array.isArray(value) && value.every(v => Number.isInteger(v));
109
- default:
110
- return false;
111
- }
112
- }
113
-
114
- /**
115
- * @param {String} id
116
- * @returns {boolean}
117
- */
118
- function is_id_valid(id) {
119
- return typeof id === 'string' && id.length > 0
120
- }
121
-
122
- /**
123
- * Validate a node against its schema.
124
- * @param {any} node - The node to validate
125
- * @param {DocumentSchema} schema - The document schema
126
- * @param {Record<string, any>} all_nodes - All nodes in the document to check references
127
- * @throws {Error} Throws if the node is invalid
128
- */
129
- function validate_node(node, schema, all_nodes = {}) {
130
- if (!is_id_valid(node.id)) {
131
- throw new Error(`Node ${node.id} has an invalid id.`);
132
- }
133
-
134
- if (!node.type || !schema[node.type]) {
135
- throw new Error(`Node ${node.id} has an invalid type: ${node.type}`);
136
- }
137
-
138
- const node_schema = schema[node.type];
139
-
140
- for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
141
- const value = node[prop_name];
142
-
143
- // Check primitive types
144
- if (is_primitive_type(prop_def.type)) {
145
- if (!validate_primitive_value(prop_def.type, value)) {
146
- throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be of type ${prop_def.type}.`);
147
- }
148
- }
149
- // Check node references
150
- if (prop_def.type === 'node') {
151
- if (!is_id_valid(value)) {
152
- throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be a valid node id.`);
153
- }
154
- // Check if referenced node exists and is of allowed type
155
- const referenced_node = all_nodes[value];
156
- if (
157
- referenced_node &&
158
- !prop_def.node_types.includes(referenced_node.type)
159
- ) {
160
- 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.`);
161
- }
162
- }
163
- // Check node arrays
164
- else if (prop_def.type === 'node_array') {
165
- if (!Array.isArray(value) || !value.every(id => typeof id === 'string' && is_id_valid(id))) {
166
- throw new Error(`Node ${node.id} has an invalid property: ${prop_name} must be an array of node ids.`);
167
- }
168
- // Check if all referenced nodes are of allowed types
169
- for (const ref_id of value) {
170
- const referenced_node = all_nodes[ref_id];
171
- if (referenced_node && !prop_def.node_types.includes(referenced_node.type)) {
172
- 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.`);
173
- }
174
- }
175
- }
176
- }
177
- }
178
-
179
- export default class Document {
180
- /** @type {Selection | undefined} */
181
- #selection = $state();
182
- config = $state();
183
- document_id = $state();
184
- nodes = $state();
185
- history = $state([]);
186
- history_index = $state(-1);
187
-
188
- // Reactive helpers for UI state
189
- can_undo = $derived(this.history_index >= 0);
190
- can_redo = $derived(this.history_index < this.history.length - 1);
191
-
192
- // Reactive variable for selected node
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
- }
274
-
275
- /**
276
- * @param {DocumentSchema} schema - The document schema
277
- * @param {SerializedDocument} serialized_doc - The serialized document array
278
- * @param {object} [options] - Optional configuration
279
- * @param {Selection} [options.selection] - Initial selection state
280
- * @param {any} [options.config] - Document configuration
281
- */
282
- constructor(schema, serialized_doc, { selection, config } = {}) {
283
- // Validate the schema first
284
- validate_document_schema(schema);
285
-
286
- this.schema = schema;
287
- this.config = config;
288
- this.nodes = {};
289
-
290
- // Initialize and validate nodes one by one
291
- // This ensures references only point to already-loaded nodes
292
- for (const node of serialized_doc) {
293
- this.validate_node(node);
294
- this.nodes[node.id] = node;
295
- }
296
-
297
- // The last element in the serialized_doc is the document itself (the root node)
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;
302
- }
303
-
304
- generate_id() {
305
- if (this.config.generate_id) {
306
- return this.config.generate_id();
307
- } else {
308
- return crypto.randomUUID();
309
- }
310
- }
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
-
319
- // Helper function to get the currently selected node
320
- get_selected_node() {
321
- if (!this.selection) return null;
322
-
323
- if (this.selection.type === 'node') {
324
- const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
325
- const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
326
- // Only consider selection of a single node
327
- if (end - start !== 1) return null;
328
- const node_array = this.get(this.selection.path);
329
- const node_id = node_array[start];
330
- return node_id ? this.get(node_id) : null;
331
- } else {
332
- // we are assuming we are either in a text or property (=custom) selection
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);
336
- return owner_node;
337
- }
338
- }
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
-
364
- // Internal unsafe function: Never call this directly
365
- _apply_op(op) {
366
- const [type, ...args] = op;
367
- if (type === 'set') {
368
- const node = this.get(args[0].slice(0, -1));
369
- node[args[0].at(-1)] = args[1];
370
- } else if (type === 'create') {
371
-
372
- this.nodes[args[0].id] = args[0];
373
- } else if (type === 'delete') {
374
- delete this.nodes[args[0]];
375
- }
376
- }
377
-
378
- /**
379
- * Validate a node against the document schema.
380
- * @param {any} node - The node to validate
381
- * @throws {Error} Throws if the node is invalid
382
- */
383
- validate_node(node) {
384
- validate_node(node, this.schema, this.nodes);
385
- }
386
-
387
- /**
388
- * Creates a new transaction for making atomic changes to the document.
389
- *
390
- * @returns {Transaction} A new transaction instance
391
- */
392
- get tr() {
393
- // We create a copy of the current document to avoid modifying the original
394
- const transaction_doc = new Document(this.schema, this.to_json(), {
395
- config: this.config,
396
- selection: this.selection
397
- });
398
- return new Transaction(transaction_doc);
399
- }
400
-
401
- /**
402
- * Applies a transaction to the document.
403
- *
404
- * @param {Transaction} transaction - The transaction to apply
405
- */
406
- apply(transaction) {
407
- this.nodes = transaction.doc.nodes; // No deep copy, trust transaction's evolved state
408
- // Make sure selection gets a new reference (is rerendered)
409
- this.selection = { ...transaction.doc.selection };
410
- if (this.history_index < this.history.length - 1) {
411
- this.history = this.history.slice(0, this.history_index + 1);
412
- }
413
- this.history.push({
414
- ops: transaction.ops,
415
- inverse_ops: transaction.inverse_ops,
416
- selection_before: transaction.selection_before,
417
- selection_after: this.selection
418
- });
419
- this.history_index++;
420
-
421
- return this;
422
- }
423
-
424
- undo() {
425
- if (this.history_index < 0) {
426
- return;
427
- }
428
- const change = this.history[this.history_index];
429
- const tr = this.tr;
430
- change.inverse_ops.slice().reverse().forEach(op => tr.doc._apply_op(op));
431
- this.nodes = tr.doc.nodes;
432
- this.selection = change.selection_before;
433
- this.history_index--;
434
- return this;
435
- }
436
-
437
- redo() {
438
- if (this.history_index >= this.history.length - 1) {
439
- return;
440
- }
441
- this.history_index++;
442
- const change = this.history[this.history_index];
443
- const tr = this.tr;
444
- change.ops.forEach(op => tr.doc._apply_op(op));
445
- tr.set_selection(change.selection_after);
446
-
447
- this.nodes = tr.doc.nodes;
448
- this.selection = change.selection_after;
449
- return this;
450
- }
451
-
452
- /**
453
- * Gets a node instance or property value at the specified path.
454
- * @param {DocumentPath|string} path - Path to the node or property
455
- * @returns {any} Either a node instance object or the value of a property
456
- * @example
457
- * // Get a node by ID
458
- * doc.get('list_1') // => { type: 'list', id: 'list_1', ... }
459
- *
460
- * @example
461
- * // Get a node array property
462
- * doc.get(['list_1', 'list_items']) // => [ 'list_item_1', 'list_item_2' ]
463
- *
464
- * @example
465
- * // Get a specific node from an array
466
- * doc.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
467
- *
468
- * @example
469
- * // Get an annotated text property
470
- * doc.get(['page_1', 'cover', 'title']) // => {text: 'Hello world', annotations: []}
471
- */
472
- get(path) {
473
- if (typeof path === 'string') {
474
- path = [ path ];
475
- }
476
- if (!(Array.isArray(path) && path.length >= 1)) {
477
- throw new Error(`Invalid path provided ${JSON.stringify(path)}`);
478
- }
479
-
480
- let val = this.nodes[path[0]];
481
- let val_type = 'node';
482
-
483
- for (let i = 1; i < path.length; i++) {
484
- const path_segment = path[i];
485
- if (val_type === 'node') {
486
- if (this.property_type(val.type, path_segment) === 'node_array') {
487
- val = val[path_segment]; // e.g. for the page body ['list_1', 'paragraph_1']
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';
492
- } else if (this.property_type(val.type, path_segment) === 'node') {
493
- val = this.nodes[val[path_segment]];
494
- val_type = 'node';
495
- } else if (['string_array', 'integer_array'].includes(this.property_type(val.type, path_segment))) {
496
- val = val[path_segment];
497
- val_type = 'value_array';
498
- } else {
499
- // Resolve value properties such as string, integer, datetime
500
- val = val[path_segment];
501
- val_type = 'value';
502
- }
503
- } else if (val_type === 'node_array') {
504
- // We expect the val to be an array of node ids and the path_segment to be an array index
505
- val = this.nodes[val[path_segment]];
506
- val_type = 'node';
507
- } else if (val_type === 'value_array') {
508
- val = val[path_segment];
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
- }
536
- }
537
- }
538
- return val;
539
- }
540
-
541
- /**
542
- * While .get gives you the value of a path, inspect gives you
543
- * the type info of that value.
544
- *
545
- * @todo The layout of these should be improved and more explictly typed
546
- *
547
- * @example
548
- * doc.inspect(['page_1', 'body']) => {
549
- * kind: 'property',
550
- * name: 'body',
551
- * type: 'node_array',
552
- * node_types: ['text', 'story', 'list'],
553
- * default_node_type: 'text'
554
- * }
555
- *
556
- * @example
557
- * doc.inspect(['page_1', 'body', 1]) => {
558
- * kind: 'node',
559
- * id: 'paragraph_234',
560
- * type: 'paragraph',
561
- * properties: {...}
562
- * }
563
- *
564
- * @param {DocumentPath} path
565
- * @returns {{kind: 'property'|'node', [key: string]: any}}
566
- */
567
- inspect(path) {
568
- const parent = path.length > 1 ? this.get(path.slice(0, -1)) : undefined;
569
- if (parent?.type) {
570
- // Parent is a node, so we are dealing with a property.
571
- const property_name = path.at(-1);
572
- return {
573
- kind: 'property',
574
- name: property_name,
575
- // Merge property spec from schema
576
- ...this.schema[parent.type].properties[property_name]
577
- };
578
- } else {
579
- // Parent is a property (or we are at the root), so we are dealing with a node.
580
- const node = this.get(path);
581
- return {
582
- kind: 'node',
583
- id: node.id,
584
- type: node.type,
585
- properties: this.schema[node.type]
586
- }
587
- }
588
- }
589
-
590
- /**
591
- * Determines the kind of a node ('block' for structured blocks, 'text' for pure
592
- * text nodes or 'annotation' for annotation nodes.
593
- * @param {any} node
594
- * @returns {NodeKind}
595
- */
596
- kind(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;
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
- };
639
- }
640
-
641
- /**
642
- * Returns the annotation object that is currently "under the cursor".
643
- * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
644
- *
645
- * @param {string} annotation_type
646
- * @returns {Annotation|null}
647
- */
648
- active_annotation(annotation_type) {
649
- if (this.selection?.type !== 'text') return null;
650
-
651
- const { start, end } = this.get_selection_range();
652
- const annotated_text = this.get(this.selection.path);
653
- const annotations = annotated_text.annotations;
654
-
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)
659
- ) || null;
660
-
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;
664
- } else {
665
- return active_annotation;
666
- }
667
- }
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
-
698
- // TODO: think about ways how we can also turn a node selection into plain text.
699
- get_selected_plain_text() {
700
- if (this.selection?.type !== 'text') return null;
701
-
702
- const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
703
- const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
704
- const annotated_text = this.get(this.selection.path);
705
- return char_slice(annotated_text.text, start, end);
706
- }
707
-
708
- get_selected_nodes() {
709
- if (this.selection?.type !== 'node') return null;
710
-
711
- const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
712
- const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
713
- const node_array = this.get(this.selection.path);
714
- return $state.snapshot(node_array.slice(start, end));
715
- }
716
-
717
- select_parent() {
718
- if (!this.selection) return;
719
- if (['text', 'property'].includes(this.selection?.type)) {
720
- // For text and property selections (e.g. ['page_1', 'body', 0, 'image']), we need to go up two levels
721
- // in the path
722
- if (this.selection.path.length > 3) {
723
- const parent_path = this.selection.path.slice(0, -2);
724
- const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
725
- this.selection = {
726
- type: 'node',
727
- path: parent_path,
728
- anchor_offset: current_index,
729
- focus_offset: current_index + 1
730
- };
731
- } else {
732
- this.selection = undefined;
733
- }
734
- } else if (this.selection.type === 'node') {
735
- // For node selections, we go up one level
736
- if (this.selection.path.length > 3) {
737
- const parent_path = this.selection.path.slice(0, -2);
738
- const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
739
-
740
- this.selection = {
741
- type: 'node',
742
- path: parent_path,
743
- anchor_offset: current_index,
744
- focus_offset: current_index + 1
745
- };
746
- } else {
747
- this.selection = undefined;
748
- }
749
- } else {
750
- this.selection = undefined;
751
- }
752
- }
753
-
754
- get_selection_range() {
755
- if (this.selection && this.selection.type !== 'property') {
756
- const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
757
- const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
758
-
759
- return {
760
- start,
761
- end,
762
- length: end - start
763
- };
764
- } else {
765
- return null;
766
- }
767
- }
768
-
769
- // Traverses the document and returns a JSON representation.
770
- // IMPORTANT: Leaf nodes must go first, branches second and the root node (entry point) last (depth-first traversal)
771
- // NOTE: Nodes that are not reachable from the entry point node will not be included in the serialization
772
- traverse(node_id) {
773
- return traverse(node_id, this.schema, $state.snapshot(this.nodes));
774
- }
775
-
776
- /**
777
- * Convert the document to serialized format.
778
- * @returns {SerializedDocument} The serialized document array
779
- */
780
- to_json() {
781
- return this.traverse(this.document_id);
782
- }
783
-
784
- // property_type('page', 'body') => 'node_array'
785
- // property_type('paragraph', 'content') => 'annotated_text'
786
- property_type(type, property) {
787
- if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
788
- if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
789
- // NOTE: Not sure if we should treat type and id as properties
790
- if (property === 'type') return 'string';
791
- if (property === 'id') return 'string';
792
-
793
- if (!this.schema[type]) throw new Error(`Type ${type} not found in schema`);
794
- if (!this.schema[type].properties[property]) throw new Error(`Property ${property} not found in type ${type}`);
795
-
796
- return this.schema[type].properties[property].type;
797
- }
798
-
799
- // Count how many times a node is referenced in the document
800
- count_references(node_id) {
801
- let count = 0;
802
-
803
- for (const node of Object.values(this.nodes)) {
804
- for (const [property, value] of Object.entries(node)) {
805
- if (property === 'id' || property === 'type') continue;
806
-
807
- const prop_type = this.property_type(node.type, property);
808
-
809
- if (prop_type === 'node_array' && Array.isArray(value)) {
810
- count += value.filter(id => id === node_id).length;
811
- } else if (prop_type === 'node' && value === node_id) {
812
- count += 1;
813
- }
814
- }
815
- }
816
-
817
- return count;
818
- }
819
-
820
- // Get all nodes referenced by a given node (recursively)
821
- /**
822
- * @param {NodeId} node_id
823
- * @returns {NodeId[]}
824
- */
825
- get_referenced_nodes(node_id) {
826
- const traversed_nodes = this.traverse(node_id);
827
-
828
- // Extract IDs and exclude the last element (root node)
829
- return traversed_nodes
830
- .slice(0, -1)
831
- .map(node => node.id);
832
- }
833
- }