svedit 0.9.0 → 0.10.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 (68) hide show
  1. package/README.md +113 -4
  2. package/dist/AnnotatedTextProperty.svelte +41 -8
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +0 -1
  5. package/dist/Command.svelte.d.ts.map +1 -1
  6. package/dist/Command.svelte.js +18 -3
  7. package/dist/CustomProperty.svelte +20 -9
  8. package/dist/CustomProperty.svelte.d.ts +2 -1
  9. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  10. package/dist/Node.svelte +7 -5
  11. package/dist/Node.svelte.d.ts.map +1 -1
  12. package/dist/NodeArrayProperty.svelte +65 -42
  13. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  14. package/dist/NodeCaret.svelte +0 -7
  15. package/dist/NodeGap.svelte +179 -81
  16. package/dist/NodeGap.svelte.d.ts +0 -2
  17. package/dist/NodeGap.svelte.d.ts.map +1 -1
  18. package/dist/NodeGapMarkers.svelte +251 -84
  19. package/dist/NodeGapMarkers.svelte.d.ts +2 -0
  20. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  21. package/dist/NodeSelectionMarkers.svelte +4 -3
  22. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  23. package/dist/Session.svelte.d.ts +12 -1
  24. package/dist/Session.svelte.d.ts.map +1 -1
  25. package/dist/Session.svelte.js +40 -3
  26. package/dist/Svedit.svelte +336 -165
  27. package/dist/Svedit.svelte.d.ts.map +1 -1
  28. package/dist/Transaction.svelte.d.ts +24 -5
  29. package/dist/Transaction.svelte.d.ts.map +1 -1
  30. package/dist/Transaction.svelte.js +103 -61
  31. package/dist/doc_utils.d.ts +64 -3
  32. package/dist/doc_utils.d.ts.map +1 -1
  33. package/dist/doc_utils.js +276 -10
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +5 -1
  36. package/dist/node_visibility.svelte.d.ts +9 -0
  37. package/dist/node_visibility.svelte.d.ts.map +1 -0
  38. package/dist/node_visibility.svelte.js +718 -0
  39. package/dist/styles/svedit-animations.css +8 -0
  40. package/dist/styles/svedit-colors.css +1 -0
  41. package/dist/types.d.ts +46 -3
  42. package/dist/utils.d.ts +52 -0
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +109 -3
  45. package/package.json +3 -2
  46. package/src/lib/AnnotatedTextProperty.svelte +41 -8
  47. package/src/lib/Command.svelte.js +18 -3
  48. package/src/lib/CustomProperty.svelte +20 -9
  49. package/src/lib/Node.svelte +7 -5
  50. package/src/lib/NodeArrayProperty.svelte +65 -42
  51. package/src/lib/NodeCaret.svelte +0 -7
  52. package/src/lib/NodeGap.svelte +179 -81
  53. package/src/lib/NodeGapMarkers.svelte +251 -84
  54. package/src/lib/NodeSelectionMarkers.svelte +4 -3
  55. package/src/lib/Session.svelte.js +40 -3
  56. package/src/lib/Svedit.svelte +336 -165
  57. package/src/lib/Transaction.svelte.js +103 -61
  58. package/src/lib/doc_utils.js +276 -10
  59. package/src/lib/index.js +5 -1
  60. package/src/lib/node_visibility.svelte.js +718 -0
  61. package/src/lib/styles/svedit-animations.css +8 -0
  62. package/src/lib/styles/svedit-colors.css +1 -0
  63. package/src/lib/types.d.ts +46 -3
  64. package/src/lib/utils.js +109 -3
  65. package/dist/node_gap_computation.svelte.d.ts +0 -5
  66. package/dist/node_gap_computation.svelte.d.ts.map +0 -1
  67. package/dist/node_gap_computation.svelte.js +0 -621
  68. package/src/lib/node_gap_computation.svelte.js +0 -621
@@ -3,16 +3,19 @@
3
3
  import {
4
4
  snake_to_pascal,
5
5
  get_char_length,
6
- utf16_to_char_offset,
7
- char_to_utf16_offset
6
+ char_to_utf16_offset,
7
+ deserialize_path,
8
+ paths_equal,
9
+ serialize_path,
10
+ is_selection_collapsed
8
11
  } from './utils.js';
9
- import { create_gap_computation } from './node_gap_computation.svelte.js';
12
+ import { create_node_visibility } from './node_visibility.svelte.js';
10
13
  import DefaultNodeSelectionMarkers from './NodeSelectionMarkers.svelte';
11
14
  import './styles/svedit-colors.css';
15
+ import './styles/svedit-animations.css';
12
16
 
13
17
  /** @import {
14
18
  * SveditProps,
15
- * DocumentPath,
16
19
  * Selection,
17
20
  * TextSelection,
18
21
  * NodeSelection,
@@ -37,9 +40,14 @@
37
40
  let NodeSelectionMarkers = $derived(session.config.system_components?.NodeSelectionMarkers ?? DefaultNodeSelectionMarkers);
38
41
  let RootComponent = $derived(session.config.node_components[snake_to_pascal(root_node.type)]);
39
42
 
43
+
40
44
  let is_composing = $state(false);
41
45
  let canvas_focused = $state(false);
42
46
  let before_composition_selection = null;
47
+ // Set by onselectionchange before it commits a DOM-derived selection
48
+ // to the model. render_selection consumes-and-clears it to skip
49
+ // rerender on DOM-driven changes (the DOM is already in place).
50
+ let selection_source_is_dom = false;
43
51
 
44
52
 
45
53
  // let is_mobile = $derived(is_mobile_browser());
@@ -71,7 +79,51 @@
71
79
  };
72
80
 
73
81
  setContext('svedit', context);
74
- create_gap_computation(context);
82
+ create_node_visibility(context);
83
+
84
+ $effect(() => {
85
+ check_duplicate_paths();
86
+ });
87
+
88
+ function check_duplicate_paths() {
89
+ if (!canvas_el) return;
90
+
91
+ /** @type {Record<string, true>} */
92
+ const mounted_paths = Object.create(null);
93
+
94
+ for (const element of canvas_el.querySelectorAll('[data-path]')) {
95
+ if (element.closest('.svedit-canvas') !== canvas_el) continue;
96
+
97
+ const path_str = element.getAttribute('data-path');
98
+ if (!path_str) continue;
99
+
100
+ if (mounted_paths[path_str]) {
101
+ console.warn(
102
+ `[svedit] Path "${path_str}" is mounted more than once. Within a single Svedit document, each path may be mounted exactly once. To render shared content in multiple places (e.g. header + footer nav), use distinct node_arrays or separate Svedit instances.`
103
+ );
104
+ }
105
+
106
+ mounted_paths[path_str] = true;
107
+ }
108
+ }
109
+
110
+ // Test-only hook: expose the svedit context so test specs can read
111
+ // near_map / edge_map for diagnostics. Gated on `import.meta.env.MODE`
112
+ // — Vite replaces the literal so the comparison is constant-false in
113
+ // production and the branch is dead-code-eliminated. Wrapped in an
114
+ // effect so unmount clears the reference and tests don't leak state
115
+ // across remounts.
116
+ if (import.meta.env?.MODE === 'test') {
117
+ $effect(() => {
118
+ const g = /** @type {any} */ (globalThis);
119
+ g.__svedit_ctx_for_test = context;
120
+ return () => {
121
+ if (g.__svedit_ctx_for_test === context) {
122
+ delete g.__svedit_ctx_for_test;
123
+ }
124
+ };
125
+ });
126
+ }
75
127
 
76
128
  // Get KeyMapper from context (may be undefined if not provided)
77
129
  const key_mapper = getContext('key_mapper');
@@ -145,14 +197,14 @@
145
197
  if (
146
198
  ['deleteContentBackward', 'deleteWordBackward', 'deleteContent'].includes(event.inputType)
147
199
  ) {
148
- delete_at_selection('backward');
200
+ session.apply(session.tr.delete_selection('backward'));
149
201
  event.preventDefault();
150
202
  event.stopPropagation();
151
203
  return;
152
204
  }
153
205
 
154
206
  if (['deleteContentForward', 'deleteWordForward'].includes(event.inputType)) {
155
- delete_at_selection('forward');
207
+ session.apply(session.tr.delete_selection('forward'));
156
208
  event.preventDefault();
157
209
  event.stopPropagation();
158
210
  return;
@@ -190,10 +242,9 @@
190
242
  /**
191
243
  * Handles composition start events for input methods like dead keys
192
244
  * This occurs when user starts typing a composed character (e.g., backtick for accents)
193
- * @param {CompositionEvent} event
194
245
  */
195
- function oncompositionstart(event) {
196
- console.log('DEBUG: oncompositionstart', event.data);
246
+ function oncompositionstart(/*event*/) {
247
+ // console.log('DEBUG: oncompositionstart', event.data);
197
248
  if (session.selection.type !== 'text') {
198
249
  // Remove all ranges - completely clears the selection
199
250
  window.getSelection()?.removeAllRanges();
@@ -217,7 +268,7 @@
217
268
  * This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
218
269
  */
219
270
  function oncompositionend(event) {
220
- console.log('DEBUG: oncompositionend, insert:', event.data, event);
271
+ // console.log('DEBUG: oncompositionend, insert:', event.data, event);
221
272
  if (!canvas_el?.contains(document.activeElement)) return;
222
273
  if (session.selection?.type === 'text') {
223
274
  // We need to remember the user's selection, as it might have changed in the process
@@ -229,12 +280,16 @@
229
280
  // native undo command. Then the DOM will be in sync again with the editor's internal state.
230
281
  document.execCommand('undo', false, null);
231
282
 
283
+ // Firefox may not undo native composition DOM. If the composed text is
284
+ // still present, remove it manually before applying the Svedit transaction.
285
+ __remove_native_composition_text(before_composition_selection, event.data);
286
+
232
287
  // Set the selection to where the user initiated the composition, make changes, and apply.
233
288
  // NOTE: We need to check for valid selection here, as there is a rare race condition
234
289
  // where the user had no text selection at the start of composition.
235
290
  if (before_composition_selection) {
236
291
  session.selection = before_composition_selection;
237
- console.log('event.data', event.data);
292
+ // console.log('event.data', event.data);
238
293
  const tr = session.tr;
239
294
  tr.insert_text(event.data);
240
295
  session.apply(tr);
@@ -261,6 +316,7 @@
261
316
  function onselectionchange() {
262
317
  if (!editable) return;
263
318
  if (!canvas_focused) return;
319
+ if (is_composing) return;
264
320
  const dom_selection = window.getSelection();
265
321
  if (!dom_selection.rangeCount) return;
266
322
 
@@ -273,6 +329,7 @@
273
329
  // structurally identical — prevents a redundant $effect cycle
274
330
  // (render_selection → scrollIntoView) on every DOM layout change.
275
331
  if (JSON.stringify(selection) === JSON.stringify(session.selection)) return;
332
+ selection_source_is_dom = true;
276
333
  session.selection = selection;
277
334
  }
278
335
  }
@@ -442,25 +499,24 @@ ${fallback_html}`;
442
499
  annotated_text = session.get_selected_annotated_text();
443
500
  const fallback_html = `<span>${annotated_text.text}</span>`;
444
501
 
445
- console.log('Text copy:', {
446
- annotated_text,
447
- plain_text,
448
- html
449
- });
502
+ // console.log('Text copy:', {
503
+ // annotated_text,
504
+ // plain_text,
505
+ // html
506
+ // });
450
507
 
451
508
  html = create_svedit_html_format(annotated_text, fallback_html);
452
509
  } else if (session.selection?.type === 'node') {
453
510
  const selected_nodes = session.get_selected_nodes();
454
511
  const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
455
-
456
512
  const json_data = { nodes, main_nodes };
457
513
 
458
- console.log('Node copy:', {
459
- selected_nodes,
460
- nodes,
461
- total_nodes: Object.keys(nodes).length,
462
- operation: delete_selection ? 'cut' : 'copy'
463
- });
514
+ // console.log('Node copy:', {
515
+ // selected_nodes,
516
+ // nodes,
517
+ // total_nodes: Object.keys(nodes).length,
518
+ // operation: delete_selection ? 'cut' : 'copy'
519
+ // });
464
520
 
465
521
  // Generate fallback HTML for cross-app compatibility
466
522
  const selected_node_objects = main_nodes.map((id) => nodes[id]);
@@ -479,7 +535,6 @@ ${fallback_html}`;
479
535
  type: property_definition.type,
480
536
  value
481
537
  };
482
- console.log('Property copy:', json_data);
483
538
  html = create_svedit_html_format(json_data, `<span>${value}</span>`);
484
539
  plain_text = String(value);
485
540
  }
@@ -488,32 +543,16 @@ ${fallback_html}`;
488
543
  try {
489
544
  event.clipboardData?.setData('text/plain', plain_text || '');
490
545
  event.clipboardData?.setData('text/html', html || '');
491
- console.log('Data copied to clipboard successfully');
492
546
  } catch (err) {
493
547
  console.error('Failed to copy data: ', err);
494
548
  }
495
549
 
496
550
  if (delete_selection) {
497
- delete_at_selection();
551
+ session.apply(session.tr.delete_selection());
498
552
  }
499
553
  }
500
554
 
501
- /**
502
- * Shared delete logic for cut, backspace, and forward-delete.
503
- * For property selections, delegates to the config's handle_property_deletion hook.
504
- * For text/node selections, uses the standard transaction delete_selection.
505
- *
506
- * @param {'backward' | 'forward'} [direction]
507
- */
508
- function delete_at_selection(direction = 'backward') {
509
- if (session.selection?.type === 'property') {
510
- if (session.config.handle_property_deletion) {
511
- session.config.handle_property_deletion(session, session.selection.path);
512
- }
513
- } else {
514
- session.apply(session.tr.delete_selection(direction));
515
- }
516
- }
555
+
517
556
 
518
557
  function oncut(event) {
519
558
  if (!editable) return;
@@ -559,9 +598,9 @@ ${fallback_html}`;
559
598
  });
560
599
  nodes_to_insert.push(new_node_id);
561
600
  } else {
562
- console.log(
563
- `rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
564
- );
601
+ // console.log(
602
+ // `rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
603
+ // );
565
604
  rejected = true;
566
605
  break;
567
606
  }
@@ -614,7 +653,6 @@ ${fallback_html}`;
614
653
  if (pasted_media.length > 0) {
615
654
  const handle_media_paste = session.config.handle_media_paste || session.config.handle_image_paste
616
655
  pasted_json = await handle_media_paste(session, pasted_media);
617
- console.log('pasted_json_after_media_paste', pasted_json);
618
656
  // NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
619
657
  // handled inside handle_media_paste already.
620
658
  if (!pasted_json) return;
@@ -625,8 +663,8 @@ ${fallback_html}`;
625
663
  if (html_content) {
626
664
  pasted_json = extract_svedit_data_from_html(html_content);
627
665
  }
628
- } catch (e) {
629
- console.log('No HTML format available or failed to extract svedit data:', e);
666
+ } catch {
667
+ // No HTML format available or failed to extract svedit data
630
668
  pasted_json = undefined;
631
669
  }
632
670
 
@@ -636,13 +674,18 @@ ${fallback_html}`;
636
674
  console.error('Failed to paste any content:', e);
637
675
  }
638
676
 
639
- // Try to contruct a node payload from plain text when applicable
677
+ // Try to construct a node payload from plain text when applicable
640
678
  if (!pasted_json && typeof plain_text === 'string') {
641
679
  const plain_text_fragments = plain_text
642
680
  .split('\n\n')
643
681
  .map((fragment) => fragment.trim())
644
682
  .filter(Boolean);
645
- if (plain_text_fragments.length > 1) {
683
+ // Also create a node payload for single paragraphs at a node gap —
684
+ // without this, insert_text bails out (requires text selection) and
685
+ // the paste is silently dropped.
686
+ const needs_node_payload = plain_text_fragments.length > 1
687
+ || (plain_text_fragments.length === 1 && session.selection?.type === 'node');
688
+ if (needs_node_payload) {
646
689
  pasted_json = {
647
690
  main_nodes: [],
648
691
  nodes: []
@@ -709,32 +752,36 @@ ${fallback_html}`;
709
752
  } else if (typeof plain_text === 'string') {
710
753
  // External paste: Fallback to plain text when no svedit data is found
711
754
  session.apply(session.tr.insert_text(plain_text.trim()));
712
- } else {
713
- console.log('Could not paste.');
714
755
  }
715
756
  }
716
757
 
717
- function render_selection() {
758
+ function render_selection(dom_driven = false) {
718
759
  const selection = /** @type {Selection} */ (session.selection);
719
760
 
720
761
  if (!selection) {
721
762
  // No model selection -> just leave things as they are
722
- let dom_selection = window.getSelection();
723
- dom_selection.removeAllRanges();
763
+ // NOTE: removeAllRanges() makes the document lose selection on
764
+ // refocus of the window, hence I disable it for now.
765
+ // let dom_selection = window.getSelection();
766
+ // dom_selection.removeAllRanges();
724
767
  return;
725
768
  }
726
769
 
727
- // NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
728
- let prev_selection =
729
- __get_property_selection_from_dom() ||
730
- __get_text_selection_from_dom() ||
731
- __get_node_selection_from_dom();
732
- if (
733
- JSON.stringify(selection) === JSON.stringify(prev_selection) &&
734
- canvas_el?.contains(document.activeElement)
735
- ) {
736
- // Skip. No need to rerender.
737
- return;
770
+ // DOM-driven updates already reflect the new selection — skip
771
+ // the rerender when DOM matches model. Model-driven updates
772
+ // (insert/undo) can have DOM matching by coincidence (Svelte
773
+ // reuses gap elements with shifted data-gap-offset), so for those
774
+ // we always rerender to scroll the cursor into view.
775
+ const is_empty_text_selection =
776
+ selection.type === 'text' && session.get(selection.path).text.length === 0;
777
+ if (dom_driven && !is_empty_text_selection) {
778
+ const prev_selection = __get_selection_from_dom();
779
+ if (
780
+ JSON.stringify(selection) === JSON.stringify(prev_selection) &&
781
+ canvas_el?.contains(document.activeElement)
782
+ ) {
783
+ return;
784
+ }
738
785
  }
739
786
 
740
787
  if (selection?.type === 'text') {
@@ -744,7 +791,7 @@ ${fallback_html}`;
744
791
  } else if (selection?.type === 'property') {
745
792
  __render_property_selection();
746
793
  } else {
747
- console.log('unsupported selection', $state.snapshot(selection));
794
+ console.warn('unsupported selection', $state.snapshot(selection));
748
795
  }
749
796
  }
750
797
 
@@ -753,18 +800,12 @@ ${fallback_html}`;
753
800
  // Use flushSync so highlight spans are removed from the DOM
754
801
  // immediately, before the browser processes the click's selection.
755
802
  flushSync(() => {
756
- // Clear the model selection so render_selection() does not call
757
- // setBaseAndExtent() with the old position, which would battle
758
- // with the new selection the user is making (the click/drag that
759
- // triggered this focus). The browser will place the caret and
760
- // fire selectionchange, which sets the model selection correctly.
761
- session.selection = null;
762
803
  canvas_focused = true;
763
804
  });
764
805
  key_mapper?.push_scope(session.keymap);
765
806
  }
766
807
 
767
- // Handle blur - pop document's keymap from stack
808
+ // Handle blur - pop document's keymap from stack.
768
809
  function handle_canvas_blur() {
769
810
  // Use flushSync so the selection highlight span (with its CSS anchor)
770
811
  // is in the DOM immediately, before any popover/dialog tries to
@@ -794,11 +835,11 @@ ${fallback_html}`;
794
835
  function __resolve_node_from_gap(el) {
795
836
  const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
796
837
  if (!gap) return null;
797
- const array_path = gap.dataset.gapArrayPath;
838
+ const array_path = deserialize_path(gap.dataset.gapArrayPath);
798
839
  const offset = parseInt(gap.dataset.gapOffset, 10);
799
840
  const node_idx = offset > 0 ? offset - 1 : 0;
800
841
  return /** @type {HTMLElement | null} */ (
801
- canvas_el.querySelector(`[data-path="${array_path}.${node_idx}"][data-type="node"]`)
842
+ canvas_el.querySelector(`[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`)
802
843
  );
803
844
  }
804
845
 
@@ -821,13 +862,36 @@ ${fallback_html}`;
821
862
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
822
863
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
823
864
 
865
+ // EDGE CASE: Collapsed selection inside an empty node placeholder.
866
+ // Firefox can place the DOM selection on the placeholder node itself,
867
+ // which otherwise looks like selecting node index 0 in an empty array.
868
+ const focus_empty_placeholder = /** @type {HTMLElement | null} */ (
869
+ focus_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
870
+ );
871
+ const anchor_empty_placeholder = /** @type {HTMLElement | null} */ (
872
+ anchor_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
873
+ );
874
+ if (focus_empty_placeholder && focus_empty_placeholder === anchor_empty_placeholder) {
875
+ const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
876
+ const array_path = empty_placeholder_path.slice(0, -1);
877
+ const node_array = session.get(array_path);
878
+ if (Array.isArray(node_array) && node_array.length === 0) {
879
+ return {
880
+ type: 'node',
881
+ path: array_path,
882
+ anchor_offset: 0,
883
+ focus_offset: 0
884
+ };
885
+ }
886
+ }
887
+
824
888
  // EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
825
889
  // Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
826
890
  const gap_el = /** @type {HTMLElement | null} */ (
827
891
  focus_node.closest('[data-gap-array-path]')
828
892
  );
829
893
  if (gap_el && focus_node === anchor_node) {
830
- const array_path = gap_el.dataset.gapArrayPath.split('.');
894
+ const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
831
895
  const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
832
896
  return {
833
897
  type: 'node',
@@ -845,8 +909,8 @@ ${fallback_html}`;
845
909
  ?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
846
910
  if (!anchor_root) return null;
847
911
 
848
- let focus_root_path = focus_root.dataset.path.split('.');
849
- let anchor_root_path = anchor_root.dataset.path.split('.');
912
+ let focus_root_path = deserialize_path(focus_root.dataset.path);
913
+ let anchor_root_path = deserialize_path(anchor_root.dataset.path);
850
914
  let focus_node_depth = focus_root_path.length;
851
915
  let anchor_node_depth = anchor_root_path.length;
852
916
 
@@ -856,30 +920,28 @@ ${fallback_html}`;
856
920
  // onto its index within that array.
857
921
  let focus_walked_up = false;
858
922
  let anchor_walked_up = false;
859
- while (
860
- focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
861
- ) {
923
+ while (!paths_equal(focus_root_path.slice(0, -1), anchor_root_path.slice(0, -1))) {
862
924
  if (focus_root_path.length > anchor_root_path.length) {
863
925
  // Focus is deeper — walk it up
864
926
  focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
865
927
  if (!focus_root) return null;
866
- focus_root_path = focus_root.dataset.path.split('.');
928
+ focus_root_path = deserialize_path(focus_root.dataset.path);
867
929
  focus_walked_up = true;
868
930
  } else if (anchor_root_path.length > focus_root_path.length) {
869
931
  // Anchor is deeper — walk it up
870
932
  anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
871
933
  if (!anchor_root) return null;
872
- anchor_root_path = anchor_root.dataset.path.split('.');
934
+ anchor_root_path = deserialize_path(anchor_root.dataset.path);
873
935
  anchor_walked_up = true;
874
936
  } else {
875
937
  // Same depth but different node arrays — walk both up
876
938
  focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
877
939
  if (!focus_root) return null;
878
- focus_root_path = focus_root.dataset.path.split('.');
940
+ focus_root_path = deserialize_path(focus_root.dataset.path);
879
941
  focus_walked_up = true;
880
942
  anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
881
943
  if (!anchor_root) return null;
882
- anchor_root_path = anchor_root.dataset.path.split('.');
944
+ anchor_root_path = deserialize_path(anchor_root.dataset.path);
883
945
  anchor_walked_up = true;
884
946
  }
885
947
  }
@@ -893,16 +955,13 @@ ${fallback_html}`;
893
955
  const parent_property = session.inspect(parent_array_path);
894
956
  if (!parent_property || parent_property.type !== 'node_array') return null;
895
957
 
896
- let anchor_offset = parseInt(anchor_root_path.at(-1));
897
- let focus_offset = parseInt(focus_root_path.at(-1));
958
+ let anchor_offset = Number(anchor_root_path.at(-1));
959
+ let focus_offset = Number(focus_root_path.at(-1));
898
960
 
899
961
  // Check if it's a backwards selection
900
962
  const is_backwards = __is_dom_selection_backwards();
901
- if (is_backwards) {
902
- anchor_offset += 1;
903
- } else {
904
- focus_offset += 1;
905
- }
963
+ if (is_backwards) anchor_offset += 1;
964
+ else focus_offset += 1;
906
965
 
907
966
  // EDGE CASE: Exclude first node when anchor_node is a gap-after
908
967
  // in a non-collapsed forward selection.
@@ -961,7 +1020,7 @@ ${fallback_html}`;
961
1020
  if (focus_root === anchor_root) {
962
1021
  return {
963
1022
  type: 'property',
964
- path: focus_root.dataset.path.split('.')
1023
+ path: deserialize_path(focus_root.dataset.path)
965
1024
  };
966
1025
  }
967
1026
  return null;
@@ -1004,19 +1063,30 @@ ${fallback_html}`;
1004
1063
  range = dom_selection.getRangeAt(0);
1005
1064
  }
1006
1065
 
1066
+ function get_text_root(node) {
1067
+ if (!node) return null;
1068
+
1069
+ if (node.nodeType === Node.ELEMENT_NODE) {
1070
+ return /** @type {HTMLElement | null} */ (
1071
+ node.closest?.('[data-path][data-type="text"]') ?? null
1072
+ );
1073
+ }
1074
+
1075
+ return /** @type {HTMLElement | null} */ (
1076
+ node.parentElement?.closest('[data-path][data-type="text"]') ?? null
1077
+ );
1078
+ }
1079
+
1007
1080
  let focus_root, anchor_root;
1008
1081
 
1009
1082
  if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
1010
1083
  // 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
1011
1084
  focus_root = anchor_root = focus_node;
1012
1085
  } else {
1013
- focus_root = /** @type {HTMLElement} */ (
1014
- focus_node.parentElement?.closest('[data-path][data-type="text"]')
1015
- );
1086
+ focus_root = get_text_root(focus_node);
1016
1087
  if (!focus_root) return null;
1017
- anchor_root = /** @type {HTMLElement} */ (
1018
- anchor_node.parentElement?.closest('[data-path][data-type="text"]')
1019
- );
1088
+
1089
+ anchor_root = get_text_root(anchor_node);
1020
1090
  if (!anchor_root) return null;
1021
1091
  }
1022
1092
 
@@ -1024,7 +1094,7 @@ ${fallback_html}`;
1024
1094
  return null;
1025
1095
  }
1026
1096
 
1027
- const path = focus_root.dataset.path.split('.');
1097
+ const path = deserialize_path(focus_root.dataset.path);
1028
1098
 
1029
1099
  if (!path) return null;
1030
1100
 
@@ -1033,12 +1103,14 @@ ${fallback_html}`;
1033
1103
  // AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
1034
1104
  // When the user places their caret after this <br>, focusNode is the container
1035
1105
  // element (not a text node), and normal processing would return position 0.
1036
- // We detect this and return text_length instead.
1037
- const text_content = session.get(path).text;
1038
- const text_length = get_char_length(text_content);
1106
+ // We detect this and return the current DOM text length instead.
1107
+ // During compositionend, the browser has already inserted the composed
1108
+ // character into the DOM, while the Svedit model still has the old text.
1109
+ const dom_text_length = get_char_length(focus_root.textContent ?? '');
1039
1110
  const child_nodes = focus_root.childNodes;
1040
1111
 
1041
1112
  if (
1113
+ range.collapsed &&
1042
1114
  focus_node === anchor_node &&
1043
1115
  focus_node === focus_root &&
1044
1116
  focus_root.dataset?.type === 'text' &&
@@ -1059,43 +1131,21 @@ ${fallback_html}`;
1059
1131
  return {
1060
1132
  type: 'text',
1061
1133
  path,
1062
- anchor_offset: text_length,
1063
- focus_offset: text_length
1134
+ anchor_offset: dom_text_length,
1135
+ focus_offset: dom_text_length
1064
1136
  };
1065
1137
  }
1066
1138
  }
1067
1139
 
1068
- let start_offset = 0;
1069
- let end_offset = 0;
1070
- let current_offset = 0;
1071
-
1072
- function processNode(node) {
1073
- if (node.nodeType === Node.TEXT_NODE) {
1074
- const nodeText = node.textContent;
1075
- const nodeCharLength = get_char_length(nodeText);
1076
- if (node === range.startContainer) {
1077
- // Convert UTF-16 offset to character offset
1078
- const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
1079
- start_offset = current_offset + char_start_offset;
1080
- }
1081
- if (node === range.endContainer) {
1082
- // Convert UTF-16 offset to character offset
1083
- const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
1084
- end_offset = current_offset + char_end_offset;
1085
- }
1086
- current_offset += nodeCharLength;
1087
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1088
- for (const childNode of node.childNodes) {
1089
- processNode(childNode);
1090
- }
1091
- }
1092
- return end_offset !== 0;
1140
+ function get_text_offset(container, offset) {
1141
+ const offset_range = window.document.createRange();
1142
+ offset_range.setStart(focus_root, 0);
1143
+ offset_range.setEnd(container, offset);
1144
+ return get_char_length(offset_range.toString());
1093
1145
  }
1094
1146
 
1095
- // Process nodes to find offsets
1096
- for (const childNode of focus_root.childNodes) {
1097
- if (processNode(childNode)) break;
1098
- }
1147
+ const start_offset = get_text_offset(range.startContainer, range.startOffset);
1148
+ const end_offset = get_text_offset(range.endContainer, range.endOffset);
1099
1149
 
1100
1150
  // Check if it's a backward selection
1101
1151
  // When range is provided, we can't detect backward selection from the range alone
@@ -1116,18 +1166,35 @@ ${fallback_html}`;
1116
1166
 
1117
1167
  function __get_node_element(node_array_path, node_offset) {
1118
1168
  return canvas_el.querySelector(
1119
- `[data-path="${node_array_path}.${node_offset}"][data-type="node"]`
1169
+ `[data-path="${serialize_path([...node_array_path, node_offset])}"][data-type="node"]`
1170
+ );
1171
+ }
1172
+
1173
+ /**
1174
+ * True when any part of the element's border box overlaps the window
1175
+ * viewport. Used to keep node-selection re-renders from scrolling a
1176
+ * cursor that is already on screen. Tests the window viewport, so a
1177
+ * node clipped only by an inner scroll container still counts visible.
1178
+ * @param {Element | null | undefined} el
1179
+ */
1180
+ function __intersects_viewport(el) {
1181
+ if (!el) return false;
1182
+ const r = el.getBoundingClientRect();
1183
+ return (
1184
+ r.bottom > 0 && r.top < window.innerHeight &&
1185
+ r.right > 0 && r.left < window.innerWidth
1120
1186
  );
1121
1187
  }
1122
1188
 
1123
1189
  function __render_node_selection() {
1124
1190
  const selection = /** @type {NodeSelection} */ (session.selection);
1125
- const node_array_path = selection.path.join('.');
1126
- const is_collapsed = selection.anchor_offset === selection.focus_offset;
1191
+ const node_array_path = selection.path;
1192
+ const node_array_path_str = serialize_path(node_array_path);
1193
+ const is_collapsed = is_selection_collapsed(selection);
1127
1194
  const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
1128
1195
 
1129
1196
  const node_array_el = canvas_el.querySelector(
1130
- `[data-path="${node_array_path}"][data-type="node_array"]`
1197
+ `[data-path="${node_array_path_str}"][data-type="node_array"]`
1131
1198
  );
1132
1199
  if (!node_array_el) return;
1133
1200
 
@@ -1135,7 +1202,7 @@ ${fallback_html}`;
1135
1202
  const range = window.document.createRange();
1136
1203
 
1137
1204
  const gap_selector = (offset) =>
1138
- `[data-gap-array-path="${node_array_path}"][data-gap-offset="${offset}"]`;
1205
+ `[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
1139
1206
 
1140
1207
  if (is_collapsed) {
1141
1208
  const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
@@ -1168,24 +1235,66 @@ ${fallback_html}`;
1168
1235
  }
1169
1236
  }
1170
1237
 
1171
- node_array_el.focus();
1172
- const scroll_node_offset = is_collapsed
1173
- ? Math.max(0, selection.anchor_offset - 1)
1174
- : (is_backward ? selection.focus_offset : selection.anchor_offset);
1175
- const scroll_node = __get_node_element(node_array_path, scroll_node_offset);
1176
- if (scroll_node) {
1177
- setTimeout(() => {
1178
- scroll_node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1179
- }, 0);
1180
- }
1238
+ // Scroll the cursor into view, but only when it is genuinely
1239
+ // off-screen. This runs on every node-selection re-render — a
1240
+ // transaction (type/layout change), select-parent, window refocus
1241
+ // — so an unconditional scroll would yank the viewport on each of
1242
+ // them. cursor_offset is a gap offset and the gap has no box of
1243
+ // its own, so visibility is judged from the nodes flanking it.
1244
+ setTimeout(() => {
1245
+ // Collapsed: anchor === focus, so focus is the gap offset.
1246
+ // Range: cursor sits at the focus end (anchor when backward).
1247
+ const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
1248
+ const array_length = session.get(node_array_path).length;
1249
+
1250
+ // If either node flanking the cursor is already (even
1251
+ // partially) on screen, the cursor is visible — keep the
1252
+ // viewport stable and scroll nothing. node_before is null at
1253
+ // offset 0: cursor_offset - 1 would be -1, and serialize_path
1254
+ // rejects a negative index.
1255
+ const node_before = cursor_offset > 0
1256
+ ? __get_node_element(node_array_path, cursor_offset - 1)
1257
+ : null;
1258
+ const node_after = __get_node_element(node_array_path, cursor_offset);
1259
+ if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
1260
+
1261
+ if (cursor_offset === 0) {
1262
+ node_array_el.scrollLeft = 0;
1263
+ node_array_el.scrollTop = 0;
1264
+ return;
1265
+ }
1266
+ if (cursor_offset >= array_length) {
1267
+ const max_left = Math.max(
1268
+ 0,
1269
+ node_array_el.scrollWidth - node_array_el.clientWidth
1270
+ );
1271
+ const max_top = Math.max(
1272
+ 0,
1273
+ node_array_el.scrollHeight - node_array_el.clientHeight
1274
+ );
1275
+ node_array_el.scrollLeft = max_left;
1276
+ node_array_el.scrollTop = max_top;
1277
+ if (max_left === 0 && max_top === 0) {
1278
+ node_before?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1279
+ }
1280
+ return;
1281
+ }
1282
+ // A range selection's selected node IS node_before; scrolling
1283
+ // node_after would reveal the following node and leave the
1284
+ // selection itself off-screen. For a collapsed caret node_after's
1285
+ // leading edge is the cursor, so that stays the right target.
1286
+ const target = is_collapsed ? node_after : node_before;
1287
+ target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1288
+ }, 0);
1181
1289
  }
1182
1290
 
1183
1291
  function __render_property_selection() {
1184
1292
  const selection = session.selection;
1185
1293
  // The element that holds the property
1186
1294
  const el = canvas_el.querySelector(
1187
- `[data-path="${selection.path.join('.')}"][data-type="property"]`
1295
+ `[data-path="${serialize_path(selection.path)}"][data-type="property"]`
1188
1296
  );
1297
+
1189
1298
  const gap_selectable = el.querySelector('.svedit-selectable');
1190
1299
  const range = window.document.createRange();
1191
1300
  const dom_selection = window.getSelection();
@@ -1198,10 +1307,7 @@ ${fallback_html}`;
1198
1307
 
1199
1308
  // Scroll the selection into view
1200
1309
  setTimeout(() => {
1201
- const selectedElement = dom_selection.focusNode.parentElement;
1202
- if (selectedElement) {
1203
- selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1204
- }
1310
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1205
1311
  }, 0);
1206
1312
  }
1207
1313
 
@@ -1209,7 +1315,7 @@ ${fallback_html}`;
1209
1315
  const selection = /** @type {any} */ (session.selection);
1210
1316
  // The element that holds the annotated string
1211
1317
  const el = canvas_el.querySelector(
1212
- `[data-path="${selection.path.join('.')}"][data-type="text"]`
1318
+ `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1213
1319
  );
1214
1320
  const empty_text = session.get(selection.path).text.length === 0;
1215
1321
  const dom_selection = window.getSelection();
@@ -1296,7 +1402,6 @@ ${fallback_html}`;
1296
1402
  focus_node,
1297
1403
  focus_node_offset
1298
1404
  );
1299
- el.focus(); // needed?
1300
1405
 
1301
1406
  // Scroll the selection into view
1302
1407
  setTimeout(() => {
@@ -1311,6 +1416,52 @@ ${fallback_html}`;
1311
1416
  // Utils
1312
1417
  // --------------------------
1313
1418
 
1419
+ function __remove_native_composition_text(selection, inserted_text) {
1420
+ if (!selection || selection.type !== 'text' || !inserted_text) return;
1421
+
1422
+ const text_el = canvas_el.querySelector(
1423
+ `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1424
+ );
1425
+ if (!text_el) return;
1426
+
1427
+ const model_text = session.get(selection.path).text;
1428
+ if ((text_el.textContent ?? '') === model_text) return;
1429
+
1430
+ let current_offset = 0;
1431
+ function get_dom_text_position(root, target_offset) {
1432
+ for (const node of root.childNodes) {
1433
+ if (node.nodeType === Node.TEXT_NODE) {
1434
+ const node_text = node.textContent ?? '';
1435
+ const node_length = get_char_length(node_text);
1436
+ if (current_offset + node_length >= target_offset) {
1437
+ return {
1438
+ node,
1439
+ offset: char_to_utf16_offset(node_text, target_offset - current_offset)
1440
+ };
1441
+ }
1442
+ current_offset += node_length;
1443
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
1444
+ const position = get_dom_text_position(node, target_offset);
1445
+ if (position) return position;
1446
+ }
1447
+ }
1448
+ return null;
1449
+ }
1450
+
1451
+ const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
1452
+ const end_offset = start_offset + get_char_length(inserted_text);
1453
+ current_offset = 0;
1454
+ const start_position = get_dom_text_position(text_el, start_offset);
1455
+ current_offset = 0;
1456
+ const end_position = get_dom_text_position(text_el, end_offset);
1457
+ if (!start_position || !end_position) return;
1458
+
1459
+ const range = window.document.createRange();
1460
+ range.setStart(start_position.node, start_position.offset);
1461
+ range.setEnd(end_position.node, end_position.offset);
1462
+ range.deleteContents();
1463
+ }
1464
+
1314
1465
  function __is_dom_selection_backwards() {
1315
1466
  const dom_selection = window.getSelection();
1316
1467
 
@@ -1333,12 +1484,17 @@ ${fallback_html}`;
1333
1484
  return comparisonRange.collapsed;
1334
1485
  }
1335
1486
 
1336
- // Whenever the model selection changes, render the selection
1487
+ // Whenever the model selection changes, render the selection.
1337
1488
  // Skip when canvas is not focused to avoid stealing focus back
1338
- // (e.g., when a dialog is open and selection highlight fragments re-render)
1489
+ // (e.g., when a dialog is open and selection highlight fragments re-render).
1490
+ // Consume the dom-driven flag at the top so it never carries stale
1491
+ // state into the next change, even when we early-return.
1339
1492
  $effect(() => {
1493
+ session.selection;
1494
+ const dom_driven = selection_source_is_dom;
1495
+ selection_source_is_dom = false;
1340
1496
  if (!canvas_focused) return;
1341
- render_selection();
1497
+ render_selection(dom_driven);
1342
1498
  });
1343
1499
  </script>
1344
1500
 
@@ -1349,10 +1505,20 @@ ${fallback_html}`;
1349
1505
  <svelte:document {onselectionchange} {oncut} {oncopy} {onpaste} />
1350
1506
 
1351
1507
  <!-- TODO: move oncut/copy/paste handlers inside .svedit -->
1352
- <div class="svedit">
1508
+ <div class="svedit" class:editable>
1353
1509
  <!-- Overlays must be before canvas so they initialize first. -->
1354
- <NodeSelectionMarkers />
1510
+ {#if editable}<NodeSelectionMarkers />{/if}
1355
1511
  {#if Overlays}<Overlays />{/if}
1512
+ <!--
1513
+ inputmode is derived from the model selection: when a custom property
1514
+ (image, embed, etc.) is selected, set inputmode="none" so iOS does
1515
+ not open the virtual keyboard. For any other selection (text, node,
1516
+ none), allow the keyboard via inputmode="text". The browser's native
1517
+ selection-change flow updates session.selection on tap, which flips
1518
+ this attribute before iOS decides whether to show the keyboard — no
1519
+ imperative state tracking, drag detection, or click/pointerdown
1520
+ handlers are required for keyboard suppression.
1521
+ -->
1356
1522
  <div
1357
1523
  class="svedit-canvas {css_class}"
1358
1524
  class:hide-selection={session.selection?.type === 'node'}
@@ -1365,6 +1531,7 @@ ${fallback_html}`;
1365
1531
  {oncompositionend}
1366
1532
  onfocus={handle_canvas_focus}
1367
1533
  onblur={handle_canvas_blur}
1534
+ inputmode={session.selection?.type === 'text' ? 'text' : 'none'}
1368
1535
  contenteditable={editable ? 'true' : 'false'}
1369
1536
  tabindex="-1"
1370
1537
  {autocapitalize}
@@ -1385,7 +1552,7 @@ ${fallback_html}`;
1385
1552
 
1386
1553
  <style>
1387
1554
  .svedit-canvas {
1388
- caret-color: var(--svedit-editing-stroke);
1555
+ caret-color: var(--svedit-caret-color);
1389
1556
  caret-shape: bar;
1390
1557
  /* Default to vertical/ column flow with: --row: 0; (the most common case)
1391
1558
  Prevents silent failures when developers forget to set the row property in their top level node component.
@@ -1396,7 +1563,11 @@ ${fallback_html}`;
1396
1563
  }
1397
1564
  }
1398
1565
 
1399
- .svedit-canvas :global(::selection) {
1566
+ /* Selection paint — wrapped in :where() so consumers can override with
1567
+ a plain `::selection` rule. Svelte still adds the scope hash inside
1568
+ the :where(), but the wrapper zeroes its specificity contribution.
1569
+ Final specificity is just (0,0,1), trivially beatable. */
1570
+ :where(.svedit-canvas) :global(::selection) {
1400
1571
  background: var(--svedit-editing-fill);
1401
1572
  }
1402
1573