svedit 0.6.3 → 0.7.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 (65) hide show
  1. package/README.md +117 -45
  2. package/dist/AnnotatedTextProperty.svelte +19 -16
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +3 -3
  5. package/dist/Command.svelte.js +5 -5
  6. package/dist/CustomProperty.svelte +5 -4
  7. package/dist/Node.svelte +29 -14
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +29 -9
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeCaret.svelte +71 -0
  12. package/dist/NodeCaret.svelte.d.ts +27 -0
  13. package/dist/NodeCaret.svelte.d.ts.map +1 -0
  14. package/dist/NodeGap.svelte +249 -0
  15. package/dist/NodeGap.svelte.d.ts +20 -0
  16. package/dist/NodeGap.svelte.d.ts.map +1 -0
  17. package/dist/NodeGapMarkers.svelte +456 -0
  18. package/dist/NodeGapMarkers.svelte.d.ts +12 -0
  19. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -0
  20. package/dist/NodeSelectionMarkers.svelte +49 -0
  21. package/dist/NodeSelectionMarkers.svelte.d.ts +7 -0
  22. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -0
  23. package/dist/Session.svelte.d.ts +6 -6
  24. package/dist/Session.svelte.d.ts.map +1 -1
  25. package/dist/Session.svelte.js +13 -69
  26. package/dist/Svedit.svelte +158 -90
  27. package/dist/Svedit.svelte.d.ts.map +1 -1
  28. package/dist/Transaction.svelte.d.ts +1 -1
  29. package/dist/Transaction.svelte.d.ts.map +1 -1
  30. package/dist/Transaction.svelte.js +10 -70
  31. package/dist/doc_utils.d.ts +12 -0
  32. package/dist/doc_utils.d.ts.map +1 -1
  33. package/dist/doc_utils.js +61 -2
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/node_gap_computation.svelte.d.ts +5 -0
  37. package/dist/node_gap_computation.svelte.d.ts.map +1 -0
  38. package/dist/node_gap_computation.svelte.js +621 -0
  39. package/dist/styles/svedit-colors.css +8 -0
  40. package/dist/transforms.svelte.d.ts.map +1 -1
  41. package/dist/transforms.svelte.js +7 -10
  42. package/dist/types.d.ts +56 -68
  43. package/dist/utils.d.ts +0 -3
  44. package/dist/utils.d.ts.map +1 -1
  45. package/dist/utils.js +10 -10
  46. package/package.json +21 -21
  47. package/src/lib/AnnotatedTextProperty.svelte +19 -16
  48. package/src/lib/Command.svelte.js +5 -5
  49. package/src/lib/CustomProperty.svelte +5 -4
  50. package/src/lib/Node.svelte +29 -14
  51. package/src/lib/NodeArrayProperty.svelte +29 -9
  52. package/src/lib/NodeCaret.svelte +71 -0
  53. package/src/lib/NodeGap.svelte +249 -0
  54. package/src/lib/NodeGapMarkers.svelte +456 -0
  55. package/src/lib/NodeSelectionMarkers.svelte +49 -0
  56. package/src/lib/Session.svelte.js +13 -69
  57. package/src/lib/Svedit.svelte +158 -90
  58. package/src/lib/Transaction.svelte.js +10 -70
  59. package/src/lib/doc_utils.js +61 -2
  60. package/src/lib/index.js +4 -0
  61. package/src/lib/node_gap_computation.svelte.js +621 -0
  62. package/src/lib/styles/svedit-colors.css +8 -0
  63. package/src/lib/transforms.svelte.js +7 -10
  64. package/src/lib/types.d.ts +56 -68
  65. package/src/lib/utils.js +10 -10
@@ -1,11 +1,14 @@
1
1
  <script>
2
- import { getContext, setContext } from 'svelte';
2
+ import { flushSync, getContext, setContext } from 'svelte';
3
3
  import {
4
4
  snake_to_pascal,
5
5
  get_char_length,
6
6
  utf16_to_char_offset,
7
7
  char_to_utf16_offset
8
8
  } from './utils.js';
9
+ import { create_gap_computation } from './node_gap_computation.svelte.js';
10
+ import DefaultNodeSelectionMarkers from './NodeSelectionMarkers.svelte';
11
+ import './styles/svedit-colors.css';
9
12
 
10
13
  /** @import {
11
14
  * SveditProps,
@@ -30,12 +33,15 @@
30
33
 
31
34
  let canvas_el;
32
35
  let root_node = $derived(session.get(path));
33
- let Overlays = $derived(session.config.system_components.Overlays);
36
+ let Overlays = $derived(session.config.system_components?.Overlays);
37
+ let NodeSelectionMarkers = $derived(session.config.system_components?.NodeSelectionMarkers ?? DefaultNodeSelectionMarkers);
34
38
  let RootComponent = $derived(session.config.node_components[snake_to_pascal(root_node.type)]);
35
39
 
36
40
  let is_composing = $state(false);
41
+ let canvas_focused = $state(false);
37
42
  let before_composition_selection = null;
38
43
 
44
+
39
45
  // let is_mobile = $derived(is_mobile_browser());
40
46
  // let is_chrome_desktop = $derived(is_chrome_desktop_browser());
41
47
 
@@ -58,10 +64,14 @@
58
64
  get canvas_el() {
59
65
  return canvas_el;
60
66
  },
67
+ get canvas_focused() {
68
+ return canvas_focused;
69
+ },
61
70
  focus_canvas
62
71
  };
63
72
 
64
73
  setContext('svedit', context);
74
+ create_gap_computation(context);
65
75
 
66
76
  // Get KeyMapper from context (may be undefined if not provided)
67
77
  const key_mapper = getContext('key_mapper');
@@ -71,16 +81,6 @@
71
81
  session.initialize_commands(context);
72
82
  });
73
83
 
74
- // Handle focus - push session's keymap onto stack
75
- function handle_canvas_focus() {
76
- key_mapper?.push_scope(session.keymap);
77
- }
78
-
79
- // Handle blur - pop document's keymap from stack
80
- function handle_canvas_blur() {
81
- key_mapper?.pop_scope();
82
- }
83
-
84
84
  /**
85
85
  * @param {InputEvent} event
86
86
  */
@@ -219,7 +219,7 @@
219
219
  function oncompositionend(event) {
220
220
  console.log('DEBUG: oncompositionend, insert:', event.data, event);
221
221
  if (!canvas_el?.contains(document.activeElement)) return;
222
- if (canvas_el?.contains(document.activeElement) && session.selection?.type === 'text') {
222
+ if (session.selection?.type === 'text') {
223
223
  // We need to remember the user's selection, as it might have changed in the process
224
224
  // of finishing a composition. For instance, the user might have selected a different
225
225
  // part of the text while composing.
@@ -260,6 +260,7 @@
260
260
  // Map DOM selection to internal model
261
261
  function onselectionchange() {
262
262
  if (!editable) return;
263
+ if (!canvas_focused) return;
263
264
  const dom_selection = window.getSelection();
264
265
  if (!dom_selection.rangeCount) return;
265
266
 
@@ -423,7 +424,8 @@ ${fallback_html}`;
423
424
  * @param {boolean} delete_selection - used by oncut()
424
425
  */
425
426
  function oncopy(event, delete_selection = false) {
426
- // Only handle copy events if focus is within the canvas
427
+ // Only handle copy events if editable and focus is within the canvas
428
+ if (!editable) return;
427
429
  if (!canvas_el?.contains(document.activeElement)) return;
428
430
 
429
431
  event.preventDefault();
@@ -493,6 +495,7 @@ ${fallback_html}`;
493
495
  }
494
496
 
495
497
  function oncut(event) {
498
+ if (!editable) return;
496
499
  oncopy(event, true);
497
500
  }
498
501
 
@@ -500,7 +503,7 @@ ${fallback_html}`;
500
503
  * Attempts to paste JSON data as a node at the current selection.
501
504
  *
502
505
  * @param {string|object} pasted_json - The JSON data to paste, either as a string or parsed object
503
- * @param {Selection} [selection] - Optional selection (node cursor) where the payload should be pasted
506
+ * @param {Selection} [selection] - Optional selection (node caret) where the payload should be pasted
504
507
  * @returns {boolean} True if the paste operation was successful, false otherwise
505
508
  */
506
509
  function try_node_paste(pasted_json, selection) {
@@ -553,9 +556,9 @@ ${fallback_html}`;
553
556
  return true;
554
557
  } else {
555
558
  if (tr.selection.path.length >= 2) {
556
- const next_node_insert_cursor = session.get_next_node_insert_cursor(tr.selection);
557
- if (next_node_insert_cursor) {
558
- try_node_paste(pasted_json, next_node_insert_cursor);
559
+ const next_node_insert_caret = session.get_next_node_insert_caret(tr.selection);
560
+ if (next_node_insert_caret) {
561
+ try_node_paste(pasted_json, next_node_insert_caret);
559
562
  }
560
563
  }
561
564
  }
@@ -563,7 +566,8 @@ ${fallback_html}`;
563
566
  }
564
567
 
565
568
  async function onpaste(event) {
566
- // Only handle paste events if focus is within the canvas
569
+ // Only handle paste events if editable and focus is within the canvas
570
+ if (!editable) return;
567
571
  if (!canvas_el?.contains(document.activeElement)) return;
568
572
  event.preventDefault();
569
573
 
@@ -637,8 +641,8 @@ ${fallback_html}`;
637
641
  }
638
642
  }
639
643
 
640
- console.log('plain_text', plain_text);
641
- console.log('pasted_json', pasted_json);
644
+ // console.log('plain_text', plain_text);
645
+ // console.log('pasted_json', pasted_json);
642
646
 
643
647
  if (pasted_json?.main_nodes && session.selection?.type === 'node') {
644
648
  // Paste nodes at a node selection
@@ -671,15 +675,15 @@ ${fallback_html}`;
671
675
  pasted_json?.main_nodes?.length === 1 &&
672
676
  session.kind(pasted_json?.nodes[pasted_json.main_nodes[0]]) === 'text'
673
677
  ) {
674
- // Paste a single text node, at a text cursor
678
+ // Paste a single text node, at a text caret
675
679
  const text_property = pasted_json.nodes[pasted_json.main_nodes[0]].content;
676
680
  session.apply(
677
681
  session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
678
682
  );
679
683
  } else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
680
- // Paste nodes at a text or property selection by finding the next valid insert cursor
681
- const next_node_insert_cursor = session.get_next_node_insert_cursor(session.selection);
682
- try_node_paste(pasted_json, next_node_insert_cursor);
684
+ // Paste nodes at a text or property selection by finding the next valid insert caret
685
+ const next_node_insert_caret = session.get_next_node_insert_caret(session.selection);
686
+ try_node_paste(pasted_json, next_node_insert_caret);
683
687
  } else if (typeof plain_text === 'string') {
684
688
  // External paste: Fallback to plain text when no svedit data is found
685
689
  session.apply(session.tr.insert_text(plain_text.trim()));
@@ -690,19 +694,19 @@ ${fallback_html}`;
690
694
 
691
695
  function render_selection() {
692
696
  const selection = /** @type {Selection} */ (session.selection);
693
- let prev_selection =
694
- __get_property_selection_from_dom() ||
695
- __get_text_selection_from_dom() ||
696
- __get_node_selection_from_dom();
697
697
 
698
698
  if (!selection) {
699
- // No model selection -> just leave things as they are'
699
+ // No model selection -> just leave things as they are
700
700
  let dom_selection = window.getSelection();
701
701
  dom_selection.removeAllRanges();
702
702
  return;
703
703
  }
704
704
 
705
705
  // NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
706
+ let prev_selection =
707
+ __get_property_selection_from_dom() ||
708
+ __get_text_selection_from_dom() ||
709
+ __get_node_selection_from_dom();
706
710
  if (
707
711
  JSON.stringify(selection) === JSON.stringify(prev_selection) &&
708
712
  canvas_el?.contains(document.activeElement)
@@ -722,9 +726,41 @@ ${fallback_html}`;
722
726
  }
723
727
  }
724
728
 
729
+ // Handle focus - push session's keymap onto stack
730
+ function handle_canvas_focus() {
731
+ // Use flushSync so highlight spans are removed from the DOM
732
+ // immediately, before the browser processes the click's selection.
733
+ flushSync(() => {
734
+ // Clear the model selection so render_selection() does not call
735
+ // setBaseAndExtent() with the old position, which would battle
736
+ // with the new selection the user is making (the click/drag that
737
+ // triggered this focus). The browser will place the caret and
738
+ // fire selectionchange, which sets the model selection correctly.
739
+ session.selection = null;
740
+ canvas_focused = true;
741
+ });
742
+ key_mapper?.push_scope(session.keymap);
743
+ }
744
+
745
+ // Handle blur - pop document's keymap from stack
746
+ function handle_canvas_blur() {
747
+ // Use flushSync so the selection highlight span (with its CSS anchor)
748
+ // is in the DOM immediately, before any popover/dialog tries to
749
+ // position itself.
750
+ flushSync(() => {
751
+ canvas_focused = false;
752
+ });
753
+ key_mapper?.pop_scope();
754
+ }
755
+
725
756
  function focus_canvas() {
726
- // We just render the selection (which will return focus to the canvas) implicitly
727
- render_selection();
757
+ // Use flushSync so highlight spans are removed from the DOM
758
+ // immediately, before we focus and render_selection walks the
759
+ // text nodes.
760
+ flushSync(() => {
761
+ canvas_focused = true;
762
+ });
763
+ canvas_el?.focus();
728
764
  }
729
765
 
730
766
  /**
@@ -746,14 +782,14 @@ ${fallback_html}`;
746
782
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
747
783
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
748
784
 
749
- // EDGE CASE: Let's first check if we are in a cursor trap for node cursors
750
- let after_node_cursor_trap =
751
- focus_node && focus_node?.closest('[data-type="after-node-cursor-trap"]');
752
- if (after_node_cursor_trap && focus_node === anchor_node) {
753
- // Find the node that this cursor trap belongs to
754
- let node = /** @type {HTMLElement} */ (after_node_cursor_trap.closest('[data-type="node"]'));
785
+ // EDGE CASE: Let's first check if we are in a node gap for node carets
786
+ let gap_after_el =
787
+ focus_node && focus_node?.closest('[data-type="gap-after"]');
788
+ if (gap_after_el && focus_node === anchor_node) {
789
+ // Find the node that this gap belongs to
790
+ let node = /** @type {HTMLElement} */ (gap_after_el.closest('[data-type="node"]'));
755
791
  if (!node) {
756
- console.log('No corresponding node found for after-node-cursor-trap');
792
+ console.log('No corresponding node found for gap-after');
757
793
  return null;
758
794
  }
759
795
  const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
@@ -767,11 +803,11 @@ ${fallback_html}`;
767
803
  };
768
804
  }
769
805
 
770
- // EDGE CASE: Let's check if we are in a position-zero-cursor-trap for node_arrays.
771
- let position_zero_cursor_trap = focus_node.closest('[data-type="position-zero-cursor-trap"]');
772
- if (position_zero_cursor_trap && focus_node === anchor_node) {
806
+ // EDGE CASE: Let's check if we are in a gap-before for node_arrays.
807
+ let gap_before_el = focus_node.closest('[data-type="gap-before"]');
808
+ if (gap_before_el && focus_node === anchor_node) {
773
809
  const node_array_el = /** @type {HTMLElement} */ (
774
- position_zero_cursor_trap.closest('[data-type="node_array"]')
810
+ gap_before_el.closest('[data-type="node_array"]')
775
811
  );
776
812
  const node_array_path = node_array_el.dataset.path.split('.');
777
813
  return {
@@ -801,22 +837,38 @@ ${fallback_html}`;
801
837
  let focus_node_depth = focus_root_path.length;
802
838
  let anchor_node_depth = anchor_root_path.length;
803
839
 
804
- // HACK: this works only for one level nesting - should be done recursively to work generally
805
- if (focus_root_path.length > anchor_root_path.length) {
806
- focus_root = focus_root.parentElement.closest('[data-path][data-type="node"]');
807
- if (!focus_root) return null;
808
- focus_root_path = focus_root.dataset.path.split('.');
809
- } else if (anchor_root_path.length > focus_root_path.length) {
810
- anchor_root = anchor_root.parentElement.closest('[data-path][data-type="node"]');
811
- if (!anchor_root) return null;
812
- anchor_root_path = anchor_root.dataset.path.split('.');
813
- }
814
-
815
- const is_same_node_array =
816
- focus_root_path.slice(0, -1).join('.') === anchor_root_path.slice(0, -1).join('.');
817
- if (!is_same_node_array) {
818
- console.log('invalid selection, not same node_array');
819
- return null;
840
+ // Walk both endpoints up the DOM until they share the same parent node_array.
841
+ // This handles selections that span across arbitrarily nested node arrays by
842
+ // finding the lowest common ancestor node_array and projecting each endpoint
843
+ // onto its index within that array.
844
+ let focus_walked_up = false;
845
+ let anchor_walked_up = false;
846
+ while (
847
+ focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
848
+ ) {
849
+ if (focus_root_path.length > anchor_root_path.length) {
850
+ // Focus is deeper — walk it up
851
+ focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
852
+ if (!focus_root) return null;
853
+ focus_root_path = focus_root.dataset.path.split('.');
854
+ focus_walked_up = true;
855
+ } else if (anchor_root_path.length > focus_root_path.length) {
856
+ // Anchor is deeper — walk it up
857
+ anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
858
+ if (!anchor_root) return null;
859
+ anchor_root_path = anchor_root.dataset.path.split('.');
860
+ anchor_walked_up = true;
861
+ } else {
862
+ // Same depth but different node arrays — walk both up
863
+ focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
864
+ if (!focus_root) return null;
865
+ focus_root_path = focus_root.dataset.path.split('.');
866
+ focus_walked_up = true;
867
+ anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
868
+ if (!anchor_root) return null;
869
+ anchor_root_path = anchor_root.dataset.path.split('.');
870
+ anchor_walked_up = true;
871
+ }
820
872
  }
821
873
 
822
874
  let anchor_offset = parseInt(anchor_root_path.at(-1));
@@ -830,19 +882,24 @@ ${fallback_html}`;
830
882
  focus_offset += 1;
831
883
  }
832
884
 
833
- // EDGE CASE: Exclude first node when anchor_node is an afer-node-cursor-trap
834
- // in a non-collapsed forward selection
885
+ // EDGE CASE: Exclude first node when anchor_node is a gap-after
886
+ // in a non-collapsed forward selection.
887
+ // Only apply when anchor wasn't walked up — if it was, the gap is at a
888
+ // deeper nesting level and no longer relevant to the resolved node array.
835
889
  if (
836
- anchor_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
890
+ !anchor_walked_up &&
891
+ anchor_node.parentElement?.dataset.type === 'gap-after' &&
837
892
  !is_backwards &&
838
893
  anchor_offset !== focus_offset
839
894
  ) {
840
895
  anchor_offset += 1;
841
896
  }
842
- // EDGE CASE: Exclude first node when focus_node is an afer-node-cursor-trap
843
- // in a non-collapsed backward selection
897
+ // EDGE CASE: Exclude first node when focus_node is a gap-after
898
+ // in a non-collapsed backward selection.
899
+ // Only apply when focus wasn't walked up — same reasoning as above.
844
900
  else if (
845
- focus_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
901
+ !focus_walked_up &&
902
+ focus_node.parentElement?.dataset.type === 'gap-after' &&
846
903
  is_backwards &&
847
904
  anchor_offset !== focus_offset &&
848
905
  // EDGE CASE: Only do correction when drag started from a deeper or equally deep anchor node
@@ -928,7 +985,7 @@ ${fallback_html}`;
928
985
  let focus_root, anchor_root;
929
986
 
930
987
  if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
931
- // EDGE CASE 1: Either text node is empty (only a <br> is present), or cursor is after a <br> at the very end of the text node
988
+ // EDGE CASE 1: Either text node is empty (only a <br> is present), or caret is after a <br> at the very end of the text node
932
989
  focus_root = anchor_root = focus_node;
933
990
  } else {
934
991
  focus_root = /** @type {HTMLElement} */ (
@@ -949,10 +1006,10 @@ ${fallback_html}`;
949
1006
 
950
1007
  if (!path) return null;
951
1008
 
952
- // EDGE CASE 1B: Cursor after trailing <br> at end of text
1009
+ // EDGE CASE 1B: Caret after trailing <br> at end of text
953
1010
  //
954
1011
  // AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
955
- // When the user places their cursor after this <br>, focusNode is the container
1012
+ // When the user places their caret after this <br>, focusNode is the container
956
1013
  // element (not a text node), and normal processing would return position 0.
957
1014
  // We detect this and return text_length instead.
958
1015
  const text_content = session.get(path).text;
@@ -971,7 +1028,7 @@ ${fallback_html}`;
971
1028
  last_element_index--;
972
1029
  }
973
1030
 
974
- // Check if cursor is at or after the trailing <br>
1031
+ // Check if caret is at or after the trailing <br>
975
1032
  if (
976
1033
  last_element_index >= 0 &&
977
1034
  child_nodes[last_element_index].nodeName === 'BR' &&
@@ -1075,16 +1132,19 @@ ${fallback_html}`;
1075
1132
  const range = window.document.createRange();
1076
1133
 
1077
1134
  if (is_collapsed) {
1078
- // Cursor position in between two nodes or at the very beginning/end of a node_array
1079
- // IMPORTANT: We need to look for direct children of anchor_node to find the right cursor trap.
1080
- const cursor_trap_el = anchor_node.querySelector(
1081
- selection.anchor_offset === 0
1082
- ? ':scope > .position-zero-cursor-trap'
1083
- : ':scope > .after-node-cursor-trap'
1084
- );
1085
-
1086
- range.setStart(cursor_trap_el, 1);
1087
- range.setEnd(cursor_trap_el, 1);
1135
+ // Caret position in between two nodes or at the very beginning/end of a node_array
1136
+ // IMPORTANT: We need to look for direct children of anchor_node to find the right node gap.
1137
+ const gap_selector = selection.anchor_offset === 0
1138
+ ? '.gap-before'
1139
+ : '.gap-after';
1140
+ // In empty arrays the node gap is a sibling of the placeholder, not a child
1141
+ const gap_el =
1142
+ anchor_node.querySelector(`:scope > ${gap_selector}`) ??
1143
+ anchor_node.parentElement?.querySelector(`:scope > ${gap_selector}`);
1144
+
1145
+ if (!gap_el) return;
1146
+ range.setStart(gap_el, 1);
1147
+ range.setEnd(gap_el, 1);
1088
1148
  dom_selection.removeAllRanges();
1089
1149
  dom_selection.addRange(range);
1090
1150
  } else {
@@ -1142,12 +1202,12 @@ ${fallback_html}`;
1142
1202
  const el = canvas_el.querySelector(
1143
1203
  `[data-path="${selection.path.join('.')}"][data-type="property"]`
1144
1204
  );
1145
- const cursor_trap_selectable = el.querySelector('.svedit-selectable');
1205
+ const gap_selectable = el.querySelector('.svedit-selectable');
1146
1206
  const range = window.document.createRange();
1147
1207
  const dom_selection = window.getSelection();
1148
1208
 
1149
- // Select the entire cursor trap element contents and collapse to start
1150
- range.selectNodeContents(cursor_trap_selectable);
1209
+ // Select the entire gap element contents and collapse to start
1210
+ range.selectNodeContents(gap_selectable);
1151
1211
  range.collapse(true); // Collapse to start position
1152
1212
  dom_selection.removeAllRanges();
1153
1213
  dom_selection.addRange(range);
@@ -1230,7 +1290,7 @@ ${fallback_html}`;
1230
1290
  // EDGE CASE: When text is empty, we need to set a different DOM selection
1231
1291
  if (start_offset === end_offset && start_offset === 0 && empty_text) {
1232
1292
  // Markup for empty text looks like this `<div data-type="text"><br></div>`.
1233
- // And the correct cursor position is after the <br> element.
1293
+ // And the correct caret position is after the <br> element.
1234
1294
  anchor_node = el;
1235
1295
  anchor_node_offset = 1;
1236
1296
  focus_node = el;
@@ -1290,7 +1350,10 @@ ${fallback_html}`;
1290
1350
  }
1291
1351
 
1292
1352
  // Whenever the model selection changes, render the selection
1353
+ // Skip when canvas is not focused to avoid stealing focus back
1354
+ // (e.g., when a dialog is open and selection highlight fragments re-render)
1293
1355
  $effect(() => {
1356
+ if (!canvas_focused) return;
1294
1357
  render_selection();
1295
1358
  });
1296
1359
  </script>
@@ -1303,10 +1366,13 @@ ${fallback_html}`;
1303
1366
 
1304
1367
  <!-- TODO: move oncut/copy/paste handlers inside .svedit -->
1305
1368
  <div class="svedit">
1369
+ <!-- Overlays must be before canvas so they initialize first. -->
1370
+ <NodeSelectionMarkers />
1371
+ {#if Overlays}<Overlays />{/if}
1306
1372
  <div
1307
1373
  class="svedit-canvas {css_class}"
1308
1374
  class:hide-selection={session.selection?.type === 'node'}
1309
- class:node-cursor={session.selection?.type === 'node' &&
1375
+ class:node-caret={session.selection?.type === 'node' &&
1310
1376
  session.selection.anchor_offset === session.selection.focus_offset}
1311
1377
  class:property-selection={session.selection?.type === 'property'}
1312
1378
  bind:this={canvas_el}
@@ -1331,21 +1397,23 @@ ${fallback_html}`;
1331
1397
  >
1332
1398
  <RootComponent {path} />
1333
1399
  </div>
1334
- <Overlays />
1335
1400
  </div>
1336
1401
 
1337
1402
  <style>
1338
1403
  .svedit-canvas {
1339
- --layout-orientation: vertical;
1340
- caret-color: var(--editing-stroke-color);
1404
+ caret-color: var(--svedit-editing-stroke);
1341
1405
  caret-shape: bar;
1406
+ /* Default to vertical/ column flow with: --row: 0; (the most common case)
1407
+ Prevents silent failures when developers forget to set the row property in their top level node component.
1408
+ TODO: Warn developers in dev mode via console if they forget to set the --row property and use a different flow.*/
1409
+ --row: 0;
1342
1410
  &:focus {
1343
1411
  outline: none;
1344
1412
  }
1345
1413
  }
1346
1414
 
1347
1415
  .svedit-canvas :global(::selection) {
1348
- background: var(--editing-fill-color);
1416
+ background: var(--svedit-editing-fill);
1349
1417
  }
1350
1418
 
1351
1419
  @media not (pointer: coarse) {
@@ -1354,8 +1422,8 @@ ${fallback_html}`;
1354
1422
  }
1355
1423
  }
1356
1424
 
1357
- /* When the cursor is in a cursor-trap we never want to see the caret */
1358
- .svedit-canvas.node-cursor,
1425
+ /* When the caret is in a node gap we never want to see the caret */
1426
+ .svedit-canvas.node-caret,
1359
1427
  .svedit-canvas.property-selection {
1360
1428
  caret-color: transparent;
1361
1429
  }
@@ -18,7 +18,8 @@ import {
18
18
  apply_op,
19
19
  count_references_excluding_deleted,
20
20
  validate_node,
21
- get_active_annotation
21
+ get_active_annotation,
22
+ validate_selection
22
23
  } from './doc_utils.js';
23
24
 
24
25
  /**
@@ -145,7 +146,7 @@ export default class Transaction {
145
146
  }
146
147
 
147
148
  /**
148
- * Returns the annotation object that is currently "under the cursor".
149
+ * Returns the annotation object that is currently "under the caret".
149
150
  * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
150
151
  *
151
152
  * @param {string} [annotation_type] Optional annotation type to filter by
@@ -336,6 +337,7 @@ export default class Transaction {
336
337
  const previous_value = this.get(id);
337
338
  if (!previous_value) {
338
339
  console.warn(`Deletion of node ${id} skipped, as it does not exist.`);
340
+ return this;
339
341
  }
340
342
  // Get nodes referenced by this node BEFORE deleting it.
341
343
  const referenced_nodes = this.get_referenced_nodes(id);
@@ -343,9 +345,7 @@ export default class Transaction {
343
345
  this.ops.push(op);
344
346
  this.inverse_ops.push(['create', previous_value]);
345
347
  this._apply_op(op);
346
- // Check if the nodes that were referenced by the deleted node are now orphaned
347
- // NOTE: We don't do this yet, because we still have some manual child node removal code
348
- // that causes errors. But we should soon implement this only here.
348
+ // Cascade delete any nodes that are now orphaned after removing this node
349
349
  this._cascade_delete_unreferenced_nodes(referenced_nodes);
350
350
  return this;
351
351
  }
@@ -371,50 +371,7 @@ export default class Transaction {
371
371
  * @private
372
372
  */
373
373
  _validate_selection(selection) {
374
- if (!selection) return;
375
-
376
- const selection_type = selection.type;
377
- if (!['node', 'text', 'property'].includes(selection_type)) {
378
- throw new Error(`Invalid selection type: ${selection_type}`);
379
- }
380
-
381
- if (selection_type === 'node') {
382
- const node_array = this.get(selection.path);
383
-
384
- if (!Array.isArray(node_array)) {
385
- throw new Error('Node selection path must point to a node_array');
386
- }
387
-
388
- const max_offset = node_array.length;
389
- if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
390
- throw new Error(
391
- `Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`
392
- );
393
- }
394
- if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
395
- throw new Error(
396
- `Node selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${max_offset}.`
397
- );
398
- }
399
- } else if (selection_type === 'text') {
400
- const annotated_text = this.get(selection.path);
401
-
402
- if (!annotated_text || typeof annotated_text.text !== 'string') {
403
- throw new Error('Text selection path must point to annotated_text');
404
- }
405
-
406
- const char_length = get_char_length(annotated_text.text);
407
- if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
408
- throw new Error(
409
- `Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`
410
- );
411
- }
412
- if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
413
- throw new Error(
414
- `Text selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${char_length}.`
415
- );
416
- }
417
- }
374
+ validate_selection(selection, this);
418
375
  }
419
376
 
420
377
  /**
@@ -653,7 +610,7 @@ export default class Transaction {
653
610
  insert_nodes(node_ids) {
654
611
  if (this.selection.type !== 'node') return this;
655
612
 
656
- // Unless cursor is collapsed, delete the selected nodes as a first step
613
+ // Unless caret is collapsed, delete the selected nodes as a first step
657
614
  if (this.selection.anchor_offset !== this.selection.focus_offset) {
658
615
  this.delete_selection();
659
616
  }
@@ -661,14 +618,7 @@ export default class Transaction {
661
618
  const path = this.selection.path;
662
619
  const node_array = [...this.get(path)];
663
620
 
664
- // Get the start and end indices for the selection
665
621
  let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
666
- let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
667
-
668
- if (start !== end) {
669
- // Remove the selected nodes from the node_array
670
- node_array.splice(start, end - start);
671
- }
672
622
 
673
623
  // Insert the new nodes
674
624
  node_array.splice(start, 0, ...node_ids);
@@ -727,8 +677,8 @@ export default class Transaction {
727
677
  return annotation;
728
678
  }
729
679
 
730
- // Annotation starts at the insertion point - extend it
731
- if (annotation_start <= range.start_offset && annotation_end >= range.start_offset) {
680
+ // Insertion point is inside the annotation (not at the start) - extend it
681
+ if (annotation_start < range.start_offset && annotation_end >= range.start_offset) {
732
682
  return {
733
683
  start_offset: annotation_start,
734
684
  end_offset: annotation_end + delta,
@@ -744,16 +694,6 @@ export default class Transaction {
744
694
  node_id
745
695
  };
746
696
  }
747
-
748
- // Insertion point is inside the annotation - extend the annotation
749
- if (annotation_start < range.start_offset && annotation_end > range.start_offset) {
750
- return {
751
- start_offset: annotation_start,
752
- end_offset: annotation_end + delta,
753
- node_id
754
- };
755
- }
756
-
757
697
  return annotation;
758
698
  });
759
699
 
@@ -771,7 +711,7 @@ export default class Transaction {
771
711
  this.selection = new_selection;
772
712
 
773
713
  // Now we apply annotations if there are any, but only if there's no active annotation
774
- // at the current collapsed cursor
714
+ // at the current collapsed caret
775
715
  if (!this.active_annotation() && annotations.length > 0) {
776
716
  const new_annotations = annotations
777
717
  .map((annotation) => {