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