svedit 0.6.4 → 0.7.1

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 (66) 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 +161 -95
  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 +8 -0
  41. package/dist/transforms.svelte.d.ts.map +1 -1
  42. package/dist/transforms.svelte.js +20 -10
  43. package/dist/types.d.ts +56 -68
  44. package/dist/utils.d.ts +0 -3
  45. package/dist/utils.d.ts.map +1 -1
  46. package/dist/utils.js +10 -10
  47. package/package.json +21 -21
  48. package/src/lib/AnnotatedTextProperty.svelte +14 -17
  49. package/src/lib/Command.svelte.js +5 -5
  50. package/src/lib/CustomProperty.svelte +5 -4
  51. package/src/lib/Node.svelte +29 -14
  52. package/src/lib/NodeArrayProperty.svelte +29 -9
  53. package/src/lib/NodeCaret.svelte +71 -0
  54. package/src/lib/NodeGap.svelte +249 -0
  55. package/src/lib/NodeGapMarkers.svelte +456 -0
  56. package/src/lib/NodeSelectionMarkers.svelte +49 -0
  57. package/src/lib/Session.svelte.js +13 -69
  58. package/src/lib/Svedit.svelte +161 -95
  59. package/src/lib/Transaction.svelte.js +8 -68
  60. package/src/lib/doc_utils.js +61 -2
  61. package/src/lib/index.js +4 -0
  62. package/src/lib/node_gap_computation.svelte.js +621 -0
  63. package/src/lib/styles/svedit-colors.css +8 -0
  64. package/src/lib/transforms.svelte.js +20 -10
  65. package/src/lib/types.d.ts +56 -68
  66. 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
  }
@@ -572,15 +573,15 @@ ${fallback_html}`;
572
573
 
573
574
  let plain_text,
574
575
  pasted_json,
575
- pasted_images = [];
576
+ pasted_media = [];
576
577
 
577
578
  // NOTE: For some reason, await navigator.clipboard.read()
578
579
  const clipboard_items = event.clipboardData?.items || [];
579
580
  for (const item of clipboard_items || []) {
580
- if (item.type.startsWith('image/')) {
581
+ if (item.type.startsWith('image/') || item.type.startsWith('video/') || item.type.startsWith('audio/')) {
581
582
  const blob = item.getAsFile();
582
583
  const data_url = URL.createObjectURL(blob);
583
- pasted_images.push({
584
+ pasted_media.push({
584
585
  blob,
585
586
  data_url,
586
587
  type: item.type,
@@ -589,11 +590,12 @@ ${fallback_html}`;
589
590
  }
590
591
  }
591
592
 
592
- if (pasted_images.length > 0) {
593
- pasted_json = await session.config.handle_image_paste(session, pasted_images);
594
- console.log('pasted_json_after_image_paste', pasted_json);
593
+ if (pasted_media.length > 0) {
594
+ const handle_media_paste = session.config.handle_media_paste || session.config.handle_image_paste
595
+ pasted_json = await handle_media_paste(session, pasted_media);
596
+ console.log('pasted_json_after_media_paste', pasted_json);
595
597
  // NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
596
- // handled inside handle_image_paste already.
598
+ // handled inside handle_media_paste already.
597
599
  if (!pasted_json) return;
598
600
  } else {
599
601
  // First try to extract svedit data from HTML format
@@ -640,8 +642,8 @@ ${fallback_html}`;
640
642
  }
641
643
  }
642
644
 
643
- console.log('plain_text', plain_text);
644
- console.log('pasted_json', pasted_json);
645
+ // console.log('plain_text', plain_text);
646
+ // console.log('pasted_json', pasted_json);
645
647
 
646
648
  if (pasted_json?.main_nodes && session.selection?.type === 'node') {
647
649
  // Paste nodes at a node selection
@@ -674,15 +676,15 @@ ${fallback_html}`;
674
676
  pasted_json?.main_nodes?.length === 1 &&
675
677
  session.kind(pasted_json?.nodes[pasted_json.main_nodes[0]]) === 'text'
676
678
  ) {
677
- // Paste a single text node, at a text cursor
679
+ // Paste a single text node, at a text caret
678
680
  const text_property = pasted_json.nodes[pasted_json.main_nodes[0]].content;
679
681
  session.apply(
680
682
  session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
681
683
  );
682
684
  } 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);
685
+ // Paste nodes at a text or property selection by finding the next valid insert caret
686
+ const next_node_insert_caret = session.get_next_node_insert_caret(session.selection);
687
+ try_node_paste(pasted_json, next_node_insert_caret);
686
688
  } else if (typeof plain_text === 'string') {
687
689
  // External paste: Fallback to plain text when no svedit data is found
688
690
  session.apply(session.tr.insert_text(plain_text.trim()));
@@ -693,19 +695,19 @@ ${fallback_html}`;
693
695
 
694
696
  function render_selection() {
695
697
  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
698
 
701
699
  if (!selection) {
702
- // No model selection -> just leave things as they are'
700
+ // No model selection -> just leave things as they are
703
701
  let dom_selection = window.getSelection();
704
702
  dom_selection.removeAllRanges();
705
703
  return;
706
704
  }
707
705
 
708
706
  // NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
707
+ let prev_selection =
708
+ __get_property_selection_from_dom() ||
709
+ __get_text_selection_from_dom() ||
710
+ __get_node_selection_from_dom();
709
711
  if (
710
712
  JSON.stringify(selection) === JSON.stringify(prev_selection) &&
711
713
  canvas_el?.contains(document.activeElement)
@@ -725,9 +727,41 @@ ${fallback_html}`;
725
727
  }
726
728
  }
727
729
 
730
+ // Handle focus - push session's keymap onto stack
731
+ function handle_canvas_focus() {
732
+ // Use flushSync so highlight spans are removed from the DOM
733
+ // immediately, before the browser processes the click's selection.
734
+ flushSync(() => {
735
+ // Clear the model selection so render_selection() does not call
736
+ // setBaseAndExtent() with the old position, which would battle
737
+ // with the new selection the user is making (the click/drag that
738
+ // triggered this focus). The browser will place the caret and
739
+ // fire selectionchange, which sets the model selection correctly.
740
+ session.selection = null;
741
+ canvas_focused = true;
742
+ });
743
+ key_mapper?.push_scope(session.keymap);
744
+ }
745
+
746
+ // Handle blur - pop document's keymap from stack
747
+ function handle_canvas_blur() {
748
+ // Use flushSync so the selection highlight span (with its CSS anchor)
749
+ // is in the DOM immediately, before any popover/dialog tries to
750
+ // position itself.
751
+ flushSync(() => {
752
+ canvas_focused = false;
753
+ });
754
+ key_mapper?.pop_scope();
755
+ }
756
+
728
757
  function focus_canvas() {
729
- // We just render the selection (which will return focus to the canvas) implicitly
730
- render_selection();
758
+ // Use flushSync so highlight spans are removed from the DOM
759
+ // immediately, before we focus and render_selection walks the
760
+ // text nodes.
761
+ flushSync(() => {
762
+ canvas_focused = true;
763
+ });
764
+ canvas_el?.focus();
731
765
  }
732
766
 
733
767
  /**
@@ -749,14 +783,14 @@ ${fallback_html}`;
749
783
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
750
784
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
751
785
 
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"]'));
786
+ // EDGE CASE: Let's first check if we are in a node gap for node carets
787
+ let gap_after_el =
788
+ focus_node && focus_node?.closest('[data-type="gap-after"]');
789
+ if (gap_after_el && focus_node === anchor_node) {
790
+ // Find the node that this gap belongs to
791
+ let node = /** @type {HTMLElement} */ (gap_after_el.closest('[data-type="node"]'));
758
792
  if (!node) {
759
- console.log('No corresponding node found for after-node-cursor-trap');
793
+ console.log('No corresponding node found for gap-after');
760
794
  return null;
761
795
  }
762
796
  const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
@@ -770,11 +804,11 @@ ${fallback_html}`;
770
804
  };
771
805
  }
772
806
 
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) {
807
+ // EDGE CASE: Let's check if we are in a gap-before for node_arrays.
808
+ let gap_before_el = focus_node.closest('[data-type="gap-before"]');
809
+ if (gap_before_el && focus_node === anchor_node) {
776
810
  const node_array_el = /** @type {HTMLElement} */ (
777
- position_zero_cursor_trap.closest('[data-type="node_array"]')
811
+ gap_before_el.closest('[data-type="node_array"]')
778
812
  );
779
813
  const node_array_path = node_array_el.dataset.path.split('.');
780
814
  return {
@@ -804,22 +838,38 @@ ${fallback_html}`;
804
838
  let focus_node_depth = focus_root_path.length;
805
839
  let anchor_node_depth = anchor_root_path.length;
806
840
 
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;
841
+ // Walk both endpoints up the DOM until they share the same parent node_array.
842
+ // This handles selections that span across arbitrarily nested node arrays by
843
+ // finding the lowest common ancestor node_array and projecting each endpoint
844
+ // onto its index within that array.
845
+ let focus_walked_up = false;
846
+ let anchor_walked_up = false;
847
+ while (
848
+ focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
849
+ ) {
850
+ if (focus_root_path.length > anchor_root_path.length) {
851
+ // Focus is deeper — walk it up
852
+ focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
853
+ if (!focus_root) return null;
854
+ focus_root_path = focus_root.dataset.path.split('.');
855
+ focus_walked_up = true;
856
+ } else if (anchor_root_path.length > focus_root_path.length) {
857
+ // Anchor is deeper — walk it up
858
+ anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
859
+ if (!anchor_root) return null;
860
+ anchor_root_path = anchor_root.dataset.path.split('.');
861
+ anchor_walked_up = true;
862
+ } else {
863
+ // Same depth but different node arrays — walk both up
864
+ focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
865
+ if (!focus_root) return null;
866
+ focus_root_path = focus_root.dataset.path.split('.');
867
+ focus_walked_up = true;
868
+ anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
869
+ if (!anchor_root) return null;
870
+ anchor_root_path = anchor_root.dataset.path.split('.');
871
+ anchor_walked_up = true;
872
+ }
823
873
  }
824
874
 
825
875
  let anchor_offset = parseInt(anchor_root_path.at(-1));
@@ -833,19 +883,24 @@ ${fallback_html}`;
833
883
  focus_offset += 1;
834
884
  }
835
885
 
836
- // EDGE CASE: Exclude first node when anchor_node is an afer-node-cursor-trap
837
- // in a non-collapsed forward selection
886
+ // EDGE CASE: Exclude first node when anchor_node is a gap-after
887
+ // in a non-collapsed forward selection.
888
+ // Only apply when anchor wasn't walked up — if it was, the gap is at a
889
+ // deeper nesting level and no longer relevant to the resolved node array.
838
890
  if (
839
- anchor_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
891
+ !anchor_walked_up &&
892
+ anchor_node.parentElement?.dataset.type === 'gap-after' &&
840
893
  !is_backwards &&
841
894
  anchor_offset !== focus_offset
842
895
  ) {
843
896
  anchor_offset += 1;
844
897
  }
845
- // EDGE CASE: Exclude first node when focus_node is an afer-node-cursor-trap
846
- // in a non-collapsed backward selection
898
+ // EDGE CASE: Exclude first node when focus_node is a gap-after
899
+ // in a non-collapsed backward selection.
900
+ // Only apply when focus wasn't walked up — same reasoning as above.
847
901
  else if (
848
- focus_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
902
+ !focus_walked_up &&
903
+ focus_node.parentElement?.dataset.type === 'gap-after' &&
849
904
  is_backwards &&
850
905
  anchor_offset !== focus_offset &&
851
906
  // EDGE CASE: Only do correction when drag started from a deeper or equally deep anchor node
@@ -931,7 +986,7 @@ ${fallback_html}`;
931
986
  let focus_root, anchor_root;
932
987
 
933
988
  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
989
+ // 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
990
  focus_root = anchor_root = focus_node;
936
991
  } else {
937
992
  focus_root = /** @type {HTMLElement} */ (
@@ -952,10 +1007,10 @@ ${fallback_html}`;
952
1007
 
953
1008
  if (!path) return null;
954
1009
 
955
- // EDGE CASE 1B: Cursor after trailing <br> at end of text
1010
+ // EDGE CASE 1B: Caret after trailing <br> at end of text
956
1011
  //
957
1012
  // 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
1013
+ // When the user places their caret after this <br>, focusNode is the container
959
1014
  // element (not a text node), and normal processing would return position 0.
960
1015
  // We detect this and return text_length instead.
961
1016
  const text_content = session.get(path).text;
@@ -974,7 +1029,7 @@ ${fallback_html}`;
974
1029
  last_element_index--;
975
1030
  }
976
1031
 
977
- // Check if cursor is at or after the trailing <br>
1032
+ // Check if caret is at or after the trailing <br>
978
1033
  if (
979
1034
  last_element_index >= 0 &&
980
1035
  child_nodes[last_element_index].nodeName === 'BR' &&
@@ -1078,16 +1133,19 @@ ${fallback_html}`;
1078
1133
  const range = window.document.createRange();
1079
1134
 
1080
1135
  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);
1136
+ // Caret position in between two nodes or at the very beginning/end of a node_array
1137
+ // IMPORTANT: We need to look for direct children of anchor_node to find the right node gap.
1138
+ const gap_selector = selection.anchor_offset === 0
1139
+ ? '.gap-before'
1140
+ : '.gap-after';
1141
+ // In empty arrays the node gap is a sibling of the placeholder, not a child
1142
+ const gap_el =
1143
+ anchor_node.querySelector(`:scope > ${gap_selector}`) ??
1144
+ anchor_node.parentElement?.querySelector(`:scope > ${gap_selector}`);
1145
+
1146
+ if (!gap_el) return;
1147
+ range.setStart(gap_el, 1);
1148
+ range.setEnd(gap_el, 1);
1091
1149
  dom_selection.removeAllRanges();
1092
1150
  dom_selection.addRange(range);
1093
1151
  } else {
@@ -1145,12 +1203,12 @@ ${fallback_html}`;
1145
1203
  const el = canvas_el.querySelector(
1146
1204
  `[data-path="${selection.path.join('.')}"][data-type="property"]`
1147
1205
  );
1148
- const cursor_trap_selectable = el.querySelector('.svedit-selectable');
1206
+ const gap_selectable = el.querySelector('.svedit-selectable');
1149
1207
  const range = window.document.createRange();
1150
1208
  const dom_selection = window.getSelection();
1151
1209
 
1152
- // Select the entire cursor trap element contents and collapse to start
1153
- range.selectNodeContents(cursor_trap_selectable);
1210
+ // Select the entire gap element contents and collapse to start
1211
+ range.selectNodeContents(gap_selectable);
1154
1212
  range.collapse(true); // Collapse to start position
1155
1213
  dom_selection.removeAllRanges();
1156
1214
  dom_selection.addRange(range);
@@ -1233,7 +1291,7 @@ ${fallback_html}`;
1233
1291
  // EDGE CASE: When text is empty, we need to set a different DOM selection
1234
1292
  if (start_offset === end_offset && start_offset === 0 && empty_text) {
1235
1293
  // Markup for empty text looks like this `<div data-type="text"><br></div>`.
1236
- // And the correct cursor position is after the <br> element.
1294
+ // And the correct caret position is after the <br> element.
1237
1295
  anchor_node = el;
1238
1296
  anchor_node_offset = 1;
1239
1297
  focus_node = el;
@@ -1293,7 +1351,10 @@ ${fallback_html}`;
1293
1351
  }
1294
1352
 
1295
1353
  // Whenever the model selection changes, render the selection
1354
+ // Skip when canvas is not focused to avoid stealing focus back
1355
+ // (e.g., when a dialog is open and selection highlight fragments re-render)
1296
1356
  $effect(() => {
1357
+ if (!canvas_focused) return;
1297
1358
  render_selection();
1298
1359
  });
1299
1360
  </script>
@@ -1306,10 +1367,13 @@ ${fallback_html}`;
1306
1367
 
1307
1368
  <!-- TODO: move oncut/copy/paste handlers inside .svedit -->
1308
1369
  <div class="svedit">
1370
+ <!-- Overlays must be before canvas so they initialize first. -->
1371
+ <NodeSelectionMarkers />
1372
+ {#if Overlays}<Overlays />{/if}
1309
1373
  <div
1310
1374
  class="svedit-canvas {css_class}"
1311
1375
  class:hide-selection={session.selection?.type === 'node'}
1312
- class:node-cursor={session.selection?.type === 'node' &&
1376
+ class:node-caret={session.selection?.type === 'node' &&
1313
1377
  session.selection.anchor_offset === session.selection.focus_offset}
1314
1378
  class:property-selection={session.selection?.type === 'property'}
1315
1379
  bind:this={canvas_el}
@@ -1334,21 +1398,23 @@ ${fallback_html}`;
1334
1398
  >
1335
1399
  <RootComponent {path} />
1336
1400
  </div>
1337
- <Overlays />
1338
1401
  </div>
1339
1402
 
1340
1403
  <style>
1341
1404
  .svedit-canvas {
1342
- --layout-orientation: vertical;
1343
- caret-color: var(--editing-stroke-color);
1405
+ caret-color: var(--svedit-editing-stroke);
1344
1406
  caret-shape: bar;
1407
+ /* Default to vertical/ column flow with: --row: 0; (the most common case)
1408
+ Prevents silent failures when developers forget to set the row property in their top level node component.
1409
+ TODO: Warn developers in dev mode via console if they forget to set the --row property and use a different flow.*/
1410
+ --row: 0;
1345
1411
  &:focus {
1346
1412
  outline: none;
1347
1413
  }
1348
1414
  }
1349
1415
 
1350
1416
  .svedit-canvas :global(::selection) {
1351
- background: var(--editing-fill-color);
1417
+ background: var(--svedit-editing-fill);
1352
1418
  }
1353
1419
 
1354
1420
  @media not (pointer: coarse) {
@@ -1357,8 +1423,8 @@ ${fallback_html}`;
1357
1423
  }
1358
1424
  }
1359
1425
 
1360
- /* When the cursor is in a cursor-trap we never want to see the caret */
1361
- .svedit-canvas.node-cursor,
1426
+ /* When the caret is in a node gap we never want to see the caret */
1427
+ .svedit-canvas.node-caret,
1362
1428
  .svedit-canvas.property-selection {
1363
1429
  caret-color: transparent;
1364
1430
  }
@@ -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) => {