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
package/dist/utils.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
2
+ * @import { Attachment, Mark, Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
3
  */
4
4
 
5
5
  const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
@@ -176,110 +176,109 @@ export function char_to_utf16_offset(str, char_offset) {
176
176
  /**
177
177
  * Splits an annotated text at the specified character position.
178
178
  *
179
- * Annotations that span the split point will be divided appropriately,
180
- * with offsets adjusted for each resulting part.
179
+ * Marks and annotations that span the split point will be divided
180
+ * appropriately, with offsets adjusted for each resulting part.
181
181
  *
182
- * @param {AnnotatedText} text_with_annotations - Annotated text object
182
+ * @param {AnnotatedText} text_value - Annotated text object
183
183
  * @param {number} at_position - Character position where to split (0-based)
184
184
  * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
185
185
  *
186
186
  * @example
187
- * split_annotated_text({text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}, 8)
187
+ * split_text({content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}, 8)
188
188
  * // Returns:
189
189
  * // [
190
- * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
191
- * // {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]}
190
+ * // {content: "Hello wo", marks: [{start_offset: 6, end_offset: 8, node_id: "strong"}], annotations: []},
191
+ * // {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []}
192
192
  * // ]
193
193
  */
194
- export function split_annotated_text(text_with_annotations, at_position) {
195
- const { text, annotations } = text_with_annotations;
194
+ export function split_text(text_value, at_position) {
195
+ const { content } = text_value;
196
196
 
197
197
  // Split the text using character-aware slicing
198
- const left_text = char_slice(text, 0, at_position);
199
- const right_text = char_slice(text, at_position);
200
-
201
- /** @type {Array<Annotation>} */
202
- const left_annotations = [];
203
- /** @type {Array<Annotation>} */
204
- const right_annotations = [];
205
-
206
- // Process each annotation
207
- for (const { start_offset, end_offset, node_id } of annotations) {
208
- if (end_offset <= at_position) {
209
- // Annotation is entirely in the left part
210
- left_annotations.push({ start_offset, end_offset, node_id });
211
- } else if (start_offset >= at_position) {
212
- // Annotation is entirely in the right part - shift offsets
213
- right_annotations.push({
214
- start_offset: start_offset - at_position,
215
- end_offset: end_offset - at_position,
216
- node_id
217
- });
218
- } else {
219
- // Annotation spans the split point - split it
220
- left_annotations.push({ start_offset, end_offset: at_position, node_id });
221
- right_annotations.push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
198
+ const left = { content: char_slice(content, 0, at_position), marks: [], annotations: [] };
199
+ const right = { content: char_slice(content, at_position), marks: [], annotations: [] };
200
+
201
+ // Process marks and annotations with the same range logic
202
+ for (const key of ['marks', 'annotations']) {
203
+ for (const { start_offset, end_offset, node_id } of text_value[key] ?? []) {
204
+ if (end_offset <= at_position) {
205
+ // Range is entirely in the left part
206
+ left[key].push({ start_offset, end_offset, node_id });
207
+ } else if (start_offset >= at_position) {
208
+ // Range is entirely in the right part - shift offsets
209
+ right[key].push({
210
+ start_offset: start_offset - at_position,
211
+ end_offset: end_offset - at_position,
212
+ node_id
213
+ });
214
+ } else {
215
+ // Range spans the split point - split it
216
+ left[key].push({ start_offset, end_offset: at_position, node_id });
217
+ right[key].push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
218
+ }
222
219
  }
223
220
  }
224
221
 
225
- return [
226
- { text: left_text, annotations: left_annotations },
227
- { text: right_text, annotations: right_annotations }
228
- ];
222
+ return [left, right];
229
223
  }
230
224
 
231
225
  /**
232
226
  * Joins two annotated texts into a single annotated text.
233
227
  *
234
- * Annotations from the second text will have their offsets shifted by the length
235
- * of the first text. Adjacent annotations of the same type and data will be merged.
228
+ * Marks and annotations from the second text will have their offsets shifted
229
+ * by the length of the first text. Adjacent ranges referencing the same node
230
+ * will be merged.
236
231
  *
237
232
  * @param {AnnotatedText} first_text - First annotated text object
238
233
  * @param {AnnotatedText} second_text - Second annotated text object
239
234
  * @returns {AnnotatedText} Combined annotated text object
240
235
  *
241
236
  * @example
242
- * join_annotated_text({text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]}, {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]})
243
- * // Returns: {text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}
237
+ * join_text({content: "Hello wo", marks: [{start_offset: 6, end_offset: 8, node_id: "strong"}], annotations: []}, {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []})
238
+ * // Returns: {content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}
244
239
  */
245
- export function join_annotated_text(first_text, second_text) {
246
- const { text: first_text_content, annotations: first_annotations } = first_text;
247
- const { text: second_text_content, annotations: second_annotations } = second_text;
248
-
240
+ export function join_text(first_text, second_text) {
249
241
  // Join the text content
250
- const joined_text = first_text_content + second_text_content;
251
-
252
- // Start with all annotations from the first text (unchanged)
253
- /** @type {Array<Annotation>} */
254
- const joined_annotations = [...first_annotations];
255
-
256
- // Add annotations from the second text, shifting their offsets
257
- const offset = get_char_length(first_text_content);
258
- for (const { start_offset, end_offset, node_id } of second_annotations) {
259
- /** @type {Annotation} */
260
- const shifted_annotation = {
261
- start_offset: start_offset + offset,
262
- end_offset: end_offset + offset,
263
- node_id
264
- };
242
+ const joined = {
243
+ content: first_text.content + second_text.content,
244
+ marks: [],
245
+ annotations: []
246
+ };
265
247
 
266
- // Check if this annotation can be merged with the last annotation from first text
267
- const last_annotation = joined_annotations[joined_annotations.length - 1];
268
- if (
269
- last_annotation &&
270
- last_annotation.end_offset === shifted_annotation.start_offset && // annotations are adjacent
271
- last_annotation.node_id === shifted_annotation.node_id
272
- ) {
273
- // same node_id
274
- // Merge by extending the end position of the last annotation
275
- last_annotation.end_offset = shifted_annotation.end_offset;
276
- } else {
277
- // Add as separate annotation
278
- joined_annotations.push(shifted_annotation);
248
+ const offset = get_char_length(first_text.content);
249
+
250
+ for (const key of ['marks', 'annotations']) {
251
+ // Start with all ranges from the first text (unchanged)
252
+ const joined_ranges = (first_text[key] ?? []).map((range) => ({ ...range }));
253
+
254
+ // Add ranges from the second text, shifting their offsets
255
+ for (const { start_offset, end_offset, node_id } of second_text[key] ?? []) {
256
+ const shifted_range = {
257
+ start_offset: start_offset + offset,
258
+ end_offset: end_offset + offset,
259
+ node_id
260
+ };
261
+
262
+ // Check if this range can be merged with the last range from the first text
263
+ const last_range = joined_ranges[joined_ranges.length - 1];
264
+ if (
265
+ last_range &&
266
+ last_range.end_offset === shifted_range.start_offset && // ranges are adjacent
267
+ last_range.node_id === shifted_range.node_id
268
+ ) {
269
+ // same node_id
270
+ // Merge by extending the end position of the last range
271
+ last_range.end_offset = shifted_range.end_offset;
272
+ } else {
273
+ // Add as separate range
274
+ joined_ranges.push(shifted_range);
275
+ }
279
276
  }
277
+
278
+ joined[key] = joined_ranges;
280
279
  }
281
280
 
282
- return { text: joined_text, annotations: joined_annotations };
281
+ return joined;
283
282
  }
284
283
 
285
284
  /**
@@ -405,6 +404,51 @@ export function paths_equal(a, b) {
405
404
  return a.every((segment, index) => segment === b[index]);
406
405
  }
407
406
 
407
+ /**
408
+ * Like traverse, but returns only node ids in depth-first order and never
409
+ * clones nodes. Use this when only the reachable id set is needed (e.g.
410
+ * reference bookkeeping) — traverse() deep-clones every visited node,
411
+ * which is wasteful for id-only consumers.
412
+ *
413
+ * @param {string} node_id - The ID of the node to start traversing from
414
+ * @param {object} schema - The document schema
415
+ * @param {Record<string, any>} nodes - All document nodes
416
+ * @returns {string[]} Node ids in depth-first order (entry point last)
417
+ */
418
+ export function traverse_ids(node_id, schema, nodes) {
419
+ const ids = [];
420
+ const visited = {};
421
+ const visit = (node) => {
422
+ if (!node || visited[node.id]) {
423
+ return;
424
+ }
425
+ visited[node.id] = true;
426
+ for (const [property_name, value] of Object.entries(node)) {
427
+ const property_definition = schema[node.type].properties[property_name];
428
+
429
+ if (property_definition?.type === 'node_array') {
430
+ for (const v of value?.nodes || []) {
431
+ if (typeof v === 'string') {
432
+ visit(nodes[v]);
433
+ }
434
+ }
435
+ for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
436
+ visit(nodes[range.node_id]);
437
+ }
438
+ } else if (property_definition?.type === 'node') {
439
+ visit(nodes[value]);
440
+ } else if (property_definition?.type === 'text') {
441
+ for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
442
+ visit(nodes[range.node_id]);
443
+ }
444
+ }
445
+ }
446
+ ids.push(node.id);
447
+ };
448
+ visit(nodes[node_id]);
449
+ return ids;
450
+ }
451
+
408
452
  export function traverse(node_id, schema, nodes) {
409
453
  const json = [];
410
454
  const visited = {};
@@ -417,16 +461,22 @@ export function traverse(node_id, schema, nodes) {
417
461
  const property_definition = schema[node.type].properties[property_name];
418
462
 
419
463
  if (property_definition?.type === 'node_array') {
420
- for (const v of value) {
464
+ const node_ids = value?.nodes || [];
465
+
466
+ for (const v of node_ids) {
421
467
  if (typeof v === 'string') {
422
468
  visit(nodes[v]);
423
469
  }
424
470
  }
471
+
472
+ for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
473
+ visit(nodes[range.node_id]);
474
+ }
425
475
  } else if (property_definition?.type === 'node') {
426
476
  visit(nodes[value]);
427
- } else if (property_definition?.type === 'annotated_text') {
428
- for (const annotation of value.annotations) {
429
- visit(nodes[annotation.node_id]);
477
+ } else if (property_definition?.type === 'text') {
478
+ for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
479
+ visit(nodes[range.node_id]);
430
480
  }
431
481
  }
432
482
  }
@@ -463,3 +513,167 @@ export function is_selection_collapsed(selection) {
463
513
  return false;
464
514
  }
465
515
  }
516
+
517
+ /**
518
+ * Adjust ranges (marks or annotations) after deleting a sequence range.
519
+ *
520
+ * Works for both annotated text (character offsets) and annotated node arrays
521
+ * (node offsets).
522
+ *
523
+ * @param {Array<import('./types.d.ts').Attachment>} ranges
524
+ * @param {number} start
525
+ * @param {number} end
526
+ * @returns {{ranges: Array<import('./types.d.ts').Attachment>, removed_node_ids: string[]}}
527
+ */
528
+ export function adjust_ranges_for_deletion(ranges, start, end) {
529
+ const removed_node_ids = [];
530
+ const deletion_length = end - start;
531
+
532
+ const next_ranges = ranges
533
+ .map((range) => {
534
+ if (range.end_offset <= start) return range;
535
+
536
+ let start_offset = range.start_offset;
537
+ if (range.start_offset >= end) {
538
+ start_offset -= deletion_length;
539
+ } else if (range.start_offset > start) {
540
+ start_offset = start;
541
+ }
542
+
543
+ let end_offset = range.end_offset;
544
+ if (range.end_offset >= end) {
545
+ end_offset -= deletion_length;
546
+ } else if (range.end_offset > start) {
547
+ end_offset = start;
548
+ }
549
+
550
+ if (start_offset >= end_offset) {
551
+ removed_node_ids.push(range.node_id);
552
+ return null;
553
+ }
554
+
555
+ return { start_offset, end_offset, node_id: range.node_id };
556
+ })
557
+ .filter(Boolean);
558
+
559
+ return { ranges: next_ranges, removed_node_ids };
560
+ }
561
+
562
+ /**
563
+ * Adjust ranges (marks or annotations) after inserting into a sequence.
564
+ *
565
+ * Insertion inside a range extends it. Insertion exactly at either edge
566
+ * stays outside the range.
567
+ *
568
+ * @param {Array<import('./types.d.ts').Attachment>} ranges
569
+ * @param {number} offset
570
+ * @param {number} length
571
+ * @returns {Array<import('./types.d.ts').Attachment>}
572
+ */
573
+ export function adjust_ranges_for_insertion(ranges, offset, length) {
574
+ if (length === 0) return ranges;
575
+
576
+ return ranges.map((range) => {
577
+ if (range.end_offset <= offset) return range;
578
+
579
+ if (range.start_offset < offset) {
580
+ return {
581
+ start_offset: range.start_offset,
582
+ end_offset: range.end_offset + length,
583
+ node_id: range.node_id
584
+ };
585
+ }
586
+
587
+ return {
588
+ start_offset: range.start_offset + length,
589
+ end_offset: range.end_offset + length,
590
+ node_id: range.node_id
591
+ };
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Check whether ranges are non-empty and mutually exclusive.
597
+ *
598
+ * @param {Array<import('./types.d.ts').Attachment>} ranges
599
+ * @param {number} [length]
600
+ * @returns {boolean}
601
+ */
602
+ export function are_ranges_exclusive(ranges, length = Infinity) {
603
+ const sorted = [...ranges].sort(
604
+ (a, b) => a.start_offset - b.start_offset || a.end_offset - b.end_offset
605
+ );
606
+
607
+ return sorted.every(
608
+ (range, index) =>
609
+ Number.isInteger(range.start_offset) &&
610
+ Number.isInteger(range.end_offset) &&
611
+ range.start_offset >= 0 &&
612
+ range.start_offset < range.end_offset &&
613
+ range.end_offset <= length &&
614
+ (index === 0 || range.start_offset >= sorted[index - 1].end_offset)
615
+ );
616
+ }
617
+
618
+ /**
619
+ * Calculates abstract fragment ranges from a length and marks.
620
+ *
621
+ * @param {number} length - Length of the sequence (e.g. text length or array length)
622
+ * @param {Array<import('./types.d.ts').Mark>} marks - Array of marks
623
+ * @param {import('./types.d.ts').SelectionRange} [selection_highlight_range] - Optional selection highlight range
624
+ * @returns {Array<{type: 'content' | 'mark' | 'selection_highlight', start_offset: number, end_offset: number, mark_index?: number, node_id?: string}>}
625
+ */
626
+ export function calculate_fragment_ranges(length, marks, selection_highlight_range) {
627
+ /** @type {Array<{type: 'content' | 'mark' | 'selection_highlight', start_offset: number, end_offset: number, mark_index?: number, node_id?: string}>} */
628
+ const fragments = [];
629
+ let last_index = 0;
630
+
631
+ // Merge marks with selection highlight and sort by start offset
632
+ const ranges = [...marks, ...(selection_highlight_range ? [selection_highlight_range] : [])].sort(
633
+ (a, b) => a.start_offset - b.start_offset
634
+ );
635
+
636
+ for (const range of ranges) {
637
+ // Add content before this range
638
+ if (range.start_offset > last_index) {
639
+ fragments.push({
640
+ type: 'content',
641
+ start_offset: last_index,
642
+ end_offset: range.start_offset
643
+ });
644
+ }
645
+
646
+ if ('node_id' in range) {
647
+ const mark = /** @type {import('./types.d.ts').Mark} */ (range);
648
+ const mark_index =
649
+ /** @type {import('./types.d.ts').Mark & { mark_index?: number }} */ (mark).mark_index ??
650
+ marks.indexOf(mark);
651
+ fragments.push({
652
+ type: 'mark',
653
+ start_offset: mark.start_offset,
654
+ end_offset: mark.end_offset,
655
+ node_id: mark.node_id,
656
+ mark_index
657
+ });
658
+ } else {
659
+ fragments.push({
660
+ type: 'selection_highlight',
661
+ start_offset: range.start_offset,
662
+ end_offset: range.end_offset
663
+ });
664
+ }
665
+
666
+ last_index = range.end_offset;
667
+ }
668
+
669
+ // Add any remaining content
670
+ if (last_index < length) {
671
+ fragments.push({
672
+ type: 'content',
673
+ start_offset: last_index,
674
+ end_offset: length
675
+ });
676
+ }
677
+
678
+ return fragments;
679
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -64,5 +64,8 @@
64
64
  "vite": "^8.0.0",
65
65
  "vitest": "^4.1.0",
66
66
  "vitest-browser-svelte": "^2.1.0"
67
+ },
68
+ "allowScripts": {
69
+ "fsevents@2.3.3": true
67
70
  }
68
71
  }
@@ -1,5 +1,5 @@
1
1
  import { insert_default_node, break_text_node } from './transforms.svelte.js';
2
- import { can_switch_annotation_type } from './doc_utils.js';
2
+ import { can_switch_mark_type, get_selected_range_types } from './doc_utils.js';
3
3
  import { is_selection_collapsed, get_char_length, char_slice } from './utils.js';
4
4
 
5
5
  /**
@@ -107,10 +107,13 @@ export class SelectParentCommand extends Command {
107
107
  }
108
108
 
109
109
  /**
110
- * Generic command that toggles an annotation on the current text selection.
111
- * Used for simple annotations like bold, italic, highlight, etc.
110
+ * Generic command that toggles a mark on the current text or node selection.
111
+ *
112
+ * Marks are mutually exclusive, so all touched marks compete: same-type marks
113
+ * are removed, a single touched property-less mark may switch type, and mixed
114
+ * touched types disable the command. Annotations never affect mark toggling.
112
115
  */
113
- export class ToggleAnnotationCommand extends Command {
116
+ export class ToggleMarkCommand extends Command {
114
117
  constructor(node_type, context) {
115
118
  super(context);
116
119
  this.node_type = node_type;
@@ -119,34 +122,89 @@ export class ToggleAnnotationCommand extends Command {
119
122
  active = $derived(this.is_active());
120
123
 
121
124
  is_active() {
122
- return this.context.session.active_annotation(this.node_type);
125
+ const selected_marks = this.context.session.selected_marks;
126
+ return (
127
+ selected_marks.length > 0 &&
128
+ selected_marks.every(({ node }) => node?.type === this.node_type)
129
+ );
123
130
  }
124
131
 
125
132
  is_enabled() {
126
133
  const { session, editable } = this.context;
127
- const active_annotation = session.active_annotation();
128
- const has_annotation = session.active_annotation(this.node_type);
129
- const can_switch_annotation =
130
- active_annotation &&
131
- !has_annotation &&
132
- session.available_annotation_types.includes(this.node_type) &&
133
- can_switch_annotation_type(
134
- session.schema,
135
- session.get(active_annotation.node_id),
136
- this.node_type
137
- );
138
- const no_annotation_and_caret_not_collapsed =
139
- !active_annotation && !is_selection_collapsed(session.selection);
134
+ const selection = session.selection;
135
+ const is_valid_selection =
136
+ selection?.type === 'text' ||
137
+ (selection?.type === 'node' && !is_selection_collapsed(selection));
138
+ const mark_type_is_allowed = session.available_mark_types.includes(this.node_type);
139
+ const selected_marks = session.selected_marks;
140
+ const selected_mark_types = get_selected_range_types(selected_marks);
141
+
142
+ if (!editable || !is_valid_selection || !mark_type_is_allowed) return false;
143
+ if (selected_mark_types.size > 1) return false;
144
+
145
+ if (selected_marks.length === 0) {
146
+ return Boolean(selection && !is_selection_collapsed(selection));
147
+ }
140
148
 
141
- return (
142
- editable &&
143
- session.selection?.type === 'text' &&
144
- (has_annotation || can_switch_annotation || no_annotation_and_caret_not_collapsed)
149
+ const first_selected_mark = selected_marks[0];
150
+ const selected_mark_type = first_selected_mark.node.type;
151
+ if (selected_mark_type === this.node_type) return true;
152
+ if (selected_marks.length !== 1) return false;
153
+
154
+ return can_switch_mark_type(session.schema, selected_mark_type, this.node_type);
155
+ }
156
+
157
+ execute() {
158
+ this.context.session.apply(this.context.session.tr.toggle_mark(this.node_type));
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Generic command that toggles an annotation on the current text or node selection.
164
+ *
165
+ * Annotations only compete with touched annotations of the same type: touched
166
+ * same-type annotations are removed, otherwise a new annotation is created.
167
+ * Marks and other annotation types never block the toggle.
168
+ */
169
+ export class ToggleAnnotationCommand extends Command {
170
+ constructor(node_type, context) {
171
+ super(context);
172
+ this.node_type = node_type;
173
+ }
174
+
175
+ active = $derived(this.is_active());
176
+
177
+ // Mirrors the filter in tr.toggle_annotation, so active/enabled match what
178
+ // execute() does.
179
+ relevant_annotations() {
180
+ return this.context.session.selected_annotations.filter(
181
+ ({ node }) => node?.type === this.node_type
145
182
  );
146
183
  }
147
184
 
185
+ is_active() {
186
+ return this.relevant_annotations().length > 0;
187
+ }
188
+
189
+ is_enabled() {
190
+ const { session, editable } = this.context;
191
+ const selection = session.selection;
192
+ const is_valid_selection =
193
+ selection?.type === 'text' ||
194
+ (selection?.type === 'node' && !is_selection_collapsed(selection));
195
+ const annotation_type_is_allowed = session.available_annotation_types.includes(this.node_type);
196
+
197
+ if (!editable || !is_valid_selection || !annotation_type_is_allowed) return false;
198
+
199
+ // Removing touched same-type annotations also works from a collapsed
200
+ // caret inside the annotation; creating a new one requires an expanded
201
+ // selection.
202
+ if (this.relevant_annotations().length > 0) return true;
203
+ return Boolean(selection && !is_selection_collapsed(selection));
204
+ }
205
+
148
206
  execute() {
149
- this.context.session.apply(this.context.session.tr.annotate_text(this.node_type));
207
+ this.context.session.apply(this.context.session.tr.toggle_annotation(this.node_type));
150
208
  }
151
209
  }
152
210
 
@@ -179,7 +237,7 @@ export class AddNewLineCommand extends Command {
179
237
 
180
238
  const collapsed_offset = tr.selection.anchor_offset;
181
239
  const content = tr.get(tr.selection.path);
182
- const text_before_caret = char_slice(content.text, 0, collapsed_offset);
240
+ const text_before_caret = char_slice(content.content, 0, collapsed_offset);
183
241
  const line_start_index = text_before_caret.lastIndexOf('\n') + 1;
184
242
  const current_line_prefix = text_before_caret.slice(line_start_index);
185
243
  const indentation_match = current_line_prefix.match(/^[\t ]*/);
@@ -235,7 +293,7 @@ export class SelectAllCommand extends Command {
235
293
 
236
294
  if (selection.type === 'text') {
237
295
  const text_content = session.get(selection.path);
238
- const text_length = get_char_length(text_content.text);
296
+ const text_length = get_char_length(text_content.content);
239
297
 
240
298
  // Check if all text is already selected
241
299
  const is_all_text_selected =
@@ -278,7 +336,7 @@ export class SelectAllCommand extends Command {
278
336
  // Check if the entire node_array is already selected
279
337
  const is_entire_node_array_selected =
280
338
  Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
281
- Math.max(selection.anchor_offset, selection.focus_offset) === node_array.length;
339
+ Math.max(selection.anchor_offset, selection.focus_offset) === node_array.nodes.length;
282
340
 
283
341
  if (!is_entire_node_array_selected) {
284
342
  // Select the entire node_array
@@ -286,7 +344,7 @@ export class SelectAllCommand extends Command {
286
344
  type: 'node',
287
345
  path: node_array_path,
288
346
  anchor_offset: 0,
289
- focus_offset: node_array.length
347
+ focus_offset: node_array.nodes.length
290
348
  };
291
349
  } else {
292
350
  // Entire node_array is selected, try to move up to parent node_array
@@ -16,17 +16,45 @@
16
16
  let child_index = $derived(node_array_meta ? parseInt(String(path.at(-1)), 10) : -1);
17
17
  let is_first = $derived(node_array_meta && child_index === 0);
18
18
  let is_last = $derived(node_array_meta && child_index === node_array_meta.length - 1);
19
+
20
+ // Marks and annotations covering this node become classes automatically:
21
+ // `mark-<type>`/`anno-<type>`, plus `-start`/`-end` variants on the run's
22
+ // first/last covered node. This allows styling ranges (including
23
+ // annotations, which never render wrapper components) with pure CSS. For
24
+ // richer rendering, node components still receive the `mark` and
25
+ // `annotations` props.
26
+ let range_classes = $derived.by(() => {
27
+ if (child_index < 0 || !node_array_meta?.annotations_for) return '';
28
+ const classes = [];
29
+
30
+ const add_classes = (range, prefix) => {
31
+ const range_type = range?.node?.type;
32
+ if (!range_type) return;
33
+ classes.push(`${prefix}-${range_type}`);
34
+ if (range.is_start) classes.push(`${prefix}-${range_type}-start`);
35
+ if (range.is_end) classes.push(`${prefix}-${range_type}-end`);
36
+ };
37
+
38
+ add_classes(node_array_meta.mark_for?.(child_index), 'mark');
39
+ for (const annotation of node_array_meta.annotations_for(child_index)) {
40
+ add_classes(annotation, 'anno');
41
+ }
42
+ return classes.join(' ');
43
+ });
19
44
  </script>
20
45
 
21
46
  <svelte:element
22
47
  this={tag}
23
48
  id={node.id}
24
- class="node-{node.type} {css_class}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
49
+ class="node-{node.type} {css_class}{range_classes
50
+ ? ` ${range_classes}`
51
+ : ''}{is_first ? ' first' : ''}{is_last ? ' last' : ''}"
25
52
  data-node-id={node.id}
26
53
  data-path={path_str}
27
54
  data-type="node"
28
55
  style="anchor-name: --{path_str};{style}"
29
56
  {...rest}
57
+ {@attach svedit.visibility_registry.track_node(path_str)}
30
58
  >
31
59
  {@render children()}
32
60
  </svelte:element>