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,5 +1,5 @@
1
1
  import Transaction from './Transaction.svelte.js';
2
- import { char_slice, get_char_length, traverse } from './utils.js';
2
+ import { char_slice, traverse } from './utils.js';
3
3
  import {
4
4
  get as doc_get,
5
5
  property_type as doc_property_type,
@@ -9,7 +9,8 @@ import {
9
9
  count_references as doc_count_references,
10
10
  validate_document_schema,
11
11
  validate_node,
12
- get_active_annotation
12
+ get_active_annotation,
13
+ validate_selection
13
14
  } from './doc_utils.js';
14
15
 
15
16
  /**
@@ -33,7 +34,7 @@ const BATCH_WINDOW_MS = 1000; // 1 second
33
34
  */
34
35
 
35
36
  export default class Session {
36
- /** @type {Selection | undefined} */
37
+ /** @type {Selection | null} */
37
38
  #selection = $state.raw(null);
38
39
 
39
40
  /** @type {DocumentSchema} */
@@ -83,7 +84,7 @@ export default class Session {
83
84
 
84
85
  /**
85
86
  * Gets the current selection
86
- * @returns {Selection | undefined}
87
+ * @returns {Selection | null}
87
88
  */
88
89
  get selection() {
89
90
  return this.#selection;
@@ -91,13 +92,11 @@ export default class Session {
91
92
 
92
93
  /**
93
94
  * Sets the selection with validation
94
- * @param {Selection | undefined} value - The new selection
95
+ * @param {Selection | null} value - The new selection
95
96
  * @throws {Error} Throws if the selection is invalid
96
97
  */
97
98
  set selection(value) {
98
- // if (value !== undefined) {
99
- // this._validate_selection(value);
100
- // }
99
+ this._validate_selection(value);
101
100
  this.#selection = value;
102
101
  }
103
102
 
@@ -109,62 +108,7 @@ export default class Session {
109
108
  * @private
110
109
  */
111
110
  _validate_selection(selection) {
112
- if (!selection) return; // no selection is a valid selection
113
- if (selection && !Array.isArray(selection.path)) {
114
- throw new Error('Selection must have a valid path');
115
- }
116
-
117
- const selection_type = selection.type;
118
- if (selection_type === 'node') {
119
- // For node selections, path should point to a node_array
120
- if (this.inspect(selection.path).type !== 'node_array') {
121
- throw new Error(
122
- `Node selection path does not point to a node array: ${selection.path.join('.')}`
123
- );
124
- }
125
-
126
- const node_array = this.get(selection.path);
127
-
128
- // Validate anchor_offset and focus_offset are within bounds
129
- const max_offset = node_array.length;
130
-
131
- if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
132
- throw new Error(
133
- `Node selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${max_offset})`
134
- );
135
- }
136
-
137
- if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
138
- throw new Error(
139
- `Node selection focus_offset ${selection.focus_offset} is out of bounds (0-${max_offset})`
140
- );
141
- }
142
- } else if (selection_type === 'text') {
143
- // For text selections, path should point to an annotated_text property
144
- const annotated_text = this.get(selection.path);
145
-
146
- // Validate anchor_offset and focus_offset are within text bounds
147
- const char_length = get_char_length(annotated_text.text);
148
-
149
- if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
150
- throw new Error(
151
- `Text selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${char_length})`
152
- );
153
- }
154
-
155
- if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
156
- throw new Error(
157
- `Text selection focus_offset ${selection.focus_offset} is out of bounds (0-${char_length})`
158
- );
159
- }
160
- } else if (selection_type === 'property') {
161
- // For property selections, just validate the path exists
162
- if (!this.inspect(selection.path)) {
163
- throw new Error(`Property selection path not found: ${selection.path.join('.')}`);
164
- }
165
- } else {
166
- throw new Error(`Unknown selection type: ${selection_type}`);
167
- }
111
+ validate_selection(selection, this);
168
112
  }
169
113
 
170
114
  /**
@@ -410,18 +354,18 @@ export default class Session {
410
354
  }
411
355
 
412
356
  // No insert position found yet, and root not reached, try one level up if possible
413
- let next_node_insert_cursor = this.get_next_node_insert_cursor(selection);
414
- if (!next_node_insert_cursor) return false;
415
- return this.can_insert(node_type, next_node_insert_cursor);
357
+ let next_node_insert_caret = this.get_next_node_insert_caret(selection);
358
+ if (!next_node_insert_caret) return false;
359
+ return this.can_insert(node_type, next_node_insert_caret);
416
360
  }
417
361
 
418
362
  /**
419
363
  * Compute next possible insert position from a given selection
420
364
  *
421
365
  * @param {Selection} [selection] - Reference selection
422
- * @returns {Selection} True if the paste operation was successful, false otherwise
366
+ * @returns {Selection|null} The next node insert caret selection, or null if none is available
423
367
  */
424
- get_next_node_insert_cursor(selection = this.selection) {
368
+ get_next_node_insert_caret(selection = this.selection) {
425
369
  // There's no parent path to insert into
426
370
  if (!selection || selection.path.length <= 2) {
427
371
  return null;
@@ -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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AAkzCA;;eAAqD;iCA3xC1C,cAAc"}
1
+ {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AAm3CA;;eAAqD;iCAt1C1C,cAAc"}
@@ -93,7 +93,7 @@ export default class Transaction {
93
93
  */
94
94
  get available_annotation_types(): string[];
95
95
  /**
96
- * Returns the annotation object that is currently "under the cursor".
96
+ * Returns the annotation object that is currently "under the caret".
97
97
  * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
98
98
  *
99
99
  * @param {string} [annotation_type] Optional annotation type to filter by
@@ -1 +1 @@
1
- {"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;;GAcG;AACH;IACC;;;;;;;OAOG;IACH,oBALW,cAAc,OACd,QAAQ,aACR,SAAS,GAAG,IAAI,UAChB,MAAM,EAgBhB;IAbA,uBAAoB;IACpB,qBAAqB;IACrB,KADW,MAAM,CACH;IACd,wBAAwB;IACxB,WADW,SAAS,CACM;IAC1B,YAAoB;IAEpB,oCAAoC;IACpC,KADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACnB;IACb,oCAAoC;IACpC,aADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACX;IAErB,4BAAiC;IAGlC;;;;;OAKG;IACH,UAHW,YAAY,GAAC,MAAM,GACjB,GAAG,CAIf;IAED;;;;;;OAMG;IACH,oBAJW,MAAM,YACN,MAAM,GACJ,MAAM,CAIlB;IAED;;;;;OAKG;IACH,WAHW,GAAG,GACD,OAAO,GAAC,MAAM,GAAC,YAAY,CAIvC;IAED;;;;;OAKG;IACH,cAHW,YAAY,GACV;QAAC,IAAI,EAAE,UAAU,GAAC,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC,CAIzD;IAED;;;;OAIG;IACH,eAFa,MAAM,CAIlB;IAED;;;;;OAKG;IACH,oBAHW,GAAG,QAKb;IAED;;;;;OAKG;IACH,8BAHW,MAAM,GACJ,MAAM,EAAE,CAKpB;IAED;;;;OAIG;IACH,kCAFa,MAAM,EAAE,CAOpB;IAED;;;;;;OAMG;IACH,oCAHW,MAAM,GACJ,UAAU,GAAC,IAAI,CAI3B;IAED;;;;;OAKG;IACH,kBAEC;IAED;;;;;;;;;;;;;;;OAeG;IACH,UAVW,YAAY,SACZ,GAAG,GACD,WAAW,CAmDvB;IAKD,qCAsDC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAbW,GAAG,GACD,WAAW,CAyBvB;IAED;;;;;;;;;;;;OAYG;IACH,WARW,GAAG,GACD,WAAW,CAuBvB;IAED;;;;;;OAMG;IACH,yBAJW,SAAS,GACP,WAAW,CAOvB;IAED;;;;;;OAMG;IACH,4BA6CC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,+BAbW,GAAG,yBACH,GAAG,GACD,WAAW,CA+DvB;IAED;;;;;;;;;;;OAWG;IACH,6BAHW,UAAU,GAAG,SAAS,GACpB,WAAW,CA0IvB;IAED;;;;;;;;OAQG;IACH,uBAHW,MAAM,EAAE,GACN,WAAW,CAkCvB;IAED;;;;;;;;;;;;OAYG;IACH,2BALW,MAAM,+BAEN,MAAM,GACJ,WAAW,CAqGvB;IAED;;;;;;;;;OASG;IACH,2CAgCC;IAED;;;;;;;;;;OAUG;IACH,4CAOC;CACD;oCA91ByF,cAAc;+BAAd,cAAc;kCAAd,cAAc;4BAAd,cAAc;gCAAd,cAAc;8BAAd,cAAc"}
1
+ {"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;;GAcG;AACH;IACC;;;;;;;OAOG;IACH,oBALW,cAAc,OACd,QAAQ,aACR,SAAS,GAAG,IAAI,UAChB,MAAM,EAgBhB;IAbA,uBAAoB;IACpB,qBAAqB;IACrB,KADW,MAAM,CACH;IACd,wBAAwB;IACxB,WADW,SAAS,CACM;IAC1B,YAAoB;IAEpB,oCAAoC;IACpC,KADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACnB;IACb,oCAAoC;IACpC,aADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACX;IAErB,4BAAiC;IAGlC;;;;;OAKG;IACH,UAHW,YAAY,GAAC,MAAM,GACjB,GAAG,CAIf;IAED;;;;;;OAMG;IACH,oBAJW,MAAM,YACN,MAAM,GACJ,MAAM,CAIlB;IAED;;;;;OAKG;IACH,WAHW,GAAG,GACD,OAAO,GAAC,MAAM,GAAC,YAAY,CAIvC;IAED;;;;;OAKG;IACH,cAHW,YAAY,GACV;QAAC,IAAI,EAAE,UAAU,GAAC,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC,CAIzD;IAED;;;;OAIG;IACH,eAFa,MAAM,CAIlB;IAED;;;;;OAKG;IACH,oBAHW,GAAG,QAKb;IAED;;;;;OAKG;IACH,8BAHW,MAAM,GACJ,MAAM,EAAE,CAKpB;IAED;;;;OAIG;IACH,kCAFa,MAAM,EAAE,CAOpB;IAED;;;;;;OAMG;IACH,oCAHW,MAAM,GACJ,UAAU,GAAC,IAAI,CAI3B;IAED;;;;;OAKG;IACH,kBAEC;IAED;;;;;;;;;;;;;;;OAeG;IACH,UAVW,YAAY,SACZ,GAAG,GACD,WAAW,CAmDvB;IAKD,qCAsDC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAbW,GAAG,GACD,WAAW,CAyBvB;IAED;;;;;;;;;;;;OAYG;IACH,WARW,GAAG,GACD,WAAW,CAsBvB;IAED;;;;;;OAMG;IACH,yBAJW,SAAS,GACP,WAAW,CAOvB;IAED;;;;;;OAMG;IACH,4BAEC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,+BAbW,GAAG,yBACH,GAAG,GACD,WAAW,CA+DvB;IAED;;;;;;;;;;;OAWG;IACH,6BAHW,UAAU,GAAG,SAAS,GACpB,WAAW,CA0IvB;IAED;;;;;;;;OAQG;IACH,uBAHW,MAAM,EAAE,GACN,WAAW,CA2BvB;IAED;;;;;;;;;;;;OAYG;IACH,2BALW,MAAM,+BAEN,MAAM,GACJ,WAAW,CA2FvB;IAED;;;;;;;;;OASG;IACH,2CAgCC;IAED;;;;;;;;;;OAUG;IACH,4CAOC;CACD;oCAlyByF,cAAc;+BAAd,cAAc;kCAAd,cAAc;4BAAd,cAAc;gCAAd,cAAc;8BAAd,cAAc"}