svedit 0.11.0 → 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 (59) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -5
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +86 -30
  5. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  6. package/dist/KeyMapper.svelte.js +18 -6
  7. package/dist/Node.svelte +29 -1
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +119 -12
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeGap.svelte +48 -6
  12. package/dist/NodeGap.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.d.ts +31 -14
  14. package/dist/Session.svelte.d.ts.map +1 -1
  15. package/dist/Session.svelte.js +155 -77
  16. package/dist/Svedit.svelte +110 -89
  17. package/dist/Svedit.svelte.d.ts.map +1 -1
  18. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  19. package/dist/TextProperty.svelte.d.ts +7 -0
  20. package/dist/TextProperty.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +78 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +412 -234
  24. package/dist/doc_utils.d.ts +96 -21
  25. package/dist/doc_utils.d.ts.map +1 -1
  26. package/dist/doc_utils.js +440 -168
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/node_visibility.svelte.d.ts +30 -4
  30. package/dist/node_visibility.svelte.d.ts.map +1 -1
  31. package/dist/node_visibility.svelte.js +327 -363
  32. package/dist/paste_utils.d.ts +3 -2
  33. package/dist/paste_utils.d.ts.map +1 -1
  34. package/dist/paste_utils.js +16 -13
  35. package/dist/transforms.svelte.d.ts.map +1 -1
  36. package/dist/transforms.svelte.js +5 -6
  37. package/dist/types.d.ts +112 -42
  38. package/dist/utils.d.ts +89 -13
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +320 -85
  41. package/package.json +4 -1
  42. package/src/lib/Command.svelte.js +86 -30
  43. package/src/lib/KeyMapper.svelte.js +18 -6
  44. package/src/lib/Node.svelte +29 -1
  45. package/src/lib/NodeArrayProperty.svelte +119 -12
  46. package/src/lib/NodeGap.svelte +48 -6
  47. package/src/lib/Session.svelte.js +155 -77
  48. package/src/lib/Svedit.svelte +110 -89
  49. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  50. package/src/lib/Transaction.svelte.js +412 -234
  51. package/src/lib/doc_utils.js +440 -168
  52. package/src/lib/index.js +1 -1
  53. package/src/lib/node_visibility.svelte.js +327 -363
  54. package/src/lib/paste_utils.js +16 -13
  55. package/src/lib/transforms.svelte.js +5 -6
  56. package/src/lib/types.d.ts +112 -42
  57. package/src/lib/utils.js +320 -85
  58. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  59. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
@@ -4,7 +4,7 @@
4
4
  * These functions operate on the core document state (schema, doc, selection, config)
5
5
  * without any history management or transaction tracking.
6
6
  *
7
- * @import { NodeId, DocumentPath, PrimitiveType, NodeProperty, NodeArrayProperty, NodeSchema, DocumentSchema, Selection, Annotation, Document, AnnotatedTextProperty, AnnotatedText, ValidateDocumentSchema } from './types'
7
+ * @import { NodeId, DocumentPath, PrimitiveType, NodeProperty, NodeArrayProperty, NodeSchema, DocumentSchema, Selection, Attachment, Mark, Annotation, Document, TextProperty, AnnotatedText, ValidateDocumentSchema } from './types'
8
8
  */
9
9
 
10
10
  import {
@@ -12,7 +12,8 @@ import {
12
12
  is_path_string_segment_valid,
13
13
  get_selection_range,
14
14
  get_char_length,
15
- serialize_path
15
+ serialize_path,
16
+ are_ranges_exclusive
16
17
  } from './utils.js';
17
18
 
18
19
  /**
@@ -39,7 +40,7 @@ export function is_primitive_type(type) {
39
40
  'boolean',
40
41
  'integer',
41
42
  'datetime',
42
- 'annotated_text',
43
+ 'text',
43
44
  'string_array',
44
45
  'number_array',
45
46
  'boolean_array',
@@ -74,8 +75,8 @@ export function get_property_default(property_definition) {
74
75
  if (property_definition.type === 'integer') return 0;
75
76
  if (property_definition.type === 'number') return 0;
76
77
  if (property_definition.type === 'boolean') return false;
77
- if (property_definition.type === 'annotated_text') return { text: '', annotations: [] };
78
- if (property_definition.type === 'node_array') return [];
78
+ if (property_definition.type === 'text') return { content: '', marks: [], annotations: [] };
79
+ if (property_definition.type === 'node_array') return { nodes: [], marks: [], annotations: [] };
79
80
  if (
80
81
  property_definition.type === 'string_array' ||
81
82
  property_definition.type === 'number_array' ||
@@ -161,6 +162,42 @@ export function validate_document_schema(document_schema) {
161
162
  );
162
163
  }
163
164
  }
165
+ if (prop_def.type === 'text' || prop_def.type === 'node_array') {
166
+ // mark_types must reference kind 'mark', annotation_types kind 'annotation'
167
+ for (const [key, expected_kind] of [
168
+ ['mark_types', 'mark'],
169
+ ['annotation_types', 'annotation']
170
+ ]) {
171
+ const invalid_types = (prop_def[key] ?? []).filter(
172
+ (ref_type) => document_schema[ref_type]?.kind !== expected_kind
173
+ );
174
+ if (invalid_types.length > 0) {
175
+ throw new Error(
176
+ `Node type "${node_type}" property "${prop_name}" ${key} must reference node types of kind '${expected_kind}', got: ${invalid_types.join(', ')}.`
177
+ );
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Validate that no annotation node type has a registered component.
187
+ *
188
+ * Marks render in-place via components; annotations are data-only overlay
189
+ * ranges that must be interpreted by the app (via CSS classes or props).
190
+ *
191
+ * @param {DocumentSchema} schema - The document schema
192
+ * @param {any} config - The Svedit config
193
+ * @throws {Error} Throws if a kind 'annotation' type has a registered component
194
+ */
195
+ export function validate_config_components(schema, config) {
196
+ for (const [node_type, node_schema] of Object.entries(schema)) {
197
+ if (node_schema.kind === 'annotation' && config?.node_components?.[node_type]) {
198
+ throw new Error(
199
+ `Annotation type "${node_type}" must not have a registered component. Annotations are data-only; use kind 'mark' for in-place rendered ranges.`
200
+ );
164
201
  }
165
202
  }
166
203
  }
@@ -183,11 +220,12 @@ function validate_primitive_value(type, value) {
183
220
  return Number.isInteger(value);
184
221
  case 'datetime':
185
222
  return typeof value === 'string' && !isNaN(Date.parse(value));
186
- case 'annotated_text':
223
+ case 'text':
187
224
  return (
188
225
  typeof value === 'object' &&
189
226
  value !== null &&
190
- typeof value.text === 'string' &&
227
+ typeof value.content === 'string' &&
228
+ Array.isArray(value.marks) &&
191
229
  Array.isArray(value.annotations)
192
230
  );
193
231
  case 'string_array':
@@ -204,79 +242,153 @@ function validate_primitive_value(type, value) {
204
242
  }
205
243
 
206
244
  /**
207
- * Validate annotated text annotations for bounds, references, allowed types, and exclusivity.
245
+ * Validate ranges (marks or annotations) for bounds, references, and allowed types.
208
246
  *
209
247
  * @param {string} node_id - Owner node id, used for error messages
210
248
  * @param {string} prop_name - Owner property name, used for error messages
211
- * @param {AnnotatedText} value - Annotated text value to validate
212
- * @param {AnnotatedTextProperty} prop_def - Annotated text property definition
249
+ * @param {'mark' | 'annotation'} label - Range label, used for error messages
250
+ * @param {Array<Attachment>} ranges - Ranges to validate
251
+ * @param {number} container_length - Length of the annotated text or node array
252
+ * @param {Array<string> | null | undefined} allowed_types - Allowed node types
213
253
  * @param {Record<string, any>} all_nodes - All document nodes
214
254
  * @param {boolean} require_references - Whether referenced nodes must already exist
215
- * @throws {Error} Throws if annotations are invalid
255
+ * @throws {Error} Throws if ranges are invalid
216
256
  */
217
- function validate_annotated_text_property(node_id, prop_name, value, prop_def, all_nodes, require_references) {
218
- const char_length = get_char_length(value.text);
219
- const annotations = value.annotations;
220
-
221
- for (const [index, annotation] of annotations.entries()) {
257
+ function validate_range_array(
258
+ node_id,
259
+ prop_name,
260
+ label,
261
+ ranges,
262
+ container_length,
263
+ allowed_types,
264
+ all_nodes,
265
+ require_references
266
+ ) {
267
+ for (const [index, range] of ranges.entries()) {
222
268
  if (
223
- typeof annotation !== 'object' ||
224
- annotation === null ||
225
- !Number.isInteger(annotation.start_offset) ||
226
- !Number.isInteger(annotation.end_offset) ||
227
- !is_id_valid(annotation.node_id)
269
+ typeof range !== 'object' ||
270
+ range === null ||
271
+ !Number.isInteger(range.start_offset) ||
272
+ !Number.isInteger(range.end_offset) ||
273
+ !is_id_valid(range.node_id)
228
274
  ) {
229
275
  throw new Error(
230
- `Node ${node_id} property ${prop_name} has an invalid annotation at index ${index}. Annotations must have integer start_offset/end_offset and a valid node_id.`
276
+ `Node ${node_id} property ${prop_name} has an invalid ${label} at index ${index}. Ranges must have integer start_offset/end_offset and a valid node_id.`
231
277
  );
232
278
  }
233
279
 
234
- if (annotation.start_offset < 0 || annotation.end_offset > char_length) {
280
+ if (range.start_offset < 0 || range.end_offset > container_length) {
235
281
  throw new Error(
236
- `Node ${node_id} property ${prop_name} annotation ${annotation.node_id} is out of bounds: ${annotation.start_offset}-${annotation.end_offset}, text length is ${char_length}.`
282
+ `Node ${node_id} property ${prop_name} ${label} ${range.node_id} is out of bounds: ${range.start_offset}-${range.end_offset}, container length is ${container_length}.`
237
283
  );
238
284
  }
239
285
 
240
- if (annotation.start_offset >= annotation.end_offset) {
286
+ if (range.start_offset >= range.end_offset) {
241
287
  throw new Error(
242
- `Node ${node_id} property ${prop_name} annotation ${annotation.node_id} must not be empty or reversed: ${annotation.start_offset}-${annotation.end_offset}.`
288
+ `Node ${node_id} property ${prop_name} ${label} ${range.node_id} must not be empty or reversed: ${range.start_offset}-${range.end_offset}.`
243
289
  );
244
290
  }
245
291
 
246
- const referenced_node = all_nodes[annotation.node_id];
292
+ const referenced_node = all_nodes[range.node_id];
247
293
  if (!referenced_node) {
248
294
  if (require_references) {
249
295
  throw new Error(
250
- `Node ${node_id} property ${prop_name} annotation references missing node ${annotation.node_id}.`
296
+ `Node ${node_id} property ${prop_name} ${label} references missing node ${range.node_id}.`
251
297
  );
252
298
  }
253
299
  continue;
254
300
  }
255
301
 
256
- if (prop_def.node_types?.length && !prop_def.node_types.includes(referenced_node.type)) {
302
+ if (allowed_types?.length && !allowed_types.includes(referenced_node.type)) {
257
303
  throw new Error(
258
- `Node ${node_id} property ${prop_name} annotation references node ${annotation.node_id} of type ${referenced_node.type}, but only types [${prop_def.node_types.join(', ')}] are allowed.`
304
+ `Node ${node_id} property ${prop_name} ${label} references node ${range.node_id} of type ${referenced_node.type}, but only types [${allowed_types.join(', ')}] are allowed.`
259
305
  );
260
306
  }
261
307
  }
308
+ }
262
309
 
263
- const sorted_annotations = annotations
264
- .map((annotation, index) => ({ annotation, index }))
265
- .sort((a, b) =>
266
- a.annotation.start_offset - b.annotation.start_offset ||
267
- a.annotation.end_offset - b.annotation.end_offset
310
+ /**
311
+ * Validate marks and annotations of an annotated container (text or node array).
312
+ *
313
+ * Marks must be mutually exclusive; annotations may overlap.
314
+ *
315
+ * @param {string} node_id - Owner node id, used for error messages
316
+ * @param {string} prop_name - Owner property name, used for error messages
317
+ * @param {{ marks: Array<Mark>, annotations: Array<Annotation> }} value - Annotated value to validate
318
+ * @param {{ mark_types?: string[], annotation_types?: string[] }} prop_def - Property definition
319
+ * @param {number} container_length - Length of the annotated text or node array
320
+ * @param {Record<string, any>} all_nodes - All document nodes
321
+ * @param {boolean} require_references - Whether referenced nodes must already exist
322
+ * @throws {Error} Throws if marks or annotations are invalid
323
+ */
324
+ function validate_marks_and_annotations(
325
+ node_id,
326
+ prop_name,
327
+ value,
328
+ prop_def,
329
+ container_length,
330
+ all_nodes,
331
+ require_references
332
+ ) {
333
+ validate_range_array(
334
+ node_id,
335
+ prop_name,
336
+ 'mark',
337
+ value.marks,
338
+ container_length,
339
+ prop_def.mark_types,
340
+ all_nodes,
341
+ require_references
342
+ );
343
+
344
+ if (!are_ranges_exclusive(value.marks, container_length)) {
345
+ throw new Error(
346
+ `Node ${node_id} property ${prop_name} has overlapping marks. Marks must be mutually exclusive.`
268
347
  );
348
+ }
269
349
 
270
- for (let index = 1; index < sorted_annotations.length; index++) {
271
- const previous = sorted_annotations[index - 1];
272
- const current = sorted_annotations[index];
350
+ validate_range_array(
351
+ node_id,
352
+ prop_name,
353
+ 'annotation',
354
+ value.annotations,
355
+ container_length,
356
+ prop_def.annotation_types,
357
+ all_nodes,
358
+ require_references
359
+ );
360
+ }
273
361
 
274
- if (current.annotation.start_offset < previous.annotation.end_offset) {
275
- throw new Error(
276
- `Node ${node_id} property ${prop_name} has overlapping annotations: annotation index ${previous.index} (${previous.annotation.node_id}, ${previous.annotation.start_offset}-${previous.annotation.end_offset}) overlaps annotation index ${current.index} (${current.annotation.node_id}, ${current.annotation.start_offset}-${current.annotation.end_offset}). Annotations must be exclusive.`
277
- );
278
- }
279
- }
362
+ /**
363
+ * Validate text property marks and annotations for bounds, references, allowed
364
+ * types, and mark exclusivity.
365
+ *
366
+ * @param {string} node_id - Owner node id, used for error messages
367
+ * @param {string} prop_name - Owner property name, used for error messages
368
+ * @param {AnnotatedText} value - Annotated text value to validate
369
+ * @param {TextProperty} prop_def - Text property definition
370
+ * @param {Record<string, any>} all_nodes - All document nodes
371
+ * @param {boolean} require_references - Whether referenced nodes must already exist
372
+ * @throws {Error} Throws if marks or annotations are invalid
373
+ */
374
+ function validate_text_property(
375
+ node_id,
376
+ prop_name,
377
+ value,
378
+ prop_def,
379
+ all_nodes,
380
+ require_references
381
+ ) {
382
+ const char_length = get_char_length(value.content);
383
+ validate_marks_and_annotations(
384
+ node_id,
385
+ prop_name,
386
+ value,
387
+ prop_def,
388
+ char_length,
389
+ all_nodes,
390
+ require_references
391
+ );
280
392
  }
281
393
 
282
394
  /**
@@ -318,12 +430,12 @@ export function validate_node(node, schema, all_nodes = {}, options = {}) {
318
430
  );
319
431
  }
320
432
  }
321
- if (prop_def.type === 'annotated_text') {
322
- validate_annotated_text_property(
433
+ if (prop_def.type === 'text') {
434
+ validate_text_property(
323
435
  node.id,
324
436
  prop_name,
325
437
  value,
326
- /** @type {AnnotatedTextProperty} */ (prop_def),
438
+ /** @type {TextProperty} */ (prop_def),
327
439
  all_nodes,
328
440
  require_references
329
441
  );
@@ -354,15 +466,27 @@ export function validate_node(node, schema, all_nodes = {}, options = {}) {
354
466
  // Check node arrays
355
467
  else if (prop_def.type === 'node_array') {
356
468
  if (
357
- !Array.isArray(value) ||
358
- !value.every((id) => typeof id === 'string' && is_id_valid(id))
469
+ !value ||
470
+ typeof value !== 'object' ||
471
+ !Array.isArray(value.nodes) ||
472
+ !Array.isArray(value.marks) ||
473
+ !Array.isArray(value.annotations)
359
474
  ) {
360
475
  throw new Error(
361
- `Node ${node.id} has an invalid property: ${prop_name} must be an array of node ids.`
476
+ `Node ${node.id} has an invalid property: ${prop_name} must be an object with nodes, marks and annotations.`
477
+ );
478
+ }
479
+
480
+ const node_array_nodes = value.nodes;
481
+
482
+ if (!node_array_nodes.every((id) => typeof id === 'string' && is_id_valid(id))) {
483
+ throw new Error(
484
+ `Node ${node.id} has an invalid property: ${prop_name} must contain valid node ids.`
362
485
  );
363
486
  }
487
+
364
488
  // Check if all referenced nodes exist and are of allowed types
365
- for (const ref_id of value) {
489
+ for (const ref_id of node_array_nodes) {
366
490
  const referenced_node = all_nodes[ref_id];
367
491
  if (!referenced_node) {
368
492
  if (require_references) {
@@ -378,6 +502,15 @@ export function validate_node(node, schema, all_nodes = {}, options = {}) {
378
502
  );
379
503
  }
380
504
  }
505
+ validate_marks_and_annotations(
506
+ node.id,
507
+ prop_name,
508
+ value,
509
+ prop_def,
510
+ node_array_nodes.length,
511
+ all_nodes,
512
+ require_references
513
+ );
381
514
  }
382
515
  }
383
516
  }
@@ -393,7 +526,13 @@ export function validate_document(doc, schema) {
393
526
  if (!is_id_valid(doc.document_id)) {
394
527
  throw new Error(`Document ${doc.document_id} has an invalid id.`);
395
528
  }
396
- for (const node of Object.values(doc.nodes)) {
529
+ for (const [node_id, node] of Object.entries(doc.nodes)) {
530
+ if (!is_id_valid(node_id)) {
531
+ throw new Error(`Document node map contains an invalid id: ${node_id}.`);
532
+ }
533
+ if (node.id !== node_id) {
534
+ throw new Error(`Document node map key ${node_id} does not match node id ${node.id}.`);
535
+ }
397
536
  validate_node(node, schema, doc.nodes);
398
537
  }
399
538
  }
@@ -424,9 +563,9 @@ export function get(schema, doc, path) {
424
563
  if (property_type(schema, val.type, path_segment_str) === 'node_array') {
425
564
  val = val[path_segment];
426
565
  val_type = 'node_array';
427
- } else if (property_type(schema, val.type, path_segment_str) === 'annotated_text') {
566
+ } else if (property_type(schema, val.type, path_segment_str) === 'text') {
428
567
  val = val[path_segment];
429
- val_type = 'annotated_text';
568
+ val_type = 'text';
430
569
  } else if (property_type(schema, val.type, path_segment_str) === 'node') {
431
570
  val = doc.nodes[val[path_segment]];
432
571
  val_type = 'node';
@@ -442,27 +581,48 @@ export function get(schema, doc, path) {
442
581
  val_type = 'value';
443
582
  }
444
583
  } else if (val_type === 'node_array') {
584
+ if (path_segment === 'nodes') {
585
+ val = val.nodes;
586
+ val_type = 'node_id_array';
587
+ } else if (path_segment === 'marks') {
588
+ val = val.marks;
589
+ val_type = 'range_array';
590
+ } else if (path_segment === 'annotations') {
591
+ val = val.annotations;
592
+ val_type = 'range_array';
593
+ } else if (typeof path_segment === 'number' || /^\d+$/.test(String(path_segment))) {
594
+ val = doc.nodes[val.nodes[path_segment]];
595
+ val_type = 'node';
596
+ } else {
597
+ throw new Error(
598
+ `Invalid path segment "${path_segment}" for node_array. Use "nodes", "marks" or "annotations".`
599
+ );
600
+ }
601
+ } else if (val_type === 'node_id_array') {
445
602
  val = doc.nodes[val[path_segment]];
446
603
  val_type = 'node';
447
604
  } else if (val_type === 'value_array') {
448
605
  val = val[path_segment];
449
606
  val_type = 'value';
450
- } else if (val_type === 'annotated_text') {
451
- if (path_segment === 'text') {
452
- val = val.text;
607
+ } else if (val_type === 'text') {
608
+ if (path_segment === 'content') {
609
+ val = val.content;
453
610
  val_type = 'value';
611
+ } else if (path_segment === 'marks') {
612
+ val = val.marks;
613
+ val_type = 'range_array';
454
614
  } else if (path_segment === 'annotations') {
455
615
  val = val.annotations;
456
- val_type = 'annotation_array';
616
+ val_type = 'range_array';
457
617
  } else {
458
618
  throw new Error(
459
- `Invalid path segment "${path_segment}" for annotated_text. Use "text" or "annotations".`
619
+ `Invalid path segment "${path_segment}" for text. Use "content", "marks" or "annotations".`
460
620
  );
461
621
  }
462
- } else if (val_type === 'annotation_array') {
622
+ } else if (val_type === 'range_array') {
463
623
  val = val[path_segment];
464
- val_type = 'annotation';
465
- } else if (val_type === 'annotation') {
624
+ val_type = 'range';
625
+ } else if (val_type === 'range') {
466
626
  if (path_segment === 'node_id') {
467
627
  val = doc.nodes[val.node_id];
468
628
  val_type = 'node';
@@ -474,7 +634,7 @@ export function get(schema, doc, path) {
474
634
  val_type = 'value';
475
635
  } else {
476
636
  throw new Error(
477
- `Invalid path segment "${path_segment}" for annotation. Use "start_offset", "end_offset", or "node_id".`
637
+ `Invalid path segment "${path_segment}" for range. Use "start_offset", "end_offset", or "node_id".`
478
638
  );
479
639
  }
480
640
  }
@@ -505,41 +665,16 @@ export function property_type(schema, type, property) {
505
665
  }
506
666
 
507
667
  /**
508
- * Determines the kind of a node ('block', 'text', or 'annotation').
668
+ * Determines the kind of a node ('block', 'text', 'mark', or 'annotation').
509
669
  *
510
670
  * @param {object} schema - The document schema
511
671
  * @param {any} node - The node to check
512
- * @returns {'block'|'text'|'annotation'} The node kind
672
+ * @returns {'block'|'text'|'mark'|'annotation'} The node kind
513
673
  */
514
674
  export function kind(schema, node) {
515
675
  return schema[node.type].kind;
516
676
  }
517
677
 
518
- /**
519
- * Checks whether an annotation node can be switched to another annotation type
520
- * without losing data.
521
- *
522
- * @param {DocumentSchema} schema - The document schema
523
- * @param {any} annotation_node - The annotation node to switch
524
- * @param {string} target_annotation_type - The target annotation type
525
- * @returns {boolean} True if the annotation can be switched safely
526
- */
527
- export function can_switch_annotation_type(schema, annotation_node, target_annotation_type) {
528
- if (!annotation_node || annotation_node.type === target_annotation_type) return false;
529
-
530
- const source_schema = schema[annotation_node.type];
531
- const target_schema = schema[target_annotation_type];
532
- if (source_schema?.kind !== 'annotation' || target_schema?.kind !== 'annotation') return false;
533
-
534
- const source_has_properties = Object.keys(source_schema.properties ?? {}).length > 0;
535
- const target_has_properties = Object.keys(target_schema.properties ?? {}).length > 0;
536
- if (source_has_properties || target_has_properties) return false;
537
-
538
- // eslint-disable-next-line no-unused-vars
539
- const { id, type, ...extra_properties } = annotation_node;
540
- return Object.keys(extra_properties).length === 0;
541
- }
542
-
543
678
  /**
544
679
  * Inspects a path to get metadata about the value at that location.
545
680
  *
@@ -569,45 +704,62 @@ export function inspect(schema, doc, path) {
569
704
  }
570
705
 
571
706
  /**
572
- * Applies an operation to a document and returns the new document.
573
- * Uses copy-on-write semantics.
707
+ * Creates a mutable draft of a document: a new document object with a new
708
+ * nodes map, sharing the (immutable) node objects with the original.
574
709
  *
575
- * @param {Document} doc - The document to apply the operation to
710
+ * apply_op_to_draft mutates the draft's nodes map but copies node objects
711
+ * before changing them, so node identity is preserved for unchanged nodes
712
+ * (fine-grained rendering relies on this) and the original document is
713
+ * never touched.
714
+ *
715
+ * @param {Document} doc - The document to create a draft of
716
+ * @returns {Document} A draft document safe to mutate via apply_op_to_draft
717
+ */
718
+ export function create_document_draft(doc) {
719
+ return {
720
+ ...doc,
721
+ nodes: { ...doc.nodes }
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Applies an operation to a document draft in place.
727
+ *
728
+ * The draft's nodes map is mutated, but node objects are copied on write —
729
+ * one nodes-map copy per draft (see create_document_draft) replaces the
730
+ * previous one-copy-per-op behavior, which made transactions with many ops
731
+ * (paste, cascade delete) O(N·ops).
732
+ *
733
+ * @param {Document} draft - A draft created via create_document_draft
576
734
  * @param {Array} op - The operation to apply [type, ...args]
577
- * @returns {Document} The new document with the operation applied
735
+ * @returns {Document} The same draft, for convenience
578
736
  */
579
- export function apply_op(doc, op) {
737
+ export function apply_op_to_draft(draft, op) {
580
738
  const [type, ...args] = op;
581
739
  if (type === 'set') {
582
740
  const [node_id, property] = args[0];
583
- const value = structuredClone(args[1]);
584
- return {
585
- ...doc,
586
- nodes: {
587
- ...doc.nodes,
588
- [node_id]: {
589
- ...doc.nodes[node_id],
590
- [property]: value
591
- }
592
- }
741
+ draft.nodes[node_id] = {
742
+ ...draft.nodes[node_id],
743
+ [property]: structuredClone(args[1])
593
744
  };
594
745
  } else if (type === 'create') {
595
- return {
596
- ...doc,
597
- nodes: {
598
- ...doc.nodes,
599
- [args[0].id]: structuredClone(args[0])
600
- }
601
- };
746
+ draft.nodes[args[0].id] = structuredClone(args[0]);
602
747
  } else if (type === 'delete') {
603
- // eslint-disable-next-line
604
- const { [args[0]]: _removed, ...remaining_nodes } = doc.nodes;
605
- return {
606
- ...doc,
607
- nodes: remaining_nodes
608
- };
748
+ delete draft.nodes[args[0]];
609
749
  }
610
- return doc;
750
+ return draft;
751
+ }
752
+
753
+ /**
754
+ * Applies an operation to a document and returns the new document.
755
+ * Uses copy-on-write semantics.
756
+ *
757
+ * @param {Document} doc - The document to apply the operation to
758
+ * @param {Array} op - The operation to apply [type, ...args]
759
+ * @returns {Document} The new document with the operation applied
760
+ */
761
+ export function apply_op(doc, op) {
762
+ return apply_op_to_draft(create_document_draft(doc), op);
611
763
  }
612
764
 
613
765
  /**
@@ -627,11 +779,17 @@ export function count_references(schema, doc, node_id) {
627
779
 
628
780
  const prop_type = property_type(schema, node.type, property);
629
781
 
630
- if (prop_type === 'node_array' && Array.isArray(value)) {
631
- count += value.filter((id) => id === node_id).length;
782
+ if (prop_type === 'node_array') {
783
+ count += value.nodes.filter((id) => id === node_id).length;
632
784
  } else if (prop_type === 'node' && value === node_id) {
633
785
  count += 1;
634
786
  }
787
+
788
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
789
+ count += [...value.marks, ...value.annotations].filter(
790
+ (range) => range.node_id === node_id
791
+ ).length;
792
+ }
635
793
  }
636
794
  }
637
795
 
@@ -639,41 +797,93 @@ export function count_references(schema, doc, node_id) {
639
797
  }
640
798
 
641
799
  /**
642
- * Gets the currently active annotation at the selection, optionally filtered by type.
800
+ * @param {DocumentSchema} schema - The document schema
801
+ * @param {string} from_type - Current mark type
802
+ * @param {string} to_type - Target mark type
803
+ * @returns {boolean} Whether a mark can be switched between these types
804
+ */
805
+ export function can_switch_mark_type(schema, from_type, to_type) {
806
+ const from_schema = schema[from_type];
807
+ const to_schema = schema[to_type];
808
+ return (
809
+ from_schema?.kind === 'mark' &&
810
+ to_schema?.kind === 'mark' &&
811
+ Object.keys(from_schema.properties ?? {}).length === 0 &&
812
+ Object.keys(to_schema.properties ?? {}).length === 0
813
+ );
814
+ }
815
+
816
+ /**
817
+ * Gets ranges of the given key ('marks' or 'annotations') touched by the
818
+ * current selection.
643
819
  *
644
- * An annotation is considered "active" if it overlaps with the current selection in any way:
645
- * - Annotation contains the selection start
646
- * - Annotation contains the selection end
647
- * - Selection fully contains the annotation
820
+ * Non-collapsed selections use strict half-open range intersection, so merely
821
+ * adjacent boundaries do not count as touching. Collapsed text carets are only
822
+ * inside a range when they are strictly between start and end; a caret at
823
+ * either boundary is not active.
648
824
  *
649
825
  * @param {DocumentSchema} schema - The document schema
650
826
  * @param {Document} doc - The document containing nodes
651
- * @param {Selection} [selection] - The current selection
652
- * @param {string} [annotation_type] - Optional annotation type to filter by
653
- * @returns {Annotation | null} The active annotation, or null if none found
827
+ * @param {Selection | null | undefined} selection - The current selection
828
+ * @param {'marks' | 'annotations'} key - Which range array to inspect
829
+ * @returns {(Attachment & { index: number, node: any })[]} Selected attachment records
654
830
  */
655
- export function get_active_annotation(schema, doc, selection, annotation_type) {
656
- if (selection?.type !== 'text') return null;
831
+ function get_selected_ranges(schema, doc, selection, key) {
832
+ if (selection?.type !== 'text' && selection?.type !== 'node') return [];
657
833
  const range = get_selection_range(selection);
658
- if (!range) return null;
659
-
660
- const annotated_text = get(schema, doc, selection.path);
661
- const annotations = annotated_text.annotations;
662
-
663
- const active_annotation =
664
- annotations.find(
665
- (/** @type {Annotation} */ { start_offset, end_offset }) =>
666
- (start_offset <= range.start_offset && end_offset > range.start_offset) ||
667
- (start_offset < range.end_offset && end_offset >= range.end_offset) ||
668
- (start_offset >= range.start_offset && end_offset <= range.end_offset)
669
- ) || null;
670
-
671
- if (annotation_type && active_annotation) {
672
- const annotation_node = get(schema, doc, [active_annotation.node_id]);
673
- return annotation_node?.type === annotation_type ? active_annotation : null;
674
- } else {
675
- return active_annotation;
676
- }
834
+ if (!range) return [];
835
+
836
+ const annotated_prop = get(schema, doc, selection.path);
837
+ const ranges = annotated_prop?.[key] ?? [];
838
+ const is_collapsed = range.start_offset === range.end_offset;
839
+
840
+ return ranges
841
+ .map((/** @type {Attachment} */ attachment, index) => {
842
+ const node = doc.nodes[attachment.node_id];
843
+ return {
844
+ ...attachment,
845
+ index,
846
+ node
847
+ };
848
+ })
849
+ .filter(({ start_offset, end_offset }) => {
850
+ if (is_collapsed) {
851
+ return start_offset < range.start_offset && end_offset > range.start_offset;
852
+ }
853
+ return start_offset < range.end_offset && end_offset > range.start_offset;
854
+ });
855
+ }
856
+
857
+ /**
858
+ * Gets marks touched by the current selection.
859
+ *
860
+ * @param {DocumentSchema} schema - The document schema
861
+ * @param {Document} doc - The document containing nodes
862
+ * @param {Selection} [selection] - The current selection
863
+ * @returns {(Mark & { index: number, node: any })[]} Selected mark records
864
+ */
865
+ export function get_selected_marks(schema, doc, selection) {
866
+ return get_selected_ranges(schema, doc, selection, 'marks');
867
+ }
868
+
869
+ /**
870
+ * Gets annotations touched by the current selection.
871
+ *
872
+ * @param {DocumentSchema} schema - The document schema
873
+ * @param {Document} doc - The document containing nodes
874
+ * @param {Selection} [selection] - The current selection
875
+ * @returns {(Annotation & { index: number, node: any })[]} Selected annotation records
876
+ */
877
+ export function get_selected_annotations(schema, doc, selection) {
878
+ return get_selected_ranges(schema, doc, selection, 'annotations');
879
+ }
880
+
881
+ /**
882
+ * @param {(Attachment & { index: number, node: any })[]} selected_ranges
883
+ * @returns {Set<string>} Node types represented in the selected ranges
884
+ */
885
+ export function get_selected_range_types(selected_ranges) {
886
+ return new Set(selected_ranges.map(({ node }) => node?.type).filter(Boolean));
677
887
  }
678
888
 
679
889
  /**
@@ -696,13 +906,15 @@ export function count_references_excluding_deleted(schema, doc, target_node_id,
696
906
 
697
907
  const prop_type = property_type(schema, node.type, property);
698
908
 
699
- if (prop_type === 'node_array' && Array.isArray(value)) {
700
- count += value.filter((id) => id === target_node_id).length;
909
+ if (prop_type === 'node_array') {
910
+ count += value.nodes.filter((id) => id === target_node_id).length;
701
911
  } else if (prop_type === 'node' && value === target_node_id) {
702
912
  count += 1;
703
- } else if (prop_type === 'annotated_text' && value && value.annotations) {
704
- count += value.annotations.filter(
705
- (annotation) => annotation.node_id === target_node_id
913
+ }
914
+
915
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
916
+ count += [...value.marks, ...value.annotations].filter(
917
+ (range) => range.node_id === target_node_id
706
918
  ).length;
707
919
  }
708
920
  }
@@ -711,6 +923,60 @@ export function count_references_excluding_deleted(schema, doc, target_node_id,
711
923
  return count;
712
924
  }
713
925
 
926
+ /**
927
+ * Iterates all node references going out of a node, invoking the visitor
928
+ * once per occurrence (multiplicity matters for reference counting).
929
+ * Covers the same reference kinds as count_references_excluding_deleted:
930
+ * a `node` property, `node_array` .nodes ids, and the mark/annotation
931
+ * node_ids carried on `text` and `node_array` properties.
932
+ *
933
+ * @param {DocumentSchema} schema - The document schema
934
+ * @param {any} node - The node whose outgoing references to visit
935
+ * @param {(referenced_id: NodeId) => void} visit - Called per reference occurrence
936
+ */
937
+ export function visit_node_references(schema, node, visit) {
938
+ const node_schema = schema[node.type];
939
+ if (!node_schema) return;
940
+
941
+ for (const [property, prop_def] of Object.entries(node_schema.properties)) {
942
+ const value = node[property];
943
+ if (value === undefined || value === null) continue;
944
+
945
+ const prop_type = prop_def.type;
946
+
947
+ if (prop_type === 'node_array') {
948
+ for (const id of value.nodes) visit(id);
949
+ } else if (prop_type === 'node' && typeof value === 'string') {
950
+ visit(value);
951
+ }
952
+
953
+ if ((prop_type === 'text' || prop_type === 'node_array') && value) {
954
+ for (const range of value.marks) visit(range.node_id);
955
+ for (const range of value.annotations) visit(range.node_id);
956
+ }
957
+ }
958
+ }
959
+
960
+ /**
961
+ * Builds reference counts for every referenced node in one full-document
962
+ * scan. Used by cascade deletion so the fixpoint can decrement counts
963
+ * incrementally instead of re-scanning the document per candidate node.
964
+ *
965
+ * @param {DocumentSchema} schema - The document schema
966
+ * @param {Document} doc - The document containing nodes
967
+ * @returns {Map<NodeId, number>} Reference count per referenced node id
968
+ */
969
+ export function build_reference_counts(schema, doc) {
970
+ /** @type {Map<NodeId, number>} */
971
+ const counts = new Map();
972
+ for (const node of Object.values(doc.nodes)) {
973
+ visit_node_references(schema, node, (id) => {
974
+ counts.set(id, (counts.get(id) || 0) + 1);
975
+ });
976
+ }
977
+ return counts;
978
+ }
979
+
714
980
  /**
715
981
  * Gets ids of nodes that reference any of the target nodes.
716
982
  *
@@ -730,16 +996,22 @@ export function get_referencing_node_ids(schema, doc, target_node_ids) {
730
996
 
731
997
  const prop_type = property_type(schema, node.type, property);
732
998
 
733
- if (prop_type === 'node_array' && Array.isArray(value)) {
734
- if (value.some((id) => target_ids.has(id))) {
999
+ if (prop_type === 'node_array') {
1000
+ if (value.nodes.some((id) => target_ids.has(id))) {
735
1001
  referencing_node_ids.add(node.id);
736
1002
  }
737
1003
  } else if (prop_type === 'node' && target_ids.has(value)) {
738
1004
  referencing_node_ids.add(node.id);
739
- } else if (prop_type === 'annotated_text' && value?.annotations) {
740
- if (value.annotations.some((annotation) => target_ids.has(annotation.node_id))) {
741
- referencing_node_ids.add(node.id);
742
- }
1005
+ }
1006
+
1007
+ if (
1008
+ (prop_type === 'text' || prop_type === 'node_array') &&
1009
+ value &&
1010
+ [...value.marks, ...value.annotations].some((range) =>
1011
+ target_ids.has(range.node_id)
1012
+ )
1013
+ ) {
1014
+ referencing_node_ids.add(node.id);
743
1015
  }
744
1016
  }
745
1017
  }
@@ -764,13 +1036,13 @@ export function validate_selection(selection, session_or_transaction) {
764
1036
  }
765
1037
 
766
1038
  if (selection_type === 'node') {
767
- const node_array = session_or_transaction.get(selection.path);
1039
+ const node_array_prop = session_or_transaction.get(selection.path);
768
1040
 
769
- if (!Array.isArray(node_array)) {
1041
+ if (!node_array_prop || !Array.isArray(node_array_prop.nodes)) {
770
1042
  throw new Error('Node selection path must point to a node_array');
771
1043
  }
772
1044
 
773
- const max_offset = node_array.length;
1045
+ const max_offset = node_array_prop.nodes.length;
774
1046
  if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
775
1047
  throw new Error(
776
1048
  `Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`
@@ -782,13 +1054,13 @@ export function validate_selection(selection, session_or_transaction) {
782
1054
  );
783
1055
  }
784
1056
  } else if (selection_type === 'text') {
785
- const annotated_text = session_or_transaction.get(selection.path);
1057
+ const text = session_or_transaction.get(selection.path);
786
1058
 
787
- if (!annotated_text || typeof annotated_text.text !== 'string') {
788
- throw new Error('Text selection path must point to annotated_text');
1059
+ if (!text || typeof text.content !== 'string') {
1060
+ throw new Error('Text selection path must point to text');
789
1061
  }
790
1062
 
791
- const char_length = get_char_length(annotated_text.text);
1063
+ const char_length = get_char_length(text.content);
792
1064
  if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
793
1065
  throw new Error(
794
1066
  `Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`