svedit 0.11.1 → 0.12.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 (56) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -4
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +85 -27
  5. package/dist/Node.svelte +29 -1
  6. package/dist/Node.svelte.d.ts.map +1 -1
  7. package/dist/NodeArrayProperty.svelte +119 -12
  8. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  9. package/dist/NodeGap.svelte +48 -6
  10. package/dist/NodeGap.svelte.d.ts.map +1 -1
  11. package/dist/Session.svelte.d.ts +31 -14
  12. package/dist/Session.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.js +155 -77
  14. package/dist/Svedit.svelte +110 -89
  15. package/dist/Svedit.svelte.d.ts.map +1 -1
  16. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  17. package/dist/TextProperty.svelte.d.ts +7 -0
  18. package/dist/TextProperty.svelte.d.ts.map +1 -0
  19. package/dist/Transaction.svelte.d.ts +78 -40
  20. package/dist/Transaction.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.js +412 -234
  22. package/dist/doc_utils.d.ts +96 -21
  23. package/dist/doc_utils.d.ts.map +1 -1
  24. package/dist/doc_utils.js +440 -168
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/node_visibility.svelte.d.ts +30 -4
  28. package/dist/node_visibility.svelte.d.ts.map +1 -1
  29. package/dist/node_visibility.svelte.js +327 -363
  30. package/dist/paste_utils.d.ts +3 -2
  31. package/dist/paste_utils.d.ts.map +1 -1
  32. package/dist/paste_utils.js +16 -13
  33. package/dist/transforms.svelte.d.ts.map +1 -1
  34. package/dist/transforms.svelte.js +5 -6
  35. package/dist/types.d.ts +112 -42
  36. package/dist/utils.d.ts +75 -12
  37. package/dist/utils.d.ts.map +1 -1
  38. package/dist/utils.js +292 -78
  39. package/package.json +4 -1
  40. package/src/lib/Command.svelte.js +85 -27
  41. package/src/lib/Node.svelte +29 -1
  42. package/src/lib/NodeArrayProperty.svelte +119 -12
  43. package/src/lib/NodeGap.svelte +48 -6
  44. package/src/lib/Session.svelte.js +155 -77
  45. package/src/lib/Svedit.svelte +110 -89
  46. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  47. package/src/lib/Transaction.svelte.js +412 -234
  48. package/src/lib/doc_utils.js +440 -168
  49. package/src/lib/index.js +1 -1
  50. package/src/lib/node_visibility.svelte.js +327 -363
  51. package/src/lib/paste_utils.js +16 -13
  52. package/src/lib/transforms.svelte.js +5 -6
  53. package/src/lib/types.d.ts +112 -42
  54. package/src/lib/utils.js +292 -78
  55. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  56. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
@@ -1,17 +1,21 @@
1
1
  import Transaction from './Transaction.svelte.js';
2
- import { char_slice, traverse } from './utils.js';
2
+ import { char_slice, traverse, traverse_ids } from './utils.js';
3
3
  import {
4
4
  get as doc_get,
5
5
  property_type as doc_property_type,
6
6
  kind as doc_kind,
7
7
  inspect as doc_inspect,
8
- apply_op,
8
+ create_document_draft,
9
+ apply_op_to_draft,
9
10
  count_references as doc_count_references,
10
11
  validate_document_schema,
11
12
  validate_document,
13
+ validate_config_components,
12
14
  validate_node,
15
+ is_id_valid,
13
16
  get_referencing_node_ids,
14
- get_active_annotation,
17
+ get_selected_marks,
18
+ get_selected_annotations,
15
19
  validate_selection
16
20
  } from './doc_utils.js';
17
21
 
@@ -20,6 +24,8 @@ import {
20
24
  * NodeId,
21
25
  * DocumentPath,
22
26
  * Selection,
27
+ * Attachment,
28
+ * Mark,
23
29
  * Annotation,
24
30
  * NodeKind,
25
31
  * DocumentSchema,
@@ -63,8 +69,14 @@ export default class Session {
63
69
 
64
70
  // Reactive variable for selected node
65
71
  selected_node = $derived(this.get_selected_node());
72
+ available_mark_types = $derived(this.get_available_mark_types());
66
73
  available_annotation_types = $derived(this.get_available_annotation_types());
67
-
74
+ selected_marks = $derived(get_selected_marks(this.schema, this.doc, this.selection));
75
+ active_mark = $derived(this.selected_marks.length === 1 ? this.selected_marks[0] : null);
76
+ selected_annotations = $derived(get_selected_annotations(this.schema, this.doc, this.selection));
77
+ active_annotation = $derived(
78
+ this.selected_annotations.length === 1 ? this.selected_annotations[0] : null
79
+ );
68
80
 
69
81
  /**
70
82
  * @param {DocumentSchema} schema - The document schema
@@ -81,6 +93,7 @@ export default class Session {
81
93
  this.doc = doc;
82
94
  this.config = config;
83
95
  validate_document(this.doc, this.schema);
96
+ validate_config_components(this.schema, this.config);
84
97
 
85
98
  // Set selection after doc is initialized so validation can work properly
86
99
  this.selection = options.selection ?? null;
@@ -136,23 +149,33 @@ export default class Session {
136
149
  */
137
150
  validate_transaction_result(transaction) {
138
151
  const doc = transaction.doc;
139
- /** @type {NodeId[]} */
140
- const affected_node_ids = [];
141
-
142
- function add_node_id(node_id) {
143
- if (!affected_node_ids.includes(node_id)) affected_node_ids.push(node_id);
144
- }
145
-
146
- for (const node_id of transaction.created_node_ids) add_node_id(node_id);
147
- for (const node_id of transaction.modified_node_ids) add_node_id(node_id);
148
- for (const node_id of get_referencing_node_ids(this.schema, doc, transaction.created_node_ids)) {
149
- add_node_id(node_id);
150
- }
151
- for (const node_id of get_referencing_node_ids(this.schema, doc, transaction.modified_node_ids)) {
152
- add_node_id(node_id);
153
- }
154
- for (const node_id of get_referencing_node_ids(this.schema, doc, transaction.deleted_node_ids)) {
155
- add_node_id(node_id);
152
+ /** @type {Set<NodeId>} */
153
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
154
+ const affected_node_ids = new Set([
155
+ ...transaction.created_node_ids,
156
+ ...transaction.modified_node_ids
157
+ ]);
158
+
159
+ // A full-document referrer scan is only needed when a reference held
160
+ // by an UNTOUCHED node can have become invalid:
161
+ // - deletions can leave dangling references behind
162
+ // - a 'type' change can violate a referrer's node/mark/annotation type
163
+ // constraint
164
+ // References to created nodes can only live in nodes that were
165
+ // themselves created or modified in this transaction (the reference
166
+ // did not exist before), and modifying a node's other properties
167
+ // cannot invalidate its referrers — so those cases are already
168
+ // covered by the affected set and need no scan.
169
+ if (transaction.deleted_node_ids.length > 0 || transaction.changed_node_types) {
170
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
171
+ const scan_targets = new Set([
172
+ ...transaction.created_node_ids,
173
+ ...transaction.modified_node_ids,
174
+ ...transaction.deleted_node_ids
175
+ ]);
176
+ for (const node_id of get_referencing_node_ids(this.schema, doc, scan_targets)) {
177
+ affected_node_ids.add(node_id);
178
+ }
156
179
  }
157
180
 
158
181
  for (const node_id of affected_node_ids) {
@@ -162,11 +185,13 @@ export default class Session {
162
185
  }
163
186
 
164
187
  generate_id() {
165
- if (this.config?.generate_id) {
166
- return this.config.generate_id();
167
- } else {
168
- return crypto.randomUUID();
188
+ const id = this.config?.generate_id ? this.config.generate_id() : `node_${crypto.randomUUID()}`;
189
+ if (!is_id_valid(id)) {
190
+ throw new Error(
191
+ `Generated node id ${JSON.stringify(id)} is invalid. Node ids must be non-empty strings that start with a letter or underscore, contain only letters, numbers, underscores, or dashes, and must not contain "__".`
192
+ );
169
193
  }
194
+ return id;
170
195
  }
171
196
 
172
197
  /**
@@ -187,11 +212,16 @@ export default class Session {
187
212
  }
188
213
  }
189
214
 
215
+ get_available_mark_types() {
216
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
217
+ const property_definition = this.inspect(this.selection.path);
218
+ return property_definition.mark_types || [];
219
+ }
220
+
190
221
  get_available_annotation_types() {
191
- if (this.selection?.type !== 'text') return [];
192
- const path = this.selection.path;
193
- const property_definition = this.inspect(path);
194
- return property_definition.node_types || [];
222
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
223
+ const property_definition = this.inspect(this.selection.path);
224
+ return property_definition.annotation_types || [];
195
225
  }
196
226
 
197
227
  // Helper function to get the currently selected node
@@ -204,7 +234,7 @@ export default class Session {
204
234
  // Only consider selection of a single node
205
235
  if (end - start !== 1) return null;
206
236
  const node_array = this.get(this.selection.path);
207
- const node_id = node_array[start];
237
+ const node_id = node_array.nodes[start];
208
238
  return node_id ? this.get(node_id) : null;
209
239
  } else {
210
240
  // we are assuming we are either in a text or property (=custom) selection
@@ -235,7 +265,13 @@ export default class Session {
235
265
  */
236
266
  apply(transaction, { batch = false } = {}) {
237
267
  this.validate_transaction_result(transaction);
238
- this.doc = transaction.doc;
268
+ // Only swap the doc when something changed. The transaction draft is
269
+ // always a fresh object, and assigning it for an ops-less transaction
270
+ // (e.g. selection-only) would needlessly invalidate every doc-dependent
271
+ // derived in the component tree.
272
+ if (transaction.ops.length > 0) {
273
+ this.doc = transaction.doc;
274
+ }
239
275
  // Make sure selection gets a new reference (is rerendered)
240
276
  this.selection = structuredClone(transaction.selection);
241
277
  if (this.history_index < this.history.length - 1) {
@@ -284,12 +320,14 @@ export default class Session {
284
320
  return;
285
321
  }
286
322
  const change = this.history[this.history_index];
287
- let doc = this.doc;
323
+ // One draft for the whole change set — ops mutate the draft with
324
+ // node-level copy-on-write instead of copying the nodes map per op.
325
+ const doc = create_document_draft(this.doc);
288
326
  change.inverse_ops
289
327
  .slice()
290
328
  .reverse()
291
329
  .forEach((op) => {
292
- doc = apply_op(doc, op);
330
+ apply_op_to_draft(doc, op);
293
331
  });
294
332
  this.doc = doc;
295
333
  this.selection = change.selection_before;
@@ -303,9 +341,9 @@ export default class Session {
303
341
  }
304
342
  this.history_index = this.history_index + 1;
305
343
  const change = this.history[this.history_index];
306
- let doc = this.doc;
344
+ const doc = create_document_draft(this.doc);
307
345
  change.ops.forEach((op) => {
308
- doc = apply_op(doc, op);
346
+ apply_op_to_draft(doc, op);
309
347
  });
310
348
  this.doc = doc;
311
349
  this.selection = change.selection_after;
@@ -329,8 +367,8 @@ export default class Session {
329
367
  * session.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
330
368
  *
331
369
  * @example
332
- * // Get an annotated text property
333
- * session.get(['page_1', 'cover', 'title']) // => {text: 'Hello world', annotations: []}
370
+ * // Get a text property
371
+ * session.get(['page_1', 'cover', 'title']) // => {content: 'Hello world', marks: [], annotations: []}
334
372
  */
335
373
  get(path) {
336
374
  return doc_get(this.schema, this.doc, path);
@@ -368,7 +406,7 @@ export default class Session {
368
406
 
369
407
  /**
370
408
  * Determines the kind of a node ('block' for structured blocks, 'text' for pure
371
- * text nodes or 'annotation' for annotation nodes.
409
+ * text nodes, 'mark' for mark nodes or 'annotation' for annotation nodes.
372
410
  * @param {any} node
373
411
  * @returns {NodeKind}
374
412
  */
@@ -417,46 +455,82 @@ export default class Session {
417
455
  };
418
456
  }
419
457
 
420
- /**
421
- * Returns the annotation object that is currently "under the cursor".
422
- * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
423
- *
424
- * @param {string} [annotation_type] Optional annotation type to filter by
425
- * @returns {Annotation|null}
426
- */
427
- active_annotation(annotation_type) {
428
- return get_active_annotation(this.schema, this.doc, this.selection, annotation_type);
429
- }
430
-
431
- get_selected_annotated_text() {
458
+ get_selected_text() {
432
459
  if (this.selection?.type !== 'text') return null;
433
460
 
434
461
  const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
435
462
  const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
436
- const annotated_text = this.get(this.selection.path);
437
- const text = char_slice(annotated_text.text, selection_start, selection_end);
463
+ const text_value = this.get(this.selection.path);
464
+ const selected_text = char_slice(text_value.content, selection_start, selection_end);
438
465
  const nodes = {};
439
- const annotations = annotated_text.annotations
440
- .map((a) => {
441
- if (selection_start < a.end_offset && selection_end > a.start_offset) {
442
- const sub_graph = this.traverse(a.node_id);
443
- for (const node of sub_graph) {
444
- if (!nodes[node.id]) {
445
- nodes[node.id] = node;
466
+
467
+ const clip_ranges = (ranges) =>
468
+ ranges
469
+ .map((range) => {
470
+ if (selection_start < range.end_offset && selection_end > range.start_offset) {
471
+ const sub_graph = this.traverse(range.node_id);
472
+ for (const node of sub_graph) {
473
+ if (!nodes[node.id]) {
474
+ nodes[node.id] = node;
475
+ }
446
476
  }
477
+ return {
478
+ start_offset: Math.max(range.start_offset - selection_start, 0),
479
+ end_offset: Math.min(
480
+ range.end_offset - selection_start,
481
+ selection_end - selection_start
482
+ ),
483
+ node_id: range.node_id
484
+ };
485
+ } else {
486
+ return null;
447
487
  }
488
+ })
489
+ .filter(Boolean);
490
+
491
+ const marks = clip_ranges(text_value.marks);
492
+ const annotations = clip_ranges(text_value.annotations);
493
+
494
+ return { content: selected_text, marks, annotations, nodes };
495
+ }
496
+
497
+ get_selected_annotated_nodes() {
498
+ if (this.selection?.type !== 'node') return null;
499
+
500
+ const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
501
+ const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
502
+ const node_array = this.get(this.selection.path);
503
+ const main_nodes = node_array.nodes.slice(selection_start, selection_end);
504
+ const nodes = {};
505
+
506
+ const add_subgraph = (node_id) => {
507
+ for (const node of this.traverse(node_id)) {
508
+ if (!nodes[node.id]) nodes[node.id] = node;
509
+ }
510
+ };
511
+
512
+ for (const node_id of main_nodes) add_subgraph(node_id);
513
+
514
+ const clip_ranges = (ranges) =>
515
+ ranges
516
+ .map((range) => {
517
+ if (selection_start >= range.end_offset || selection_end <= range.start_offset) {
518
+ return null;
519
+ }
520
+
521
+ add_subgraph(range.node_id);
448
522
  return {
449
- start_offset: Math.max(a.start_offset - selection_start, 0),
450
- end_offset: Math.min(a.end_offset - selection_start, selection_end - selection_start),
451
- node_id: a.node_id
523
+ start_offset: Math.max(range.start_offset - selection_start, 0),
524
+ end_offset: Math.min(range.end_offset - selection_start, selection_end - selection_start),
525
+ node_id: range.node_id
452
526
  };
453
- } else {
454
- return null;
455
- }
456
- })
457
- .filter(Boolean);
527
+ })
528
+ .filter(Boolean);
458
529
 
459
- return { text, annotations, nodes };
530
+ const marks = clip_ranges(node_array.marks);
531
+ const annotations = clip_ranges(node_array.annotations);
532
+
533
+ return { nodes, main_nodes, marks, annotations };
460
534
  }
461
535
 
462
536
  // TODO: think about ways how we can also turn a node selection into plain text.
@@ -465,8 +539,8 @@ export default class Session {
465
539
 
466
540
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
467
541
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
468
- const annotated_text = this.get(this.selection.path);
469
- return char_slice(annotated_text.text, start, end);
542
+ const text = this.get(this.selection.path);
543
+ return char_slice(text.content, start, end);
470
544
  }
471
545
 
472
546
  get_selected_nodes() {
@@ -475,7 +549,9 @@ export default class Session {
475
549
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
476
550
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
477
551
  const node_array = this.get(this.selection.path);
478
- return $state.snapshot(node_array.slice(start, end));
552
+ // node_array.nodes is a plain array of id strings (doc is $state.raw),
553
+ // so a slice is already a safe fresh copy.
554
+ return node_array.nodes.slice(start, end);
479
555
  }
480
556
 
481
557
  select_parent() {
@@ -528,7 +604,11 @@ export default class Session {
528
604
  * @note Nodes that are not reachable from the entry point node will not be included
529
605
  */
530
606
  traverse(node_id) {
531
- return traverse(node_id, this.schema, $state.snapshot(this.doc.nodes));
607
+ // doc is $state.raw, so doc.nodes is a plain (non-proxied) object —
608
+ // traverse() reads it directly and deep-clones each visited node.
609
+ // The previous $state.snapshot(this.doc.nodes) here deep-cloned the
610
+ // ENTIRE document per call, which made copying M nodes O(M·N).
611
+ return traverse(node_id, this.schema, this.doc.nodes);
532
612
  }
533
613
 
534
614
  /**
@@ -551,7 +631,7 @@ export default class Session {
551
631
  }
552
632
 
553
633
  // property_type('page', 'body') => 'node_array'
554
- // property_type('paragraph', 'content') => 'annotated_text'
634
+ // property_type('paragraph', 'content') => 'text'
555
635
  property_type(type, property) {
556
636
  return doc_property_type(this.schema, type, property);
557
637
  }
@@ -567,9 +647,7 @@ export default class Session {
567
647
  * @returns {NodeId[]}
568
648
  */
569
649
  get_referenced_nodes(node_id) {
570
- const traversed_nodes = this.traverse(node_id);
571
-
572
- // Extract IDs and exclude the last element (root node)
573
- return traversed_nodes.slice(0, -1).map((node) => node.id);
650
+ // Id-only traversal — no need to deep-clone the subgraph for ids.
651
+ return traverse_ids(node_id, this.schema, this.doc.nodes).slice(0, -1);
574
652
  }
575
653
  }