svedit 0.8.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 +137 -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 +340 -147
  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 +340 -147
  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');
@@ -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,7 +543,6 @@ ${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
  }
@@ -498,6 +552,8 @@ ${fallback_html}`;
498
552
  }
499
553
  }
500
554
 
555
+
556
+
501
557
  function oncut(event) {
502
558
  if (!editable) return;
503
559
  oncopy(event, true);
@@ -542,9 +598,9 @@ ${fallback_html}`;
542
598
  });
543
599
  nodes_to_insert.push(new_node_id);
544
600
  } else {
545
- console.log(
546
- `rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
547
- );
601
+ // console.log(
602
+ // `rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
603
+ // );
548
604
  rejected = true;
549
605
  break;
550
606
  }
@@ -597,7 +653,6 @@ ${fallback_html}`;
597
653
  if (pasted_media.length > 0) {
598
654
  const handle_media_paste = session.config.handle_media_paste || session.config.handle_image_paste
599
655
  pasted_json = await handle_media_paste(session, pasted_media);
600
- console.log('pasted_json_after_media_paste', pasted_json);
601
656
  // NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
602
657
  // handled inside handle_media_paste already.
603
658
  if (!pasted_json) return;
@@ -608,8 +663,8 @@ ${fallback_html}`;
608
663
  if (html_content) {
609
664
  pasted_json = extract_svedit_data_from_html(html_content);
610
665
  }
611
- } catch (e) {
612
- 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
613
668
  pasted_json = undefined;
614
669
  }
615
670
 
@@ -619,13 +674,18 @@ ${fallback_html}`;
619
674
  console.error('Failed to paste any content:', e);
620
675
  }
621
676
 
622
- // Try to contruct a node payload from plain text when applicable
677
+ // Try to construct a node payload from plain text when applicable
623
678
  if (!pasted_json && typeof plain_text === 'string') {
624
679
  const plain_text_fragments = plain_text
625
680
  .split('\n\n')
626
681
  .map((fragment) => fragment.trim())
627
682
  .filter(Boolean);
628
- 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) {
629
689
  pasted_json = {
630
690
  main_nodes: [],
631
691
  nodes: []
@@ -692,32 +752,36 @@ ${fallback_html}`;
692
752
  } else if (typeof plain_text === 'string') {
693
753
  // External paste: Fallback to plain text when no svedit data is found
694
754
  session.apply(session.tr.insert_text(plain_text.trim()));
695
- } else {
696
- console.log('Could not paste.');
697
755
  }
698
756
  }
699
757
 
700
- function render_selection() {
758
+ function render_selection(dom_driven = false) {
701
759
  const selection = /** @type {Selection} */ (session.selection);
702
760
 
703
761
  if (!selection) {
704
762
  // No model selection -> just leave things as they are
705
- let dom_selection = window.getSelection();
706
- 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();
707
767
  return;
708
768
  }
709
769
 
710
- // NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
711
- let prev_selection =
712
- __get_property_selection_from_dom() ||
713
- __get_text_selection_from_dom() ||
714
- __get_node_selection_from_dom();
715
- if (
716
- JSON.stringify(selection) === JSON.stringify(prev_selection) &&
717
- canvas_el?.contains(document.activeElement)
718
- ) {
719
- // Skip. No need to rerender.
720
- 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
+ }
721
785
  }
722
786
 
723
787
  if (selection?.type === 'text') {
@@ -727,7 +791,7 @@ ${fallback_html}`;
727
791
  } else if (selection?.type === 'property') {
728
792
  __render_property_selection();
729
793
  } else {
730
- console.log('unsupported selection', $state.snapshot(selection));
794
+ console.warn('unsupported selection', $state.snapshot(selection));
731
795
  }
732
796
  }
733
797
 
@@ -736,18 +800,12 @@ ${fallback_html}`;
736
800
  // Use flushSync so highlight spans are removed from the DOM
737
801
  // immediately, before the browser processes the click's selection.
738
802
  flushSync(() => {
739
- // Clear the model selection so render_selection() does not call
740
- // setBaseAndExtent() with the old position, which would battle
741
- // with the new selection the user is making (the click/drag that
742
- // triggered this focus). The browser will place the caret and
743
- // fire selectionchange, which sets the model selection correctly.
744
- session.selection = null;
745
803
  canvas_focused = true;
746
804
  });
747
805
  key_mapper?.push_scope(session.keymap);
748
806
  }
749
807
 
750
- // Handle blur - pop document's keymap from stack
808
+ // Handle blur - pop document's keymap from stack.
751
809
  function handle_canvas_blur() {
752
810
  // Use flushSync so the selection highlight span (with its CSS anchor)
753
811
  // is in the DOM immediately, before any popover/dialog tries to
@@ -777,11 +835,11 @@ ${fallback_html}`;
777
835
  function __resolve_node_from_gap(el) {
778
836
  const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
779
837
  if (!gap) return null;
780
- const array_path = gap.dataset.gapArrayPath;
838
+ const array_path = deserialize_path(gap.dataset.gapArrayPath);
781
839
  const offset = parseInt(gap.dataset.gapOffset, 10);
782
840
  const node_idx = offset > 0 ? offset - 1 : 0;
783
841
  return /** @type {HTMLElement | null} */ (
784
- 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"]`)
785
843
  );
786
844
  }
787
845
 
@@ -804,13 +862,36 @@ ${fallback_html}`;
804
862
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
805
863
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
806
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
+
807
888
  // EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
808
889
  // Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
809
890
  const gap_el = /** @type {HTMLElement | null} */ (
810
891
  focus_node.closest('[data-gap-array-path]')
811
892
  );
812
893
  if (gap_el && focus_node === anchor_node) {
813
- const array_path = gap_el.dataset.gapArrayPath.split('.');
894
+ const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
814
895
  const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
815
896
  return {
816
897
  type: 'node',
@@ -828,8 +909,8 @@ ${fallback_html}`;
828
909
  ?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
829
910
  if (!anchor_root) return null;
830
911
 
831
- let focus_root_path = focus_root.dataset.path.split('.');
832
- 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);
833
914
  let focus_node_depth = focus_root_path.length;
834
915
  let anchor_node_depth = anchor_root_path.length;
835
916
 
@@ -839,30 +920,28 @@ ${fallback_html}`;
839
920
  // onto its index within that array.
840
921
  let focus_walked_up = false;
841
922
  let anchor_walked_up = false;
842
- while (
843
- focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
844
- ) {
923
+ while (!paths_equal(focus_root_path.slice(0, -1), anchor_root_path.slice(0, -1))) {
845
924
  if (focus_root_path.length > anchor_root_path.length) {
846
925
  // Focus is deeper — walk it up
847
926
  focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
848
927
  if (!focus_root) return null;
849
- focus_root_path = focus_root.dataset.path.split('.');
928
+ focus_root_path = deserialize_path(focus_root.dataset.path);
850
929
  focus_walked_up = true;
851
930
  } else if (anchor_root_path.length > focus_root_path.length) {
852
931
  // Anchor is deeper — walk it up
853
932
  anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
854
933
  if (!anchor_root) return null;
855
- anchor_root_path = anchor_root.dataset.path.split('.');
934
+ anchor_root_path = deserialize_path(anchor_root.dataset.path);
856
935
  anchor_walked_up = true;
857
936
  } else {
858
937
  // Same depth but different node arrays — walk both up
859
938
  focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
860
939
  if (!focus_root) return null;
861
- focus_root_path = focus_root.dataset.path.split('.');
940
+ focus_root_path = deserialize_path(focus_root.dataset.path);
862
941
  focus_walked_up = true;
863
942
  anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
864
943
  if (!anchor_root) return null;
865
- anchor_root_path = anchor_root.dataset.path.split('.');
944
+ anchor_root_path = deserialize_path(anchor_root.dataset.path);
866
945
  anchor_walked_up = true;
867
946
  }
868
947
  }
@@ -871,16 +950,18 @@ ${fallback_html}`;
871
950
  // (e.g. selecting between nav and page body). The resulting path would be empty.
872
951
  if (anchor_root_path.length <= 1) return null;
873
952
 
874
- let anchor_offset = parseInt(anchor_root_path.at(-1));
875
- let focus_offset = parseInt(focus_root_path.at(-1));
953
+ const parent_array_path = anchor_root_path.slice(0, -1);
954
+ // A node selection is only valid inside a node_array property.
955
+ const parent_property = session.inspect(parent_array_path);
956
+ if (!parent_property || parent_property.type !== 'node_array') return null;
957
+
958
+ let anchor_offset = Number(anchor_root_path.at(-1));
959
+ let focus_offset = Number(focus_root_path.at(-1));
876
960
 
877
961
  // Check if it's a backwards selection
878
962
  const is_backwards = __is_dom_selection_backwards();
879
- if (is_backwards) {
880
- anchor_offset += 1;
881
- } else {
882
- focus_offset += 1;
883
- }
963
+ if (is_backwards) anchor_offset += 1;
964
+ else focus_offset += 1;
884
965
 
885
966
  // EDGE CASE: Exclude first node when anchor_node is a gap-after
886
967
  // in a non-collapsed forward selection.
@@ -910,7 +991,7 @@ ${fallback_html}`;
910
991
 
911
992
  return {
912
993
  type: 'node',
913
- path: anchor_root_path.slice(0, -1),
994
+ path: parent_array_path,
914
995
  anchor_offset: anchor_offset,
915
996
  focus_offset: focus_offset
916
997
  };
@@ -939,7 +1020,7 @@ ${fallback_html}`;
939
1020
  if (focus_root === anchor_root) {
940
1021
  return {
941
1022
  type: 'property',
942
- path: focus_root.dataset.path.split('.')
1023
+ path: deserialize_path(focus_root.dataset.path)
943
1024
  };
944
1025
  }
945
1026
  return null;
@@ -982,19 +1063,30 @@ ${fallback_html}`;
982
1063
  range = dom_selection.getRangeAt(0);
983
1064
  }
984
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
+
985
1080
  let focus_root, anchor_root;
986
1081
 
987
1082
  if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
988
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
989
1084
  focus_root = anchor_root = focus_node;
990
1085
  } else {
991
- focus_root = /** @type {HTMLElement} */ (
992
- focus_node.parentElement?.closest('[data-path][data-type="text"]')
993
- );
1086
+ focus_root = get_text_root(focus_node);
994
1087
  if (!focus_root) return null;
995
- anchor_root = /** @type {HTMLElement} */ (
996
- anchor_node.parentElement?.closest('[data-path][data-type="text"]')
997
- );
1088
+
1089
+ anchor_root = get_text_root(anchor_node);
998
1090
  if (!anchor_root) return null;
999
1091
  }
1000
1092
 
@@ -1002,7 +1094,7 @@ ${fallback_html}`;
1002
1094
  return null;
1003
1095
  }
1004
1096
 
1005
- const path = focus_root.dataset.path.split('.');
1097
+ const path = deserialize_path(focus_root.dataset.path);
1006
1098
 
1007
1099
  if (!path) return null;
1008
1100
 
@@ -1011,12 +1103,14 @@ ${fallback_html}`;
1011
1103
  // AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
1012
1104
  // When the user places their caret after this <br>, focusNode is the container
1013
1105
  // element (not a text node), and normal processing would return position 0.
1014
- // We detect this and return text_length instead.
1015
- const text_content = session.get(path).text;
1016
- 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 ?? '');
1017
1110
  const child_nodes = focus_root.childNodes;
1018
1111
 
1019
1112
  if (
1113
+ range.collapsed &&
1020
1114
  focus_node === anchor_node &&
1021
1115
  focus_node === focus_root &&
1022
1116
  focus_root.dataset?.type === 'text' &&
@@ -1037,43 +1131,21 @@ ${fallback_html}`;
1037
1131
  return {
1038
1132
  type: 'text',
1039
1133
  path,
1040
- anchor_offset: text_length,
1041
- focus_offset: text_length
1134
+ anchor_offset: dom_text_length,
1135
+ focus_offset: dom_text_length
1042
1136
  };
1043
1137
  }
1044
1138
  }
1045
1139
 
1046
- let start_offset = 0;
1047
- let end_offset = 0;
1048
- let current_offset = 0;
1049
-
1050
- function processNode(node) {
1051
- if (node.nodeType === Node.TEXT_NODE) {
1052
- const nodeText = node.textContent;
1053
- const nodeCharLength = get_char_length(nodeText);
1054
- if (node === range.startContainer) {
1055
- // Convert UTF-16 offset to character offset
1056
- const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
1057
- start_offset = current_offset + char_start_offset;
1058
- }
1059
- if (node === range.endContainer) {
1060
- // Convert UTF-16 offset to character offset
1061
- const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
1062
- end_offset = current_offset + char_end_offset;
1063
- }
1064
- current_offset += nodeCharLength;
1065
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1066
- for (const childNode of node.childNodes) {
1067
- processNode(childNode);
1068
- }
1069
- }
1070
- 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());
1071
1145
  }
1072
1146
 
1073
- // Process nodes to find offsets
1074
- for (const childNode of focus_root.childNodes) {
1075
- if (processNode(childNode)) break;
1076
- }
1147
+ const start_offset = get_text_offset(range.startContainer, range.startOffset);
1148
+ const end_offset = get_text_offset(range.endContainer, range.endOffset);
1077
1149
 
1078
1150
  // Check if it's a backward selection
1079
1151
  // When range is provided, we can't detect backward selection from the range alone
@@ -1094,18 +1166,35 @@ ${fallback_html}`;
1094
1166
 
1095
1167
  function __get_node_element(node_array_path, node_offset) {
1096
1168
  return canvas_el.querySelector(
1097
- `[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
1098
1186
  );
1099
1187
  }
1100
1188
 
1101
1189
  function __render_node_selection() {
1102
1190
  const selection = /** @type {NodeSelection} */ (session.selection);
1103
- const node_array_path = selection.path.join('.');
1104
- 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);
1105
1194
  const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
1106
1195
 
1107
1196
  const node_array_el = canvas_el.querySelector(
1108
- `[data-path="${node_array_path}"][data-type="node_array"]`
1197
+ `[data-path="${node_array_path_str}"][data-type="node_array"]`
1109
1198
  );
1110
1199
  if (!node_array_el) return;
1111
1200
 
@@ -1113,7 +1202,7 @@ ${fallback_html}`;
1113
1202
  const range = window.document.createRange();
1114
1203
 
1115
1204
  const gap_selector = (offset) =>
1116
- `[data-gap-array-path="${node_array_path}"][data-gap-offset="${offset}"]`;
1205
+ `[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
1117
1206
 
1118
1207
  if (is_collapsed) {
1119
1208
  const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
@@ -1146,24 +1235,66 @@ ${fallback_html}`;
1146
1235
  }
1147
1236
  }
1148
1237
 
1149
- node_array_el.focus();
1150
- const scroll_node_offset = is_collapsed
1151
- ? Math.max(0, selection.anchor_offset - 1)
1152
- : (is_backward ? selection.focus_offset : selection.anchor_offset);
1153
- const scroll_node = __get_node_element(node_array_path, scroll_node_offset);
1154
- if (scroll_node) {
1155
- setTimeout(() => {
1156
- scroll_node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1157
- }, 0);
1158
- }
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);
1159
1289
  }
1160
1290
 
1161
1291
  function __render_property_selection() {
1162
1292
  const selection = session.selection;
1163
1293
  // The element that holds the property
1164
1294
  const el = canvas_el.querySelector(
1165
- `[data-path="${selection.path.join('.')}"][data-type="property"]`
1295
+ `[data-path="${serialize_path(selection.path)}"][data-type="property"]`
1166
1296
  );
1297
+
1167
1298
  const gap_selectable = el.querySelector('.svedit-selectable');
1168
1299
  const range = window.document.createRange();
1169
1300
  const dom_selection = window.getSelection();
@@ -1176,10 +1307,7 @@ ${fallback_html}`;
1176
1307
 
1177
1308
  // Scroll the selection into view
1178
1309
  setTimeout(() => {
1179
- const selectedElement = dom_selection.focusNode.parentElement;
1180
- if (selectedElement) {
1181
- selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1182
- }
1310
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1183
1311
  }, 0);
1184
1312
  }
1185
1313
 
@@ -1187,7 +1315,7 @@ ${fallback_html}`;
1187
1315
  const selection = /** @type {any} */ (session.selection);
1188
1316
  // The element that holds the annotated string
1189
1317
  const el = canvas_el.querySelector(
1190
- `[data-path="${selection.path.join('.')}"][data-type="text"]`
1318
+ `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1191
1319
  );
1192
1320
  const empty_text = session.get(selection.path).text.length === 0;
1193
1321
  const dom_selection = window.getSelection();
@@ -1274,7 +1402,6 @@ ${fallback_html}`;
1274
1402
  focus_node,
1275
1403
  focus_node_offset
1276
1404
  );
1277
- el.focus(); // needed?
1278
1405
 
1279
1406
  // Scroll the selection into view
1280
1407
  setTimeout(() => {
@@ -1289,6 +1416,52 @@ ${fallback_html}`;
1289
1416
  // Utils
1290
1417
  // --------------------------
1291
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
+
1292
1465
  function __is_dom_selection_backwards() {
1293
1466
  const dom_selection = window.getSelection();
1294
1467
 
@@ -1311,12 +1484,17 @@ ${fallback_html}`;
1311
1484
  return comparisonRange.collapsed;
1312
1485
  }
1313
1486
 
1314
- // Whenever the model selection changes, render the selection
1487
+ // Whenever the model selection changes, render the selection.
1315
1488
  // Skip when canvas is not focused to avoid stealing focus back
1316
- // (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.
1317
1492
  $effect(() => {
1493
+ session.selection;
1494
+ const dom_driven = selection_source_is_dom;
1495
+ selection_source_is_dom = false;
1318
1496
  if (!canvas_focused) return;
1319
- render_selection();
1497
+ render_selection(dom_driven);
1320
1498
  });
1321
1499
  </script>
1322
1500
 
@@ -1327,10 +1505,20 @@ ${fallback_html}`;
1327
1505
  <svelte:document {onselectionchange} {oncut} {oncopy} {onpaste} />
1328
1506
 
1329
1507
  <!-- TODO: move oncut/copy/paste handlers inside .svedit -->
1330
- <div class="svedit">
1508
+ <div class="svedit" class:editable>
1331
1509
  <!-- Overlays must be before canvas so they initialize first. -->
1332
- <NodeSelectionMarkers />
1510
+ {#if editable}<NodeSelectionMarkers />{/if}
1333
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
+ -->
1334
1522
  <div
1335
1523
  class="svedit-canvas {css_class}"
1336
1524
  class:hide-selection={session.selection?.type === 'node'}
@@ -1343,6 +1531,7 @@ ${fallback_html}`;
1343
1531
  {oncompositionend}
1344
1532
  onfocus={handle_canvas_focus}
1345
1533
  onblur={handle_canvas_blur}
1534
+ inputmode={session.selection?.type === 'text' ? 'text' : 'none'}
1346
1535
  contenteditable={editable ? 'true' : 'false'}
1347
1536
  tabindex="-1"
1348
1537
  {autocapitalize}
@@ -1363,7 +1552,7 @@ ${fallback_html}`;
1363
1552
 
1364
1553
  <style>
1365
1554
  .svedit-canvas {
1366
- caret-color: var(--svedit-editing-stroke);
1555
+ caret-color: var(--svedit-caret-color);
1367
1556
  caret-shape: bar;
1368
1557
  /* Default to vertical/ column flow with: --row: 0; (the most common case)
1369
1558
  Prevents silent failures when developers forget to set the row property in their top level node component.
@@ -1374,7 +1563,11 @@ ${fallback_html}`;
1374
1563
  }
1375
1564
  }
1376
1565
 
1377
- .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) {
1378
1571
  background: var(--svedit-editing-fill);
1379
1572
  }
1380
1573