svedit 0.6.4 → 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 +14 -17
  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 +153 -88
  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 +8 -68
  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 +14 -17
  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 +153 -88
  58. package/src/lib/Transaction.svelte.js +8 -68
  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
 
@@ -502,7 +503,7 @@ ${fallback_html}`;
502
503
  * Attempts to paste JSON data as a node at the current selection.
503
504
  *
504
505
  * @param {string|object} pasted_json - The JSON data to paste, either as a string or parsed object
505
- * @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
506
507
  * @returns {boolean} True if the paste operation was successful, false otherwise
507
508
  */
508
509
  function try_node_paste(pasted_json, selection) {
@@ -555,9 +556,9 @@ ${fallback_html}`;
555
556
  return true;
556
557
  } else {
557
558
  if (tr.selection.path.length >= 2) {
558
- const next_node_insert_cursor = session.get_next_node_insert_cursor(tr.selection);
559
- if (next_node_insert_cursor) {
560
- 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);
561
562
  }
562
563
  }
563
564
  }
@@ -640,8 +641,8 @@ ${fallback_html}`;
640
641
  }
641
642
  }
642
643
 
643
- console.log('plain_text', plain_text);
644
- console.log('pasted_json', pasted_json);
644
+ // console.log('plain_text', plain_text);
645
+ // console.log('pasted_json', pasted_json);
645
646
 
646
647
  if (pasted_json?.main_nodes && session.selection?.type === 'node') {
647
648
  // Paste nodes at a node selection
@@ -674,15 +675,15 @@ ${fallback_html}`;
674
675
  pasted_json?.main_nodes?.length === 1 &&
675
676
  session.kind(pasted_json?.nodes[pasted_json.main_nodes[0]]) === 'text'
676
677
  ) {
677
- // Paste a single text node, at a text cursor
678
+ // Paste a single text node, at a text caret
678
679
  const text_property = pasted_json.nodes[pasted_json.main_nodes[0]].content;
679
680
  session.apply(
680
681
  session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
681
682
  );
682
683
  } else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
683
- // Paste nodes at a text or property selection by finding the next valid insert cursor
684
- const next_node_insert_cursor = session.get_next_node_insert_cursor(session.selection);
685
- 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);
686
687
  } else if (typeof plain_text === 'string') {
687
688
  // External paste: Fallback to plain text when no svedit data is found
688
689
  session.apply(session.tr.insert_text(plain_text.trim()));
@@ -693,19 +694,19 @@ ${fallback_html}`;
693
694
 
694
695
  function render_selection() {
695
696
  const selection = /** @type {Selection} */ (session.selection);
696
- let prev_selection =
697
- __get_property_selection_from_dom() ||
698
- __get_text_selection_from_dom() ||
699
- __get_node_selection_from_dom();
700
697
 
701
698
  if (!selection) {
702
- // No model selection -> just leave things as they are'
699
+ // No model selection -> just leave things as they are
703
700
  let dom_selection = window.getSelection();
704
701
  dom_selection.removeAllRanges();
705
702
  return;
706
703
  }
707
704
 
708
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();
709
710
  if (
710
711
  JSON.stringify(selection) === JSON.stringify(prev_selection) &&
711
712
  canvas_el?.contains(document.activeElement)
@@ -725,9 +726,41 @@ ${fallback_html}`;
725
726
  }
726
727
  }
727
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
+
728
756
  function focus_canvas() {
729
- // We just render the selection (which will return focus to the canvas) implicitly
730
- 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();
731
764
  }
732
765
 
733
766
  /**
@@ -749,14 +782,14 @@ ${fallback_html}`;
749
782
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
750
783
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
751
784
 
752
- // EDGE CASE: Let's first check if we are in a cursor trap for node cursors
753
- let after_node_cursor_trap =
754
- focus_node && focus_node?.closest('[data-type="after-node-cursor-trap"]');
755
- if (after_node_cursor_trap && focus_node === anchor_node) {
756
- // Find the node that this cursor trap belongs to
757
- 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"]'));
758
791
  if (!node) {
759
- console.log('No corresponding node found for after-node-cursor-trap');
792
+ console.log('No corresponding node found for gap-after');
760
793
  return null;
761
794
  }
762
795
  const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
@@ -770,11 +803,11 @@ ${fallback_html}`;
770
803
  };
771
804
  }
772
805
 
773
- // EDGE CASE: Let's check if we are in a position-zero-cursor-trap for node_arrays.
774
- let position_zero_cursor_trap = focus_node.closest('[data-type="position-zero-cursor-trap"]');
775
- 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) {
776
809
  const node_array_el = /** @type {HTMLElement} */ (
777
- position_zero_cursor_trap.closest('[data-type="node_array"]')
810
+ gap_before_el.closest('[data-type="node_array"]')
778
811
  );
779
812
  const node_array_path = node_array_el.dataset.path.split('.');
780
813
  return {
@@ -804,22 +837,38 @@ ${fallback_html}`;
804
837
  let focus_node_depth = focus_root_path.length;
805
838
  let anchor_node_depth = anchor_root_path.length;
806
839
 
807
- // HACK: this works only for one level nesting - should be done recursively to work generally
808
- if (focus_root_path.length > anchor_root_path.length) {
809
- focus_root = focus_root.parentElement.closest('[data-path][data-type="node"]');
810
- if (!focus_root) return null;
811
- focus_root_path = focus_root.dataset.path.split('.');
812
- } else if (anchor_root_path.length > focus_root_path.length) {
813
- anchor_root = anchor_root.parentElement.closest('[data-path][data-type="node"]');
814
- if (!anchor_root) return null;
815
- anchor_root_path = anchor_root.dataset.path.split('.');
816
- }
817
-
818
- const is_same_node_array =
819
- focus_root_path.slice(0, -1).join('.') === anchor_root_path.slice(0, -1).join('.');
820
- if (!is_same_node_array) {
821
- console.log('invalid selection, not same node_array');
822
- 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
+ }
823
872
  }
824
873
 
825
874
  let anchor_offset = parseInt(anchor_root_path.at(-1));
@@ -833,19 +882,24 @@ ${fallback_html}`;
833
882
  focus_offset += 1;
834
883
  }
835
884
 
836
- // EDGE CASE: Exclude first node when anchor_node is an afer-node-cursor-trap
837
- // 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.
838
889
  if (
839
- anchor_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
890
+ !anchor_walked_up &&
891
+ anchor_node.parentElement?.dataset.type === 'gap-after' &&
840
892
  !is_backwards &&
841
893
  anchor_offset !== focus_offset
842
894
  ) {
843
895
  anchor_offset += 1;
844
896
  }
845
- // EDGE CASE: Exclude first node when focus_node is an afer-node-cursor-trap
846
- // 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.
847
900
  else if (
848
- focus_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
901
+ !focus_walked_up &&
902
+ focus_node.parentElement?.dataset.type === 'gap-after' &&
849
903
  is_backwards &&
850
904
  anchor_offset !== focus_offset &&
851
905
  // EDGE CASE: Only do correction when drag started from a deeper or equally deep anchor node
@@ -931,7 +985,7 @@ ${fallback_html}`;
931
985
  let focus_root, anchor_root;
932
986
 
933
987
  if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
934
- // 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
935
989
  focus_root = anchor_root = focus_node;
936
990
  } else {
937
991
  focus_root = /** @type {HTMLElement} */ (
@@ -952,10 +1006,10 @@ ${fallback_html}`;
952
1006
 
953
1007
  if (!path) return null;
954
1008
 
955
- // EDGE CASE 1B: Cursor after trailing <br> at end of text
1009
+ // EDGE CASE 1B: Caret after trailing <br> at end of text
956
1010
  //
957
1011
  // AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
958
- // 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
959
1013
  // element (not a text node), and normal processing would return position 0.
960
1014
  // We detect this and return text_length instead.
961
1015
  const text_content = session.get(path).text;
@@ -974,7 +1028,7 @@ ${fallback_html}`;
974
1028
  last_element_index--;
975
1029
  }
976
1030
 
977
- // Check if cursor is at or after the trailing <br>
1031
+ // Check if caret is at or after the trailing <br>
978
1032
  if (
979
1033
  last_element_index >= 0 &&
980
1034
  child_nodes[last_element_index].nodeName === 'BR' &&
@@ -1078,16 +1132,19 @@ ${fallback_html}`;
1078
1132
  const range = window.document.createRange();
1079
1133
 
1080
1134
  if (is_collapsed) {
1081
- // Cursor position in between two nodes or at the very beginning/end of a node_array
1082
- // IMPORTANT: We need to look for direct children of anchor_node to find the right cursor trap.
1083
- const cursor_trap_el = anchor_node.querySelector(
1084
- selection.anchor_offset === 0
1085
- ? ':scope > .position-zero-cursor-trap'
1086
- : ':scope > .after-node-cursor-trap'
1087
- );
1088
-
1089
- range.setStart(cursor_trap_el, 1);
1090
- 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);
1091
1148
  dom_selection.removeAllRanges();
1092
1149
  dom_selection.addRange(range);
1093
1150
  } else {
@@ -1145,12 +1202,12 @@ ${fallback_html}`;
1145
1202
  const el = canvas_el.querySelector(
1146
1203
  `[data-path="${selection.path.join('.')}"][data-type="property"]`
1147
1204
  );
1148
- const cursor_trap_selectable = el.querySelector('.svedit-selectable');
1205
+ const gap_selectable = el.querySelector('.svedit-selectable');
1149
1206
  const range = window.document.createRange();
1150
1207
  const dom_selection = window.getSelection();
1151
1208
 
1152
- // Select the entire cursor trap element contents and collapse to start
1153
- range.selectNodeContents(cursor_trap_selectable);
1209
+ // Select the entire gap element contents and collapse to start
1210
+ range.selectNodeContents(gap_selectable);
1154
1211
  range.collapse(true); // Collapse to start position
1155
1212
  dom_selection.removeAllRanges();
1156
1213
  dom_selection.addRange(range);
@@ -1233,7 +1290,7 @@ ${fallback_html}`;
1233
1290
  // EDGE CASE: When text is empty, we need to set a different DOM selection
1234
1291
  if (start_offset === end_offset && start_offset === 0 && empty_text) {
1235
1292
  // Markup for empty text looks like this `<div data-type="text"><br></div>`.
1236
- // And the correct cursor position is after the <br> element.
1293
+ // And the correct caret position is after the <br> element.
1237
1294
  anchor_node = el;
1238
1295
  anchor_node_offset = 1;
1239
1296
  focus_node = el;
@@ -1293,7 +1350,10 @@ ${fallback_html}`;
1293
1350
  }
1294
1351
 
1295
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)
1296
1355
  $effect(() => {
1356
+ if (!canvas_focused) return;
1297
1357
  render_selection();
1298
1358
  });
1299
1359
  </script>
@@ -1306,10 +1366,13 @@ ${fallback_html}`;
1306
1366
 
1307
1367
  <!-- TODO: move oncut/copy/paste handlers inside .svedit -->
1308
1368
  <div class="svedit">
1369
+ <!-- Overlays must be before canvas so they initialize first. -->
1370
+ <NodeSelectionMarkers />
1371
+ {#if Overlays}<Overlays />{/if}
1309
1372
  <div
1310
1373
  class="svedit-canvas {css_class}"
1311
1374
  class:hide-selection={session.selection?.type === 'node'}
1312
- class:node-cursor={session.selection?.type === 'node' &&
1375
+ class:node-caret={session.selection?.type === 'node' &&
1313
1376
  session.selection.anchor_offset === session.selection.focus_offset}
1314
1377
  class:property-selection={session.selection?.type === 'property'}
1315
1378
  bind:this={canvas_el}
@@ -1334,21 +1397,23 @@ ${fallback_html}`;
1334
1397
  >
1335
1398
  <RootComponent {path} />
1336
1399
  </div>
1337
- <Overlays />
1338
1400
  </div>
1339
1401
 
1340
1402
  <style>
1341
1403
  .svedit-canvas {
1342
- --layout-orientation: vertical;
1343
- caret-color: var(--editing-stroke-color);
1404
+ caret-color: var(--svedit-editing-stroke);
1344
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;
1345
1410
  &:focus {
1346
1411
  outline: none;
1347
1412
  }
1348
1413
  }
1349
1414
 
1350
1415
  .svedit-canvas :global(::selection) {
1351
- background: var(--editing-fill-color);
1416
+ background: var(--svedit-editing-fill);
1352
1417
  }
1353
1418
 
1354
1419
  @media not (pointer: coarse) {
@@ -1357,8 +1422,8 @@ ${fallback_html}`;
1357
1422
  }
1358
1423
  }
1359
1424
 
1360
- /* When the cursor is in a cursor-trap we never want to see the caret */
1361
- .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,
1362
1427
  .svedit-canvas.property-selection {
1363
1428
  caret-color: transparent;
1364
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);
@@ -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) => {