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
package/dist/utils.js CHANGED
@@ -1,24 +1,45 @@
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' });
6
6
 
7
7
  /**
8
- * Detect if the current browser is on a mobile device
8
+ * Detect if the virtual keyboard is likely visible.
9
+ *
10
+ * This is a heuristic based on the visual viewport becoming smaller than the
11
+ * layout viewport.
12
+ *
13
+ * @returns {boolean} true if the virtual keyboard is likely active, false otherwise
14
+ */
15
+ export function is_virtual_keyboard_active() {
16
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
17
+ return false;
18
+ }
19
+
20
+ const visual_viewport = window.visualViewport;
21
+ if (!visual_viewport) {
22
+ return false;
23
+ }
24
+
25
+ return visual_viewport.height < document.documentElement.clientHeight;
26
+ }
27
+
28
+ /**
29
+ * Detect if the current browser is likely on a mobile device.
30
+ *
31
+ * This uses the user agent only so touch-capable laptops are not treated as
32
+ * mobile browsers.
33
+ *
9
34
  * @returns {boolean} true if mobile browser, false otherwise
10
35
  */
11
36
  export function is_mobile_browser() {
12
- if (typeof window === 'undefined' || typeof navigator === 'undefined') {
37
+ if (typeof navigator === 'undefined') {
13
38
  return false;
14
39
  }
15
40
 
16
41
  const user_agent = navigator.userAgent;
17
- return (
18
- /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
19
- 'ontouchstart' in window ||
20
- navigator.maxTouchPoints > 0
21
- );
42
+ return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent);
22
43
  }
23
44
 
24
45
  // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
@@ -155,110 +176,109 @@ export function char_to_utf16_offset(str, char_offset) {
155
176
  /**
156
177
  * Splits an annotated text at the specified character position.
157
178
  *
158
- * Annotations that span the split point will be divided appropriately,
159
- * 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.
160
181
  *
161
- * @param {AnnotatedText} text_with_annotations - Annotated text object
182
+ * @param {AnnotatedText} text_value - Annotated text object
162
183
  * @param {number} at_position - Character position where to split (0-based)
163
184
  * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
164
185
  *
165
186
  * @example
166
- * 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)
167
188
  * // Returns:
168
189
  * // [
169
- * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
170
- * // {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: []}
171
192
  * // ]
172
193
  */
173
- export function split_annotated_text(text_with_annotations, at_position) {
174
- const { text, annotations } = text_with_annotations;
194
+ export function split_text(text_value, at_position) {
195
+ const { content } = text_value;
175
196
 
176
197
  // Split the text using character-aware slicing
177
- const left_text = char_slice(text, 0, at_position);
178
- const right_text = char_slice(text, at_position);
179
-
180
- /** @type {Array<Annotation>} */
181
- const left_annotations = [];
182
- /** @type {Array<Annotation>} */
183
- const right_annotations = [];
184
-
185
- // Process each annotation
186
- for (const { start_offset, end_offset, node_id } of annotations) {
187
- if (end_offset <= at_position) {
188
- // Annotation is entirely in the left part
189
- left_annotations.push({ start_offset, end_offset, node_id });
190
- } else if (start_offset >= at_position) {
191
- // Annotation is entirely in the right part - shift offsets
192
- right_annotations.push({
193
- start_offset: start_offset - at_position,
194
- end_offset: end_offset - at_position,
195
- node_id
196
- });
197
- } else {
198
- // Annotation spans the split point - split it
199
- left_annotations.push({ start_offset, end_offset: at_position, node_id });
200
- 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
+ }
201
219
  }
202
220
  }
203
221
 
204
- return [
205
- { text: left_text, annotations: left_annotations },
206
- { text: right_text, annotations: right_annotations }
207
- ];
222
+ return [left, right];
208
223
  }
209
224
 
210
225
  /**
211
226
  * Joins two annotated texts into a single annotated text.
212
227
  *
213
- * Annotations from the second text will have their offsets shifted by the length
214
- * 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.
215
231
  *
216
232
  * @param {AnnotatedText} first_text - First annotated text object
217
233
  * @param {AnnotatedText} second_text - Second annotated text object
218
234
  * @returns {AnnotatedText} Combined annotated text object
219
235
  *
220
236
  * @example
221
- * 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"}]})
222
- * // 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: []}
223
239
  */
224
- export function join_annotated_text(first_text, second_text) {
225
- const { text: first_text_content, annotations: first_annotations } = first_text;
226
- const { text: second_text_content, annotations: second_annotations } = second_text;
227
-
240
+ export function join_text(first_text, second_text) {
228
241
  // Join the text content
229
- const joined_text = first_text_content + second_text_content;
230
-
231
- // Start with all annotations from the first text (unchanged)
232
- /** @type {Array<Annotation>} */
233
- const joined_annotations = [...first_annotations];
234
-
235
- // Add annotations from the second text, shifting their offsets
236
- const offset = get_char_length(first_text_content);
237
- for (const { start_offset, end_offset, node_id } of second_annotations) {
238
- /** @type {Annotation} */
239
- const shifted_annotation = {
240
- start_offset: start_offset + offset,
241
- end_offset: end_offset + offset,
242
- node_id
243
- };
242
+ const joined = {
243
+ content: first_text.content + second_text.content,
244
+ marks: [],
245
+ annotations: []
246
+ };
244
247
 
245
- // Check if this annotation can be merged with the last annotation from first text
246
- const last_annotation = joined_annotations[joined_annotations.length - 1];
247
- if (
248
- last_annotation &&
249
- last_annotation.end_offset === shifted_annotation.start_offset && // annotations are adjacent
250
- last_annotation.node_id === shifted_annotation.node_id
251
- ) {
252
- // same node_id
253
- // Merge by extending the end position of the last annotation
254
- last_annotation.end_offset = shifted_annotation.end_offset;
255
- } else {
256
- // Add as separate annotation
257
- 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
+ }
258
276
  }
277
+
278
+ joined[key] = joined_ranges;
259
279
  }
260
280
 
261
- return { text: joined_text, annotations: joined_annotations };
281
+ return joined;
262
282
  }
263
283
 
264
284
  /**
@@ -384,6 +404,51 @@ export function paths_equal(a, b) {
384
404
  return a.every((segment, index) => segment === b[index]);
385
405
  }
386
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
+
387
452
  export function traverse(node_id, schema, nodes) {
388
453
  const json = [];
389
454
  const visited = {};
@@ -396,16 +461,22 @@ export function traverse(node_id, schema, nodes) {
396
461
  const property_definition = schema[node.type].properties[property_name];
397
462
 
398
463
  if (property_definition?.type === 'node_array') {
399
- for (const v of value) {
464
+ const node_ids = value?.nodes || [];
465
+
466
+ for (const v of node_ids) {
400
467
  if (typeof v === 'string') {
401
468
  visit(nodes[v]);
402
469
  }
403
470
  }
471
+
472
+ for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
473
+ visit(nodes[range.node_id]);
474
+ }
404
475
  } else if (property_definition?.type === 'node') {
405
476
  visit(nodes[value]);
406
- } else if (property_definition?.type === 'annotated_text') {
407
- for (const annotation of value.annotations) {
408
- 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]);
409
480
  }
410
481
  }
411
482
  }
@@ -442,3 +513,167 @@ export function is_selection_collapsed(selection) {
442
513
  return false;
443
514
  }
444
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.0",
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,6 +1,6 @@
1
1
  import { insert_default_node, break_text_node } from './transforms.svelte.js';
2
- import { can_switch_annotation_type } from './doc_utils.js';
3
- import { is_selection_collapsed, is_mobile_browser, get_char_length, char_slice } from './utils.js';
2
+ import { can_switch_mark_type, get_selected_range_types } from './doc_utils.js';
3
+ import { is_selection_collapsed, get_char_length, char_slice } from './utils.js';
4
4
 
5
5
  /**
6
6
  * Base class for commands that can be executed in response to user actions
@@ -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,41 +122,95 @@ 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
 
153
211
  /**
154
212
  * Command that adds a new line character at the current caret position.
155
213
  * Only works in text selections where newlines are allowed.
156
- * Disabled on mobile browsers where Shift+Enter has different behavior.
157
214
  */
158
215
  export class AddNewLineCommand extends Command {
159
216
  is_enabled() {
@@ -162,7 +219,6 @@ export class AddNewLineCommand extends Command {
162
219
 
163
220
  return (
164
221
  this.context.editable &&
165
- !is_mobile_browser() &&
166
222
  selection?.type === 'text' &&
167
223
  session.inspect(selection.path).allow_newlines
168
224
  );
@@ -181,7 +237,7 @@ export class AddNewLineCommand extends Command {
181
237
 
182
238
  const collapsed_offset = tr.selection.anchor_offset;
183
239
  const content = tr.get(tr.selection.path);
184
- const text_before_caret = char_slice(content.text, 0, collapsed_offset);
240
+ const text_before_caret = char_slice(content.content, 0, collapsed_offset);
185
241
  const line_start_index = text_before_caret.lastIndexOf('\n') + 1;
186
242
  const current_line_prefix = text_before_caret.slice(line_start_index);
187
243
  const indentation_match = current_line_prefix.match(/^[\t ]*/);
@@ -237,7 +293,7 @@ export class SelectAllCommand extends Command {
237
293
 
238
294
  if (selection.type === 'text') {
239
295
  const text_content = session.get(selection.path);
240
- const text_length = get_char_length(text_content.text);
296
+ const text_length = get_char_length(text_content.content);
241
297
 
242
298
  // Check if all text is already selected
243
299
  const is_all_text_selected =
@@ -280,7 +336,7 @@ export class SelectAllCommand extends Command {
280
336
  // Check if the entire node_array is already selected
281
337
  const is_entire_node_array_selected =
282
338
  Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
283
- Math.max(selection.anchor_offset, selection.focus_offset) === node_array.length;
339
+ Math.max(selection.anchor_offset, selection.focus_offset) === node_array.nodes.length;
284
340
 
285
341
  if (!is_entire_node_array_selected) {
286
342
  // Select the entire node_array
@@ -288,7 +344,7 @@ export class SelectAllCommand extends Command {
288
344
  type: 'node',
289
345
  path: node_array_path,
290
346
  anchor_offset: 0,
291
- focus_offset: node_array.length
347
+ focus_offset: node_array.nodes.length
292
348
  };
293
349
  } else {
294
350
  // Entire node_array is selected, try to move up to parent node_array