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,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
  }
@@ -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
  }
@@ -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":";;;;;;;AAo3CA;;eAAqD;iCAv1C1C,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"}