svedit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import Transaction from './Transaction.svelte.js';
2
- import { char_slice } from './util.js';
2
+ import { char_slice, get_char_length } from './util.js';
3
3
 
4
4
  /**
5
5
  * @import {
@@ -11,6 +11,10 @@ import { char_slice } from './util.js';
11
11
  * DocumentSchemaPrimitive,
12
12
  * NodeProperty,
13
13
  * NodeArrayProperty,
14
+ * NodeKind,
15
+ * NodeSchema,
16
+ * TextNodeSchema,
17
+ * NonTextNodeSchema,
14
18
  * DocumentSchema,
15
19
  * SerializedDocument
16
20
  * } from './types.d.ts';
@@ -20,7 +24,7 @@ import { char_slice } from './util.js';
20
24
  * Identity function — keeps schema at runtime & makes IDE infer types.
21
25
  * Similar to your define_schema pattern but for document schemas.
22
26
  *
23
- * @template {Record<string, Record<string, {type: DocumentSchemaPrimitive}>>} S
27
+ * @template {Record<string, NodeSchema>} S
24
28
  * @param {S} schema - The document schema to validate
25
29
  * @returns {S} The same schema, but with type information preserved
26
30
  */
@@ -36,7 +40,7 @@ export function define_document_schema(schema) {
36
40
  export function is_primitive_type(type) {
37
41
  return [
38
42
  'string', 'number', 'boolean', 'integer', 'datetime',
39
- 'annotated_string', 'string_array', 'number_array',
43
+ 'annotated_text', 'string_array', 'number_array',
40
44
  'boolean_array', 'integer_array'
41
45
  ].includes(type);
42
46
  }
@@ -63,7 +67,7 @@ export function get_default_node_type(property_definition) {
63
67
  export function validate_document_schema(document_schema) {
64
68
  // Check that all referenced node types exist
65
69
  for (const [node_type, node_schema] of Object.entries(document_schema)) {
66
- for (const [prop_name, prop_def] of Object.entries(node_schema)) {
70
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
67
71
  if (prop_def.type === 'node' || prop_def.type === 'node_array') {
68
72
  const missing_types = prop_def.node_types.filter(ref_type => !(ref_type in document_schema));
69
73
  if (missing_types.length > 0) {
@@ -92,11 +96,11 @@ function validate_primitive_value(type, value) {
92
96
  return Number.isInteger(value);
93
97
  case 'datetime':
94
98
  return typeof value === 'string' && !isNaN(Date.parse(value));
95
- case 'annotated_string':
96
- return Array.isArray(value) &&
97
- value.length === 2 &&
98
- typeof value[0] === 'string' &&
99
- Array.isArray(value[1]);
99
+ case 'annotated_text':
100
+ return typeof value === 'object' &&
101
+ value !== null &&
102
+ typeof value.text === 'string' &&
103
+ Array.isArray(value.annotations);
100
104
  case 'string_array':
101
105
  return Array.isArray(value) && value.every(v => typeof v === 'string');
102
106
  case 'number_array':
@@ -136,7 +140,7 @@ function validate_node(node, schema, all_nodes = {}) {
136
140
 
137
141
  const node_schema = schema[node.type];
138
142
 
139
- for (const [prop_name, prop_def] of Object.entries(node_schema)) {
143
+ for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
140
144
  const value = node[prop_name];
141
145
 
142
146
  // Check primitive types
@@ -177,7 +181,7 @@ function validate_node(node, schema, all_nodes = {}) {
177
181
 
178
182
  export default class Document {
179
183
  /** @type {Selection | undefined} */
180
- selection = $state();
184
+ #selection = $state();
181
185
  config = $state();
182
186
  document_id = $state();
183
187
  nodes = $state();
@@ -190,6 +194,86 @@ export default class Document {
190
194
 
191
195
  // Reactive variable for selected node
192
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
+ }
193
277
 
194
278
  /**
195
279
  * @param {DocumentSchema} schema - The document schema
@@ -203,7 +287,6 @@ export default class Document {
203
287
  validate_document_schema(schema);
204
288
 
205
289
  this.schema = schema;
206
- this.selection = selection;
207
290
  this.config = config;
208
291
  this.nodes = {};
209
292
 
@@ -216,6 +299,9 @@ export default class Document {
216
299
 
217
300
  // The last element in the serialized_doc is the document itself (the root node)
218
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;
219
305
  }
220
306
 
221
307
  generate_id() {
@@ -226,6 +312,13 @@ export default class Document {
226
312
  }
227
313
  }
228
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
+
229
322
  // Helper function to get the currently selected node
230
323
  get_selected_node() {
231
324
  if (!this.selection) return null;
@@ -240,13 +333,37 @@ export default class Document {
240
333
  return node_id ? this.get(node_id) : null;
241
334
  } else {
242
335
  // we are assuming we are either in a text or property (=custom) selection
243
- const node_id = this.selection?.path?.slice(0, -1);
244
- if (!node_id) return null;
245
- const owner_node = this.get(node_id);
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);
246
339
  return owner_node;
247
340
  }
248
341
  }
249
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
+
250
367
  // Internal unsafe function: Never call this directly
251
368
  _apply_op(op) {
252
369
  const [type, ...args] = op;
@@ -352,8 +469,8 @@ export default class Document {
352
469
  * doc.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
353
470
  *
354
471
  * @example
355
- * // Get an annotated string property
356
- * doc.get(['page_1', 'cover', 'title']) // => ['Hello world', []]
472
+ * // Get an annotated text property
473
+ * doc.get(['page_1', 'cover', 'title']) // => {text: 'Hello world', annotations: []}
357
474
  */
358
475
  get(path) {
359
476
  if (typeof path === 'string') {
@@ -372,6 +489,9 @@ export default class Document {
372
489
  if (this.property_type(val.type, path_segment) === 'node_array') {
373
490
  val = val[path_segment]; // e.g. for the page body ['list_1', 'paragraph_1']
374
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';
375
495
  } else if (this.property_type(val.type, path_segment) === 'node') {
376
496
  val = this.nodes[val[path_segment]];
377
497
  val_type = 'node';
@@ -379,7 +499,7 @@ export default class Document {
379
499
  val = val[path_segment];
380
500
  val_type = 'value_array';
381
501
  } else {
382
- // Resolve value properties sucha as string, integer, datetime
502
+ // Resolve value properties such as string, integer, datetime
383
503
  val = val[path_segment];
384
504
  val_type = 'value';
385
505
  }
@@ -390,6 +510,32 @@ export default class Document {
390
510
  } else if (val_type === 'value_array') {
391
511
  val = val[path_segment];
392
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
+ }
393
539
  }
394
540
  }
395
541
  return val;
@@ -430,7 +576,7 @@ export default class Document {
430
576
  kind: 'property',
431
577
  name: property_name,
432
578
  // Merge property spec from schema
433
- ...this.schema[parent.type][property_name]
579
+ ...this.schema[parent.type].properties[property_name]
434
580
  };
435
581
  } else {
436
582
  // Parent is a property (or we are at the root), so we are dealing with a node.
@@ -445,17 +591,13 @@ export default class Document {
445
591
  }
446
592
 
447
593
  /**
448
- * Determines the kind of a node ('text' for pure text nodes or 'node' for anything else)
449
- * NOTE: currently we assume a 'content' property for pure text nodes
594
+ * Determines the kind of a node ('block' for structured blocks, 'text' for pure
595
+ * text nodes or 'annotation' for annotation nodes.
450
596
  * @param {any} node
451
- * @returns {'text'|'node'}
597
+ * @returns {NodeKind}
452
598
  */
453
599
  kind(node) {
454
- if (['annotated_string', 'string'].includes(this.schema[node.type]?.content?.type)) {
455
- return 'text'
456
- } else {
457
- return 'node';
458
- }
600
+ return this.schema[node.type].kind;
459
601
  }
460
602
 
461
603
  /**
@@ -469,17 +611,18 @@ export default class Document {
469
611
  if (this.selection?.type !== 'text') return null;
470
612
 
471
613
  const { start, end } = this.get_selection_range();
472
- const annotated_string = this.get(this.selection.path);
473
- const annotations = annotated_string[1];
614
+ const annotated_text = this.get(this.selection.path);
615
+ const annotations = annotated_text.annotations;
474
616
 
475
- const active_annotation = annotations.find(([annotation_start, annotation_end]) =>
476
- (annotation_start <= start && annotation_end > start) ||
477
- (annotation_start < end && annotation_end >= end) ||
478
- (annotation_start >= start && annotation_end <= end)
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)
479
621
  ) || null;
480
622
 
481
- if (annotation_type) {
482
- return active_annotation?.[2] === annotation_type ? active_annotation : null;
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;
483
626
  } else {
484
627
  return active_annotation;
485
628
  }
@@ -491,8 +634,8 @@ export default class Document {
491
634
 
492
635
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
493
636
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
494
- const annotated_string = this.get(this.selection.path);
495
- return char_slice(annotated_string[0], start, end);
637
+ const annotated_text = this.get(this.selection.path);
638
+ return char_slice(annotated_text.text, start, end);
496
639
  }
497
640
 
498
641
  get_selected_nodes() {
@@ -504,41 +647,6 @@ export default class Document {
504
647
  return $state.snapshot(node_array.slice(start, end));
505
648
  }
506
649
 
507
- move_node_cursor(direction) {
508
- if (this.selection?.type !== 'node') return;
509
- const node_array = this.get(this.selection.path);
510
-
511
- const { start, end } = this.get_selection_range();
512
-
513
- if (this.selection.anchor_offset !== this.selection.focus_offset) {
514
- // If selection is not collapsed, collapse it to the right or the left
515
- if (direction === 'forward') {
516
- this.selection.focus_offset = end;
517
- this.selection.anchor_offset = end;
518
- } else if (direction === 'backward') {
519
- this.selection.focus_offset = start;
520
- this.selection.anchor_offset = start;
521
- }
522
- } else if (direction === 'forward' && end < node_array.length) {
523
- this.selection.focus_offset = end + 1;
524
- this.selection.anchor_offset = end + 1;
525
- } else if (direction === 'backward' && start > 0) {
526
- this.selection.focus_offset = start - 1;
527
- this.selection.anchor_offset = start - 1;
528
- }
529
- }
530
-
531
- expand_node_selection(direction) {
532
- if (this.selection.type !== 'node') return;
533
- const node_array = this.get(this.selection.path);
534
-
535
- if (direction === 'forward') {
536
- this.selection.focus_offset = Math.min(this.selection.focus_offset + 1, node_array.length);
537
- } else if (direction === 'backward') {
538
- this.selection.focus_offset = Math.max(this.selection.focus_offset - 1, 0);
539
- }
540
- }
541
-
542
650
  select_parent() {
543
651
  if (!this.selection) return;
544
652
  if (['text', 'property'].includes(this.selection?.type)) {
@@ -602,17 +710,21 @@ export default class Document {
602
710
  return;
603
711
  }
604
712
  visited[node.id] = true;
605
- for (const [, value] of Object.entries(node)) {
606
- // TODO: Use schema inspection and do this only for properties of type `node_array`
607
- if (Array.isArray(value)) {
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') {
608
717
  for (const v of value) {
609
718
  if (typeof v === 'string') {
610
719
  visit($state.snapshot(this.get(v)));
611
720
  }
612
721
  }
613
- } else if (typeof value === 'string') {
614
- // TODO: Use schema inspection and do this only for properties of type `ref`
722
+ } else if (property_schema?.type === 'node') {
615
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
+ }
616
728
  }
617
729
  }
618
730
  // Finally add the node to the result.
@@ -633,7 +745,7 @@ export default class Document {
633
745
  }
634
746
 
635
747
  // property_type('page', 'body') => 'node_array'
636
- // property_type('paragraph', 'content') => 'annotated_string'
748
+ // property_type('paragraph', 'content') => 'annotated_text'
637
749
  property_type(type, property) {
638
750
  if (typeof type !== 'string') throw new Error(`Invalid type ${type} provided`);
639
751
  if (typeof property !== 'string') throw new Error(`Invalid property ${property} provided`);
@@ -642,9 +754,9 @@ export default class Document {
642
754
  if (property === 'id') return 'string';
643
755
 
644
756
  if (!this.schema[type]) throw new Error(`Type ${type} not found in schema`);
645
- if (!this.schema[type][property]) throw new Error(`Property ${property} not found in type ${type}`);
757
+ if (!this.schema[type].properties[property]) throw new Error(`Property ${property} not found in type ${type}`);
646
758
 
647
- return this.schema[type][property].type;
759
+ return this.schema[type].properties[property].type;
648
760
  }
649
761
 
650
762
  // Count how many times a node is referenced in the document