svedit 0.9.0 → 0.11.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 (72) hide show
  1. package/README.md +476 -364
  2. package/dist/AnnotatedTextProperty.svelte +40 -10
  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 +48 -6
  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 +59 -48
  13. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  14. package/dist/NodeCaret.svelte +0 -7
  15. package/dist/NodeGap.svelte +208 -118
  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 +496 -221
  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 +69 -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 +6 -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/paste_utils.d.ts +46 -0
  40. package/dist/paste_utils.d.ts.map +1 -0
  41. package/dist/paste_utils.js +167 -0
  42. package/dist/styles/svedit-animations.css +8 -0
  43. package/dist/styles/svedit-colors.css +1 -0
  44. package/dist/types.d.ts +46 -3
  45. package/dist/utils.d.ts +52 -0
  46. package/dist/utils.d.ts.map +1 -1
  47. package/dist/utils.js +109 -3
  48. package/package.json +3 -2
  49. package/src/lib/AnnotatedTextProperty.svelte +40 -10
  50. package/src/lib/Command.svelte.js +48 -6
  51. package/src/lib/CustomProperty.svelte +20 -9
  52. package/src/lib/Node.svelte +7 -5
  53. package/src/lib/NodeArrayProperty.svelte +59 -48
  54. package/src/lib/NodeCaret.svelte +0 -7
  55. package/src/lib/NodeGap.svelte +208 -118
  56. package/src/lib/NodeGapMarkers.svelte +251 -84
  57. package/src/lib/NodeSelectionMarkers.svelte +4 -3
  58. package/src/lib/Session.svelte.js +40 -3
  59. package/src/lib/Svedit.svelte +496 -221
  60. package/src/lib/Transaction.svelte.js +103 -61
  61. package/src/lib/doc_utils.js +276 -10
  62. package/src/lib/index.js +6 -1
  63. package/src/lib/node_visibility.svelte.js +718 -0
  64. package/src/lib/paste_utils.js +167 -0
  65. package/src/lib/styles/svedit-animations.css +8 -0
  66. package/src/lib/styles/svedit-colors.css +1 -0
  67. package/src/lib/types.d.ts +46 -3
  68. package/src/lib/utils.js +109 -3
  69. package/dist/node_gap_computation.svelte.d.ts +0 -5
  70. package/dist/node_gap_computation.svelte.d.ts.map +0 -1
  71. package/dist/node_gap_computation.svelte.js +0 -621
  72. package/src/lib/node_gap_computation.svelte.js +0 -621
@@ -1,18 +1,31 @@
1
1
  <script>
2
2
  import { flushSync, getContext, setContext } from 'svelte';
3
3
  import {
4
- snake_to_pascal,
5
4
  get_char_length,
6
- utf16_to_char_offset,
7
- char_to_utf16_offset
5
+ char_to_utf16_offset,
6
+ deserialize_path,
7
+ paths_equal,
8
+ serialize_path,
9
+ is_selection_collapsed
8
10
  } from './utils.js';
9
- import { create_gap_computation } from './node_gap_computation.svelte.js';
11
+ import {
12
+ normalize_line_endings,
13
+ dedent_plain_text,
14
+ split_plain_text_paragraphs,
15
+ normalize_plain_text_for_single_line_property,
16
+ get_text_property_name,
17
+ get_text_content,
18
+ is_text_like_node_payload,
19
+ get_default_text_node,
20
+ create_plain_text_nodes_payload
21
+ } from './paste_utils.js';
22
+ import { create_node_visibility } from './node_visibility.svelte.js';
10
23
  import DefaultNodeSelectionMarkers from './NodeSelectionMarkers.svelte';
11
24
  import './styles/svedit-colors.css';
25
+ import './styles/svedit-animations.css';
12
26
 
13
27
  /** @import {
14
28
  * SveditProps,
15
- * DocumentPath,
16
29
  * Selection,
17
30
  * TextSelection,
18
31
  * NodeSelection,
@@ -33,14 +46,19 @@
33
46
 
34
47
  let canvas_el;
35
48
  let root_node = $derived(session.get(path));
36
- let Overlays = $derived(session.config.system_components?.Overlays);
37
- let NodeSelectionMarkers = $derived(session.config.system_components?.NodeSelectionMarkers ?? DefaultNodeSelectionMarkers);
38
- let RootComponent = $derived(session.config.node_components[snake_to_pascal(root_node.type)]);
49
+ let Overlays = $derived(session.config.system_components?.overlays);
50
+ let NodeSelectionMarkers = $derived(
51
+ session.config.system_components?.node_selection_markers ?? DefaultNodeSelectionMarkers
52
+ );
53
+ let RootComponent = $derived(session.config.node_components[root_node.type]);
39
54
 
40
55
  let is_composing = $state(false);
41
56
  let canvas_focused = $state(false);
42
57
  let before_composition_selection = null;
43
-
58
+ // Set by onselectionchange before it commits a DOM-derived selection
59
+ // to the model. render_selection consumes-and-clears it to skip
60
+ // rerender on DOM-driven changes (the DOM is already in place).
61
+ let selection_source_is_dom = false;
44
62
 
45
63
  // let is_mobile = $derived(is_mobile_browser());
46
64
  // let is_chrome_desktop = $derived(is_chrome_desktop_browser());
@@ -71,7 +89,51 @@
71
89
  };
72
90
 
73
91
  setContext('svedit', context);
74
- create_gap_computation(context);
92
+ create_node_visibility(context);
93
+
94
+ $effect(() => {
95
+ check_duplicate_paths();
96
+ });
97
+
98
+ function check_duplicate_paths() {
99
+ if (!canvas_el) return;
100
+
101
+ /** @type {Record<string, true>} */
102
+ const mounted_paths = Object.create(null);
103
+
104
+ for (const element of canvas_el.querySelectorAll('[data-path]')) {
105
+ if (element.closest('.svedit-canvas') !== canvas_el) continue;
106
+
107
+ const path_str = element.getAttribute('data-path');
108
+ if (!path_str) continue;
109
+
110
+ if (mounted_paths[path_str]) {
111
+ console.warn(
112
+ `[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.`
113
+ );
114
+ }
115
+
116
+ mounted_paths[path_str] = true;
117
+ }
118
+ }
119
+
120
+ // Test-only hook: expose the svedit context so test specs can read
121
+ // near_map / edge_map for diagnostics. Gated on `import.meta.env.MODE`
122
+ // — Vite replaces the literal so the comparison is constant-false in
123
+ // production and the branch is dead-code-eliminated. Wrapped in an
124
+ // effect so unmount clears the reference and tests don't leak state
125
+ // across remounts.
126
+ if (import.meta.env?.MODE === 'test') {
127
+ $effect(() => {
128
+ const g = /** @type {any} */ (globalThis);
129
+ g.__svedit_ctx_for_test = context;
130
+ return () => {
131
+ if (g.__svedit_ctx_for_test === context) {
132
+ delete g.__svedit_ctx_for_test;
133
+ }
134
+ };
135
+ });
136
+ }
75
137
 
76
138
  // Get KeyMapper from context (may be undefined if not provided)
77
139
  const key_mapper = getContext('key_mapper');
@@ -145,14 +207,14 @@
145
207
  if (
146
208
  ['deleteContentBackward', 'deleteWordBackward', 'deleteContent'].includes(event.inputType)
147
209
  ) {
148
- delete_at_selection('backward');
210
+ session.apply(session.tr.delete_selection('backward'));
149
211
  event.preventDefault();
150
212
  event.stopPropagation();
151
213
  return;
152
214
  }
153
215
 
154
216
  if (['deleteContentForward', 'deleteWordForward'].includes(event.inputType)) {
155
- delete_at_selection('forward');
217
+ session.apply(session.tr.delete_selection('forward'));
156
218
  event.preventDefault();
157
219
  event.stopPropagation();
158
220
  return;
@@ -190,10 +252,9 @@
190
252
  /**
191
253
  * Handles composition start events for input methods like dead keys
192
254
  * This occurs when user starts typing a composed character (e.g., backtick for accents)
193
- * @param {CompositionEvent} event
194
255
  */
195
- function oncompositionstart(event) {
196
- console.log('DEBUG: oncompositionstart', event.data);
256
+ function oncompositionstart(/*event*/) {
257
+ // console.log('DEBUG: oncompositionstart', event.data);
197
258
  if (session.selection.type !== 'text') {
198
259
  // Remove all ranges - completely clears the selection
199
260
  window.getSelection()?.removeAllRanges();
@@ -217,7 +278,7 @@
217
278
  * This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
218
279
  */
219
280
  function oncompositionend(event) {
220
- console.log('DEBUG: oncompositionend, insert:', event.data, event);
281
+ // console.log('DEBUG: oncompositionend, insert:', event.data, event);
221
282
  if (!canvas_el?.contains(document.activeElement)) return;
222
283
  if (session.selection?.type === 'text') {
223
284
  // We need to remember the user's selection, as it might have changed in the process
@@ -229,12 +290,16 @@
229
290
  // native undo command. Then the DOM will be in sync again with the editor's internal state.
230
291
  document.execCommand('undo', false, null);
231
292
 
293
+ // Firefox may not undo native composition DOM. If the composed text is
294
+ // still present, remove it manually before applying the Svedit transaction.
295
+ __remove_native_composition_text(before_composition_selection, event.data);
296
+
232
297
  // Set the selection to where the user initiated the composition, make changes, and apply.
233
298
  // NOTE: We need to check for valid selection here, as there is a rare race condition
234
299
  // where the user had no text selection at the start of composition.
235
300
  if (before_composition_selection) {
236
301
  session.selection = before_composition_selection;
237
- console.log('event.data', event.data);
302
+ // console.log('event.data', event.data);
238
303
  const tr = session.tr;
239
304
  tr.insert_text(event.data);
240
305
  session.apply(tr);
@@ -261,6 +326,7 @@
261
326
  function onselectionchange() {
262
327
  if (!editable) return;
263
328
  if (!canvas_focused) return;
329
+ if (is_composing) return;
264
330
  const dom_selection = window.getSelection();
265
331
  if (!dom_selection.rangeCount) return;
266
332
 
@@ -273,6 +339,7 @@
273
339
  // structurally identical — prevents a redundant $effect cycle
274
340
  // (render_selection → scrollIntoView) on every DOM layout change.
275
341
  if (JSON.stringify(selection) === JSON.stringify(session.selection)) return;
342
+ selection_source_is_dom = true;
276
343
  session.selection = selection;
277
344
  }
278
345
  }
@@ -442,25 +509,24 @@ ${fallback_html}`;
442
509
  annotated_text = session.get_selected_annotated_text();
443
510
  const fallback_html = `<span>${annotated_text.text}</span>`;
444
511
 
445
- console.log('Text copy:', {
446
- annotated_text,
447
- plain_text,
448
- html
449
- });
512
+ // console.log('Text copy:', {
513
+ // annotated_text,
514
+ // plain_text,
515
+ // html
516
+ // });
450
517
 
451
518
  html = create_svedit_html_format(annotated_text, fallback_html);
452
519
  } else if (session.selection?.type === 'node') {
453
520
  const selected_nodes = session.get_selected_nodes();
454
521
  const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
455
-
456
522
  const json_data = { nodes, main_nodes };
457
523
 
458
- console.log('Node copy:', {
459
- selected_nodes,
460
- nodes,
461
- total_nodes: Object.keys(nodes).length,
462
- operation: delete_selection ? 'cut' : 'copy'
463
- });
524
+ // console.log('Node copy:', {
525
+ // selected_nodes,
526
+ // nodes,
527
+ // total_nodes: Object.keys(nodes).length,
528
+ // operation: delete_selection ? 'cut' : 'copy'
529
+ // });
464
530
 
465
531
  // Generate fallback HTML for cross-app compatibility
466
532
  const selected_node_objects = main_nodes.map((id) => nodes[id]);
@@ -479,7 +545,6 @@ ${fallback_html}`;
479
545
  type: property_definition.type,
480
546
  value
481
547
  };
482
- console.log('Property copy:', json_data);
483
548
  html = create_svedit_html_format(json_data, `<span>${value}</span>`);
484
549
  plain_text = String(value);
485
550
  }
@@ -488,36 +553,82 @@ ${fallback_html}`;
488
553
  try {
489
554
  event.clipboardData?.setData('text/plain', plain_text || '');
490
555
  event.clipboardData?.setData('text/html', html || '');
491
- console.log('Data copied to clipboard successfully');
492
556
  } catch (err) {
493
557
  console.error('Failed to copy data: ', err);
494
558
  }
495
559
 
496
560
  if (delete_selection) {
497
- delete_at_selection();
561
+ session.apply(session.tr.delete_selection());
498
562
  }
499
563
  }
500
564
 
565
+ function oncut(event) {
566
+ if (!editable) return;
567
+ oncopy(event, true);
568
+ }
569
+
570
+
571
+
501
572
  /**
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]
573
+ * @returns {NodeSelection|null}
507
574
  */
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));
575
+ function get_root_node_insert_caret() {
576
+ const root_node = session.get(path);
577
+ const root_schema = root_node ? session.schema[root_node.type] : null;
578
+ if (!root_schema?.properties) return null;
579
+
580
+ const preferred_property_name =
581
+ root_schema.properties.body?.type === 'node_array'
582
+ ? 'body'
583
+ : Object.entries(root_schema.properties).find(([, property_definition]) => {
584
+ return property_definition.type === 'node_array';
585
+ })?.[0];
586
+ if (!preferred_property_name) return null;
587
+
588
+ const node_array_path = [...path, preferred_property_name];
589
+ const node_array = session.get(node_array_path);
590
+ if (!Array.isArray(node_array)) return null;
591
+
592
+ return {
593
+ type: /** @type {const} */ ('node'),
594
+ path: node_array_path,
595
+ anchor_offset: node_array.length,
596
+ focus_offset: node_array.length
597
+ };
598
+ }
599
+
600
+ /**
601
+ * @param {Selection|null} [selection]
602
+ * @returns {NodeSelection|null}
603
+ */
604
+ function get_target_node_insert_caret(selection = session.selection) {
605
+ if (selection?.type === 'node') {
606
+ return selection;
515
607
  }
608
+
609
+ const next_node_insert_caret = session.get_next_node_insert_caret(selection);
610
+ if (next_node_insert_caret?.type === 'node') {
611
+ return next_node_insert_caret;
612
+ }
613
+
614
+ return get_root_node_insert_caret();
516
615
  }
517
616
 
518
- function oncut(event) {
519
- if (!editable) return;
520
- oncopy(event, true);
617
+ /**
618
+ * @param {Selection|null} [selection]
619
+ * @returns {NodeSelection|null}
620
+ */
621
+ function get_node_insert_caret_after_text_selection(selection = session.selection) {
622
+ if (selection?.type !== 'text') return null;
623
+ const node_index = parseInt(String(selection.path.at(-2)), 10);
624
+ if (Number.isNaN(node_index)) return null;
625
+
626
+ return {
627
+ type: /** @type {const} */ ('node'),
628
+ path: selection.path.slice(0, -2),
629
+ anchor_offset: node_index + 1,
630
+ focus_offset: node_index + 1
631
+ };
521
632
  }
522
633
 
523
634
  /**
@@ -528,40 +639,46 @@ ${fallback_html}`;
528
639
  * @returns {boolean} True if the paste operation was successful, false otherwise
529
640
  */
530
641
  function try_node_paste(pasted_json, selection) {
531
- const { nodes, main_nodes } = pasted_json;
642
+ const { nodes, main_nodes } = pasted_json || {};
643
+ if (!nodes || !main_nodes?.length) return false;
532
644
 
533
- // NOTE: At this point, nodes contains a subgraph from the copy
534
- // with original ids.
535
645
  let tr = session.tr;
536
646
  if (selection) {
537
647
  tr.set_selection(selection);
538
648
  }
649
+ if (tr.selection?.type !== 'node') return false;
539
650
 
540
- // We can safely assume we're dealing with a node_array property
541
651
  const property_definition = session.inspect(tr.selection.path);
542
- const first_compatible_text_node_type = property_definition.node_types.find(
543
- (type) => session.kind({ type }) === 'text'
544
- );
652
+ if (property_definition?.type !== 'node_array') return false;
653
+
654
+ const default_text_node_type = get_default_text_node(property_definition, session.schema);
655
+ const target_text_property_name = get_text_property_name(default_text_node_type, session.schema);
545
656
 
546
657
  const nodes_to_insert = [];
547
658
  let rejected = false;
548
659
  for (const node_id of main_nodes) {
549
660
  const node = nodes[node_id];
661
+ if (!node) {
662
+ rejected = true;
663
+ break;
664
+ }
665
+
550
666
  if (!property_definition.node_types.includes(node.type)) {
551
- // Incompatible node type detected
552
- if (session.kind(node) === 'text' && first_compatible_text_node_type) {
667
+ const text_content = get_text_content(node, session.schema);
668
+ if (
669
+ is_text_like_node_payload(node, session.schema) &&
670
+ default_text_node_type &&
671
+ target_text_property_name
672
+ ) {
553
673
  const new_node_id = tr.build('the_node', {
554
674
  the_node: {
555
675
  id: 'the_node',
556
- type: first_compatible_text_node_type,
557
- content: node.content
676
+ type: default_text_node_type,
677
+ [target_text_property_name]: text_content || { text: '', annotations: [] }
558
678
  }
559
679
  });
560
680
  nodes_to_insert.push(new_node_id);
561
681
  } else {
562
- console.log(
563
- `rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
564
- );
565
682
  rejected = true;
566
683
  break;
567
684
  }
@@ -575,12 +692,12 @@ ${fallback_html}`;
575
692
  tr.insert_nodes(nodes_to_insert);
576
693
  session.apply(tr);
577
694
  return true;
578
- } else {
579
- if (tr.selection.path.length >= 2) {
580
- const next_node_insert_caret = session.get_next_node_insert_caret(tr.selection);
581
- if (next_node_insert_caret) {
582
- try_node_paste(pasted_json, next_node_insert_caret);
583
- }
695
+ }
696
+
697
+ if (tr.selection.path.length >= 2) {
698
+ const next_node_insert_caret = session.get_next_node_insert_caret(tr.selection);
699
+ if (next_node_insert_caret) {
700
+ return try_node_paste(pasted_json, next_node_insert_caret);
584
701
  }
585
702
  }
586
703
  return false;
@@ -599,7 +716,11 @@ ${fallback_html}`;
599
716
  // NOTE: For some reason, await navigator.clipboard.read()
600
717
  const clipboard_items = event.clipboardData?.items || [];
601
718
  for (const item of clipboard_items || []) {
602
- if (item.type.startsWith('image/') || item.type.startsWith('video/') || item.type.startsWith('audio/')) {
719
+ if (
720
+ item.type.startsWith('image/') ||
721
+ item.type.startsWith('video/') ||
722
+ item.type.startsWith('audio/')
723
+ ) {
603
724
  const blob = item.getAsFile();
604
725
  const data_url = URL.createObjectURL(blob);
605
726
  pasted_media.push({
@@ -612,9 +733,9 @@ ${fallback_html}`;
612
733
  }
613
734
 
614
735
  if (pasted_media.length > 0) {
615
- const handle_media_paste = session.config.handle_media_paste || session.config.handle_image_paste
736
+ const handle_media_paste =
737
+ session.config.handle_media_paste || session.config.handle_image_paste;
616
738
  pasted_json = await handle_media_paste(session, pasted_media);
617
- console.log('pasted_json_after_media_paste', pasted_json);
618
739
  // NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
619
740
  // handled inside handle_media_paste already.
620
741
  if (!pasted_json) return;
@@ -625,8 +746,8 @@ ${fallback_html}`;
625
746
  if (html_content) {
626
747
  pasted_json = extract_svedit_data_from_html(html_content);
627
748
  }
628
- } catch (e) {
629
- console.log('No HTML format available or failed to extract svedit data:', e);
749
+ } catch {
750
+ // No HTML format available or failed to extract svedit data
630
751
  pasted_json = undefined;
631
752
  }
632
753
 
@@ -636,28 +757,51 @@ ${fallback_html}`;
636
757
  console.error('Failed to paste any content:', e);
637
758
  }
638
759
 
639
- // Try to contruct a node payload from plain text when applicable
760
+ // Try to construct a node payload from plain text when applicable
640
761
  if (!pasted_json && typeof plain_text === 'string') {
641
- const plain_text_fragments = plain_text
642
- .split('\n\n')
643
- .map((fragment) => fragment.trim())
644
- .filter(Boolean);
645
- if (plain_text_fragments.length > 1) {
646
- pasted_json = {
647
- main_nodes: [],
648
- nodes: []
649
- };
650
- for (let i = 0; i < plain_text_fragments.length; i++) {
651
- const fragment = plain_text_fragments[i];
652
- pasted_json.nodes['fragment_' + i] = {
653
- id: 'fragment_' + i,
654
- type: 'text',
655
- content: {
656
- text: fragment,
657
- annotations: []
762
+ plain_text = normalize_line_endings(plain_text);
763
+ plain_text = dedent_plain_text(plain_text);
764
+ const plain_text_fragments = split_plain_text_paragraphs(plain_text);
765
+ const has_multiple_paragraphs = plain_text_fragments.length > 1;
766
+
767
+ if (session.selection?.type === 'text') {
768
+ const property_definition = session.inspect(session.selection.path);
769
+ if (property_definition?.type === 'annotated_text' && !property_definition.allow_newlines) {
770
+ plain_text = normalize_plain_text_for_single_line_property(plain_text);
771
+ }
772
+
773
+ const owner_node = session.get(session.selection.path.slice(0, -1));
774
+ const owner_is_text_node = owner_node && session.kind(owner_node) === 'text';
775
+
776
+ if (owner_is_text_node && has_multiple_paragraphs) {
777
+ const node_array_property_definition = session.inspect(
778
+ session.selection.path.slice(0, -2)
779
+ );
780
+ const default_text_node_type = get_default_text_node(node_array_property_definition, session.schema);
781
+ const node_insert_caret = get_node_insert_caret_after_text_selection(session.selection);
782
+ const plain_text_nodes_payload = create_plain_text_nodes_payload(
783
+ plain_text_fragments,
784
+ default_text_node_type,
785
+ session.schema
786
+ );
787
+
788
+ if (node_insert_caret && plain_text_nodes_payload) {
789
+ const did_paste_nodes = try_node_paste(plain_text_nodes_payload, node_insert_caret);
790
+ if (did_paste_nodes) {
791
+ return;
658
792
  }
659
- };
660
- pasted_json.main_nodes.push('fragment_' + i);
793
+ }
794
+ }
795
+ } else {
796
+ const target_node_insert_caret = get_target_node_insert_caret(session.selection);
797
+ if (target_node_insert_caret) {
798
+ const node_array_property_definition = session.inspect(target_node_insert_caret.path);
799
+ const default_text_node_type = get_default_text_node(node_array_property_definition, session.schema);
800
+ pasted_json = create_plain_text_nodes_payload(
801
+ plain_text_fragments,
802
+ default_text_node_type,
803
+ session.schema
804
+ );
661
805
  }
662
806
  }
663
807
  }
@@ -695,46 +839,57 @@ ${fallback_html}`;
695
839
  } else if (
696
840
  session.selection?.type === 'text' &&
697
841
  pasted_json?.main_nodes?.length === 1 &&
698
- session.kind(pasted_json?.nodes[pasted_json.main_nodes[0]]) === 'text'
842
+ is_text_like_node_payload(pasted_json?.nodes[pasted_json.main_nodes[0]], session.schema)
699
843
  ) {
700
844
  // Paste a single text node, at a text caret
701
- const text_property = pasted_json.nodes[pasted_json.main_nodes[0]].content;
702
- session.apply(
703
- session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
845
+ const text_property = get_text_content(
846
+ pasted_json.nodes[pasted_json.main_nodes[0]],
847
+ session.schema
704
848
  );
849
+ if (text_property) {
850
+ session.apply(
851
+ session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
852
+ );
853
+ }
705
854
  } else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
706
855
  // Paste nodes at a text or property selection by finding the next valid insert caret
707
- const next_node_insert_caret = session.get_next_node_insert_caret(session.selection);
708
- try_node_paste(pasted_json, next_node_insert_caret);
856
+ const target_node_insert_caret = get_target_node_insert_caret(session.selection);
857
+ if (target_node_insert_caret) {
858
+ try_node_paste(pasted_json, target_node_insert_caret);
859
+ }
709
860
  } else if (typeof plain_text === 'string') {
710
861
  // External paste: Fallback to plain text when no svedit data is found
711
- session.apply(session.tr.insert_text(plain_text.trim()));
712
- } else {
713
- console.log('Could not paste.');
862
+ session.apply(session.tr.insert_text(plain_text));
714
863
  }
715
864
  }
716
865
 
717
- function render_selection() {
866
+ function render_selection(dom_driven = false) {
718
867
  const selection = /** @type {Selection} */ (session.selection);
719
868
 
720
869
  if (!selection) {
721
870
  // No model selection -> just leave things as they are
722
- let dom_selection = window.getSelection();
723
- dom_selection.removeAllRanges();
871
+ // NOTE: removeAllRanges() makes the document lose selection on
872
+ // refocus of the window, hence I disable it for now.
873
+ // let dom_selection = window.getSelection();
874
+ // dom_selection.removeAllRanges();
724
875
  return;
725
876
  }
726
877
 
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;
878
+ // DOM-driven updates already reflect the new selection — skip
879
+ // the rerender when DOM matches model. Model-driven updates
880
+ // (insert/undo) can have DOM matching by coincidence (Svelte
881
+ // reuses gap elements with shifted data-gap-offset), so for those
882
+ // we always rerender to scroll the cursor into view.
883
+ const is_empty_text_selection =
884
+ selection.type === 'text' && session.get(selection.path).text.length === 0;
885
+ if (dom_driven && !is_empty_text_selection) {
886
+ const prev_selection = __get_selection_from_dom();
887
+ if (
888
+ JSON.stringify(selection) === JSON.stringify(prev_selection) &&
889
+ canvas_el?.contains(document.activeElement)
890
+ ) {
891
+ return;
892
+ }
738
893
  }
739
894
 
740
895
  if (selection?.type === 'text') {
@@ -744,7 +899,7 @@ ${fallback_html}`;
744
899
  } else if (selection?.type === 'property') {
745
900
  __render_property_selection();
746
901
  } else {
747
- console.log('unsupported selection', $state.snapshot(selection));
902
+ console.warn('unsupported selection', $state.snapshot(selection));
748
903
  }
749
904
  }
750
905
 
@@ -753,18 +908,12 @@ ${fallback_html}`;
753
908
  // Use flushSync so highlight spans are removed from the DOM
754
909
  // immediately, before the browser processes the click's selection.
755
910
  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
911
  canvas_focused = true;
763
912
  });
764
913
  key_mapper?.push_scope(session.keymap);
765
914
  }
766
915
 
767
- // Handle blur - pop document's keymap from stack
916
+ // Handle blur - pop document's keymap from stack.
768
917
  function handle_canvas_blur() {
769
918
  // Use flushSync so the selection highlight span (with its CSS anchor)
770
919
  // is in the DOM immediately, before any popover/dialog tries to
@@ -794,11 +943,13 @@ ${fallback_html}`;
794
943
  function __resolve_node_from_gap(el) {
795
944
  const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
796
945
  if (!gap) return null;
797
- const array_path = gap.dataset.gapArrayPath;
946
+ const array_path = deserialize_path(gap.dataset.gapArrayPath);
798
947
  const offset = parseInt(gap.dataset.gapOffset, 10);
799
948
  const node_idx = offset > 0 ? offset - 1 : 0;
800
949
  return /** @type {HTMLElement | null} */ (
801
- canvas_el.querySelector(`[data-path="${array_path}.${node_idx}"][data-type="node"]`)
950
+ canvas_el.querySelector(
951
+ `[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`
952
+ )
802
953
  );
803
954
  }
804
955
 
@@ -821,13 +972,34 @@ ${fallback_html}`;
821
972
  if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
822
973
  if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
823
974
 
975
+ // EDGE CASE: Collapsed selection inside an empty node placeholder.
976
+ // Firefox can place the DOM selection on the placeholder node itself,
977
+ // which otherwise looks like selecting node index 0 in an empty array.
978
+ const focus_empty_placeholder = /** @type {HTMLElement | null} */ (
979
+ focus_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
980
+ );
981
+ const anchor_empty_placeholder = /** @type {HTMLElement | null} */ (
982
+ anchor_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
983
+ );
984
+ if (focus_empty_placeholder && focus_empty_placeholder === anchor_empty_placeholder) {
985
+ const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
986
+ const array_path = empty_placeholder_path.slice(0, -1);
987
+ const node_array = session.get(array_path);
988
+ if (Array.isArray(node_array) && node_array.length === 0) {
989
+ return {
990
+ type: 'node',
991
+ path: array_path,
992
+ anchor_offset: 0,
993
+ focus_offset: 0
994
+ };
995
+ }
996
+ }
997
+
824
998
  // EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
825
999
  // Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
826
- const gap_el = /** @type {HTMLElement | null} */ (
827
- focus_node.closest('[data-gap-array-path]')
828
- );
1000
+ const gap_el = /** @type {HTMLElement | null} */ (focus_node.closest('[data-gap-array-path]'));
829
1001
  if (gap_el && focus_node === anchor_node) {
830
- const array_path = gap_el.dataset.gapArrayPath.split('.');
1002
+ const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
831
1003
  const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
832
1004
  return {
833
1005
  type: 'node',
@@ -837,16 +1009,18 @@ ${fallback_html}`;
837
1009
  };
838
1010
  }
839
1011
 
840
- let focus_root = __resolve_node_from_gap(focus_node)
841
- ?? /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
1012
+ let focus_root =
1013
+ __resolve_node_from_gap(focus_node) ??
1014
+ /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
842
1015
  if (!focus_root) return null;
843
1016
 
844
- let anchor_root = __resolve_node_from_gap(anchor_node)
845
- ?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
1017
+ let anchor_root =
1018
+ __resolve_node_from_gap(anchor_node) ??
1019
+ /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
846
1020
  if (!anchor_root) return null;
847
1021
 
848
- let focus_root_path = focus_root.dataset.path.split('.');
849
- let anchor_root_path = anchor_root.dataset.path.split('.');
1022
+ let focus_root_path = deserialize_path(focus_root.dataset.path);
1023
+ let anchor_root_path = deserialize_path(anchor_root.dataset.path);
850
1024
  let focus_node_depth = focus_root_path.length;
851
1025
  let anchor_node_depth = anchor_root_path.length;
852
1026
 
@@ -856,30 +1030,28 @@ ${fallback_html}`;
856
1030
  // onto its index within that array.
857
1031
  let focus_walked_up = false;
858
1032
  let anchor_walked_up = false;
859
- while (
860
- focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
861
- ) {
1033
+ while (!paths_equal(focus_root_path.slice(0, -1), anchor_root_path.slice(0, -1))) {
862
1034
  if (focus_root_path.length > anchor_root_path.length) {
863
1035
  // Focus is deeper — walk it up
864
1036
  focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
865
1037
  if (!focus_root) return null;
866
- focus_root_path = focus_root.dataset.path.split('.');
1038
+ focus_root_path = deserialize_path(focus_root.dataset.path);
867
1039
  focus_walked_up = true;
868
1040
  } else if (anchor_root_path.length > focus_root_path.length) {
869
1041
  // Anchor is deeper — walk it up
870
1042
  anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
871
1043
  if (!anchor_root) return null;
872
- anchor_root_path = anchor_root.dataset.path.split('.');
1044
+ anchor_root_path = deserialize_path(anchor_root.dataset.path);
873
1045
  anchor_walked_up = true;
874
1046
  } else {
875
1047
  // Same depth but different node arrays — walk both up
876
1048
  focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
877
1049
  if (!focus_root) return null;
878
- focus_root_path = focus_root.dataset.path.split('.');
1050
+ focus_root_path = deserialize_path(focus_root.dataset.path);
879
1051
  focus_walked_up = true;
880
1052
  anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
881
1053
  if (!anchor_root) return null;
882
- anchor_root_path = anchor_root.dataset.path.split('.');
1054
+ anchor_root_path = deserialize_path(anchor_root.dataset.path);
883
1055
  anchor_walked_up = true;
884
1056
  }
885
1057
  }
@@ -893,16 +1065,13 @@ ${fallback_html}`;
893
1065
  const parent_property = session.inspect(parent_array_path);
894
1066
  if (!parent_property || parent_property.type !== 'node_array') return null;
895
1067
 
896
- let anchor_offset = parseInt(anchor_root_path.at(-1));
897
- let focus_offset = parseInt(focus_root_path.at(-1));
1068
+ let anchor_offset = Number(anchor_root_path.at(-1));
1069
+ let focus_offset = Number(focus_root_path.at(-1));
898
1070
 
899
1071
  // Check if it's a backwards selection
900
1072
  const is_backwards = __is_dom_selection_backwards();
901
- if (is_backwards) {
902
- anchor_offset += 1;
903
- } else {
904
- focus_offset += 1;
905
- }
1073
+ if (is_backwards) anchor_offset += 1;
1074
+ else focus_offset += 1;
906
1075
 
907
1076
  // EDGE CASE: Exclude first node when anchor_node is a gap-after
908
1077
  // in a non-collapsed forward selection.
@@ -961,7 +1130,7 @@ ${fallback_html}`;
961
1130
  if (focus_root === anchor_root) {
962
1131
  return {
963
1132
  type: 'property',
964
- path: focus_root.dataset.path.split('.')
1133
+ path: deserialize_path(focus_root.dataset.path)
965
1134
  };
966
1135
  }
967
1136
  return null;
@@ -1004,19 +1173,30 @@ ${fallback_html}`;
1004
1173
  range = dom_selection.getRangeAt(0);
1005
1174
  }
1006
1175
 
1176
+ function get_text_root(node) {
1177
+ if (!node) return null;
1178
+
1179
+ if (node.nodeType === Node.ELEMENT_NODE) {
1180
+ return /** @type {HTMLElement | null} */ (
1181
+ node.closest?.('[data-path][data-type="text"]') ?? null
1182
+ );
1183
+ }
1184
+
1185
+ return /** @type {HTMLElement | null} */ (
1186
+ node.parentElement?.closest('[data-path][data-type="text"]') ?? null
1187
+ );
1188
+ }
1189
+
1007
1190
  let focus_root, anchor_root;
1008
1191
 
1009
1192
  if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
1010
1193
  // 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
1194
  focus_root = anchor_root = focus_node;
1012
1195
  } else {
1013
- focus_root = /** @type {HTMLElement} */ (
1014
- focus_node.parentElement?.closest('[data-path][data-type="text"]')
1015
- );
1196
+ focus_root = get_text_root(focus_node);
1016
1197
  if (!focus_root) return null;
1017
- anchor_root = /** @type {HTMLElement} */ (
1018
- anchor_node.parentElement?.closest('[data-path][data-type="text"]')
1019
- );
1198
+
1199
+ anchor_root = get_text_root(anchor_node);
1020
1200
  if (!anchor_root) return null;
1021
1201
  }
1022
1202
 
@@ -1024,7 +1204,7 @@ ${fallback_html}`;
1024
1204
  return null;
1025
1205
  }
1026
1206
 
1027
- const path = focus_root.dataset.path.split('.');
1207
+ const path = deserialize_path(focus_root.dataset.path);
1028
1208
 
1029
1209
  if (!path) return null;
1030
1210
 
@@ -1033,12 +1213,14 @@ ${fallback_html}`;
1033
1213
  // AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
1034
1214
  // When the user places their caret after this <br>, focusNode is the container
1035
1215
  // 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);
1216
+ // We detect this and return the current DOM text length instead.
1217
+ // During compositionend, the browser has already inserted the composed
1218
+ // character into the DOM, while the Svedit model still has the old text.
1219
+ const dom_text_length = get_char_length(focus_root.textContent ?? '');
1039
1220
  const child_nodes = focus_root.childNodes;
1040
1221
 
1041
1222
  if (
1223
+ range.collapsed &&
1042
1224
  focus_node === anchor_node &&
1043
1225
  focus_node === focus_root &&
1044
1226
  focus_root.dataset?.type === 'text' &&
@@ -1046,7 +1228,10 @@ ${fallback_html}`;
1046
1228
  ) {
1047
1229
  // Find the last non-comment child node (comments are inserted by Svelte)
1048
1230
  let last_element_index = child_nodes.length - 1;
1049
- while (last_element_index >= 0 && child_nodes[last_element_index].nodeType === Node.COMMENT_NODE) {
1231
+ while (
1232
+ last_element_index >= 0 &&
1233
+ child_nodes[last_element_index].nodeType === Node.COMMENT_NODE
1234
+ ) {
1050
1235
  last_element_index--;
1051
1236
  }
1052
1237
 
@@ -1059,43 +1244,21 @@ ${fallback_html}`;
1059
1244
  return {
1060
1245
  type: 'text',
1061
1246
  path,
1062
- anchor_offset: text_length,
1063
- focus_offset: text_length
1247
+ anchor_offset: dom_text_length,
1248
+ focus_offset: dom_text_length
1064
1249
  };
1065
1250
  }
1066
1251
  }
1067
1252
 
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;
1253
+ function get_text_offset(container, offset) {
1254
+ const offset_range = window.document.createRange();
1255
+ offset_range.setStart(focus_root, 0);
1256
+ offset_range.setEnd(container, offset);
1257
+ return get_char_length(offset_range.toString());
1093
1258
  }
1094
1259
 
1095
- // Process nodes to find offsets
1096
- for (const childNode of focus_root.childNodes) {
1097
- if (processNode(childNode)) break;
1098
- }
1260
+ const start_offset = get_text_offset(range.startContainer, range.startOffset);
1261
+ const end_offset = get_text_offset(range.endContainer, range.endOffset);
1099
1262
 
1100
1263
  // Check if it's a backward selection
1101
1264
  // When range is provided, we can't detect backward selection from the range alone
@@ -1115,19 +1278,34 @@ ${fallback_html}`;
1115
1278
  }
1116
1279
 
1117
1280
  function __get_node_element(node_array_path, node_offset) {
1281
+ if (!canvas_el) return null;
1118
1282
  return canvas_el.querySelector(
1119
- `[data-path="${node_array_path}.${node_offset}"][data-type="node"]`
1283
+ `[data-path="${serialize_path([...node_array_path, node_offset])}"][data-type="node"]`
1120
1284
  );
1121
1285
  }
1122
1286
 
1287
+ /**
1288
+ * True when any part of the element's border box overlaps the window
1289
+ * viewport. Used to keep node-selection re-renders from scrolling a
1290
+ * cursor that is already on screen. Tests the window viewport, so a
1291
+ * node clipped only by an inner scroll container still counts visible.
1292
+ * @param {Element | null | undefined} el
1293
+ */
1294
+ function __intersects_viewport(el) {
1295
+ if (!el) return false;
1296
+ const r = el.getBoundingClientRect();
1297
+ return r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
1298
+ }
1299
+
1123
1300
  function __render_node_selection() {
1124
1301
  const selection = /** @type {NodeSelection} */ (session.selection);
1125
- const node_array_path = selection.path.join('.');
1126
- const is_collapsed = selection.anchor_offset === selection.focus_offset;
1302
+ const node_array_path = selection.path;
1303
+ const node_array_path_str = serialize_path(node_array_path);
1304
+ const is_collapsed = is_selection_collapsed(selection);
1127
1305
  const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
1128
1306
 
1129
1307
  const node_array_el = canvas_el.querySelector(
1130
- `[data-path="${node_array_path}"][data-type="node_array"]`
1308
+ `[data-path="${node_array_path_str}"][data-type="node_array"]`
1131
1309
  );
1132
1310
  if (!node_array_el) return;
1133
1311
 
@@ -1135,7 +1313,7 @@ ${fallback_html}`;
1135
1313
  const range = window.document.createRange();
1136
1314
 
1137
1315
  const gap_selector = (offset) =>
1138
- `[data-gap-array-path="${node_array_path}"][data-gap-offset="${offset}"]`;
1316
+ `[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
1139
1317
 
1140
1318
  if (is_collapsed) {
1141
1319
  const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
@@ -1168,24 +1346,59 @@ ${fallback_html}`;
1168
1346
  }
1169
1347
  }
1170
1348
 
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
- }
1349
+ // Scroll the cursor into view, but only when it is genuinely
1350
+ // off-screen. This runs on every node-selection re-render — a
1351
+ // transaction (type/layout change), select-parent, window refocus
1352
+ // — so an unconditional scroll would yank the viewport on each of
1353
+ // them. cursor_offset is a gap offset and the gap has no box of
1354
+ // its own, so visibility is judged from the nodes flanking it.
1355
+ setTimeout(() => {
1356
+ // Collapsed: anchor === focus, so focus is the gap offset.
1357
+ // Range: cursor sits at the focus end (anchor when backward).
1358
+ const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
1359
+ const array_length = session.get(node_array_path).length;
1360
+
1361
+ // If either node flanking the cursor is already (even
1362
+ // partially) on screen, the cursor is visible — keep the
1363
+ // viewport stable and scroll nothing. node_before is null at
1364
+ // offset 0: cursor_offset - 1 would be -1, and serialize_path
1365
+ // rejects a negative index.
1366
+ const node_before =
1367
+ cursor_offset > 0 ? __get_node_element(node_array_path, cursor_offset - 1) : null;
1368
+ const node_after = __get_node_element(node_array_path, cursor_offset);
1369
+ if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
1370
+
1371
+ if (cursor_offset === 0) {
1372
+ node_array_el.scrollLeft = 0;
1373
+ node_array_el.scrollTop = 0;
1374
+ return;
1375
+ }
1376
+ if (cursor_offset >= array_length) {
1377
+ const max_left = Math.max(0, node_array_el.scrollWidth - node_array_el.clientWidth);
1378
+ const max_top = Math.max(0, node_array_el.scrollHeight - node_array_el.clientHeight);
1379
+ node_array_el.scrollLeft = max_left;
1380
+ node_array_el.scrollTop = max_top;
1381
+ if (max_left === 0 && max_top === 0) {
1382
+ node_before?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1383
+ }
1384
+ return;
1385
+ }
1386
+ // A range selection's selected node IS node_before; scrolling
1387
+ // node_after would reveal the following node and leave the
1388
+ // selection itself off-screen. For a collapsed caret node_after's
1389
+ // leading edge is the cursor, so that stays the right target.
1390
+ const target = is_collapsed ? node_after : node_before;
1391
+ target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1392
+ }, 0);
1181
1393
  }
1182
1394
 
1183
1395
  function __render_property_selection() {
1184
1396
  const selection = session.selection;
1185
1397
  // The element that holds the property
1186
1398
  const el = canvas_el.querySelector(
1187
- `[data-path="${selection.path.join('.')}"][data-type="property"]`
1399
+ `[data-path="${serialize_path(selection.path)}"][data-type="property"]`
1188
1400
  );
1401
+
1189
1402
  const gap_selectable = el.querySelector('.svedit-selectable');
1190
1403
  const range = window.document.createRange();
1191
1404
  const dom_selection = window.getSelection();
@@ -1198,10 +1411,7 @@ ${fallback_html}`;
1198
1411
 
1199
1412
  // Scroll the selection into view
1200
1413
  setTimeout(() => {
1201
- const selectedElement = dom_selection.focusNode.parentElement;
1202
- if (selectedElement) {
1203
- selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1204
- }
1414
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1205
1415
  }, 0);
1206
1416
  }
1207
1417
 
@@ -1209,7 +1419,7 @@ ${fallback_html}`;
1209
1419
  const selection = /** @type {any} */ (session.selection);
1210
1420
  // The element that holds the annotated string
1211
1421
  const el = canvas_el.querySelector(
1212
- `[data-path="${selection.path.join('.')}"][data-type="text"]`
1422
+ `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1213
1423
  );
1214
1424
  const empty_text = session.get(selection.path).text.length === 0;
1215
1425
  const dom_selection = window.getSelection();
@@ -1296,7 +1506,6 @@ ${fallback_html}`;
1296
1506
  focus_node,
1297
1507
  focus_node_offset
1298
1508
  );
1299
- el.focus(); // needed?
1300
1509
 
1301
1510
  // Scroll the selection into view
1302
1511
  setTimeout(() => {
@@ -1311,6 +1520,52 @@ ${fallback_html}`;
1311
1520
  // Utils
1312
1521
  // --------------------------
1313
1522
 
1523
+ function __remove_native_composition_text(selection, inserted_text) {
1524
+ if (!selection || selection.type !== 'text' || !inserted_text) return;
1525
+
1526
+ const text_el = canvas_el.querySelector(
1527
+ `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1528
+ );
1529
+ if (!text_el) return;
1530
+
1531
+ const model_text = session.get(selection.path).text;
1532
+ if ((text_el.textContent ?? '') === model_text) return;
1533
+
1534
+ let current_offset = 0;
1535
+ function get_dom_text_position(root, target_offset) {
1536
+ for (const node of root.childNodes) {
1537
+ if (node.nodeType === Node.TEXT_NODE) {
1538
+ const node_text = node.textContent ?? '';
1539
+ const node_length = get_char_length(node_text);
1540
+ if (current_offset + node_length >= target_offset) {
1541
+ return {
1542
+ node,
1543
+ offset: char_to_utf16_offset(node_text, target_offset - current_offset)
1544
+ };
1545
+ }
1546
+ current_offset += node_length;
1547
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
1548
+ const position = get_dom_text_position(node, target_offset);
1549
+ if (position) return position;
1550
+ }
1551
+ }
1552
+ return null;
1553
+ }
1554
+
1555
+ const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
1556
+ const end_offset = start_offset + get_char_length(inserted_text);
1557
+ current_offset = 0;
1558
+ const start_position = get_dom_text_position(text_el, start_offset);
1559
+ current_offset = 0;
1560
+ const end_position = get_dom_text_position(text_el, end_offset);
1561
+ if (!start_position || !end_position) return;
1562
+
1563
+ const range = window.document.createRange();
1564
+ range.setStart(start_position.node, start_position.offset);
1565
+ range.setEnd(end_position.node, end_position.offset);
1566
+ range.deleteContents();
1567
+ }
1568
+
1314
1569
  function __is_dom_selection_backwards() {
1315
1570
  const dom_selection = window.getSelection();
1316
1571
 
@@ -1333,12 +1588,17 @@ ${fallback_html}`;
1333
1588
  return comparisonRange.collapsed;
1334
1589
  }
1335
1590
 
1336
- // Whenever the model selection changes, render the selection
1591
+ // Whenever the model selection changes, render the selection.
1337
1592
  // 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)
1593
+ // (e.g., when a dialog is open and selection highlight fragments re-render).
1594
+ // Consume the dom-driven flag at the top so it never carries stale
1595
+ // state into the next change, even when we early-return.
1339
1596
  $effect(() => {
1597
+ session.selection;
1598
+ const dom_driven = selection_source_is_dom;
1599
+ selection_source_is_dom = false;
1340
1600
  if (!canvas_focused) return;
1341
- render_selection();
1601
+ render_selection(dom_driven);
1342
1602
  });
1343
1603
  </script>
1344
1604
 
@@ -1349,10 +1609,20 @@ ${fallback_html}`;
1349
1609
  <svelte:document {onselectionchange} {oncut} {oncopy} {onpaste} />
1350
1610
 
1351
1611
  <!-- TODO: move oncut/copy/paste handlers inside .svedit -->
1352
- <div class="svedit">
1612
+ <div class="svedit" class:editable>
1353
1613
  <!-- Overlays must be before canvas so they initialize first. -->
1354
- <NodeSelectionMarkers />
1614
+ {#if editable}<NodeSelectionMarkers />{/if}
1355
1615
  {#if Overlays}<Overlays />{/if}
1616
+ <!--
1617
+ inputmode is derived from the model selection: when a custom property
1618
+ (image, embed, etc.) is selected, set inputmode="none" so iOS does
1619
+ not open the virtual keyboard. For any other selection (text, node,
1620
+ none), allow the keyboard via inputmode="text". The browser's native
1621
+ selection-change flow updates session.selection on tap, which flips
1622
+ this attribute before iOS decides whether to show the keyboard — no
1623
+ imperative state tracking, drag detection, or click/pointerdown
1624
+ handlers are required for keyboard suppression.
1625
+ -->
1356
1626
  <div
1357
1627
  class="svedit-canvas {css_class}"
1358
1628
  class:hide-selection={session.selection?.type === 'node'}
@@ -1365,6 +1635,7 @@ ${fallback_html}`;
1365
1635
  {oncompositionend}
1366
1636
  onfocus={handle_canvas_focus}
1367
1637
  onblur={handle_canvas_blur}
1638
+ inputmode={session.selection?.type === 'text' ? 'text' : 'none'}
1368
1639
  contenteditable={editable ? 'true' : 'false'}
1369
1640
  tabindex="-1"
1370
1641
  {autocapitalize}
@@ -1385,7 +1656,7 @@ ${fallback_html}`;
1385
1656
 
1386
1657
  <style>
1387
1658
  .svedit-canvas {
1388
- caret-color: var(--svedit-editing-stroke);
1659
+ caret-color: var(--svedit-caret-color);
1389
1660
  caret-shape: bar;
1390
1661
  /* Default to vertical/ column flow with: --row: 0; (the most common case)
1391
1662
  Prevents silent failures when developers forget to set the row property in their top level node component.
@@ -1396,7 +1667,11 @@ ${fallback_html}`;
1396
1667
  }
1397
1668
  }
1398
1669
 
1399
- .svedit-canvas :global(::selection) {
1670
+ /* Selection paint — wrapped in :where() so consumers can override with
1671
+ a plain `::selection` rule. Svelte still adds the scope hash inside
1672
+ the :where(), but the wrapper zeroes its specificity contribution.
1673
+ Final specificity is just (0,0,1), trivially beatable. */
1674
+ :where(.svedit-canvas) :global(::selection) {
1400
1675
  background: var(--svedit-editing-fill);
1401
1676
  }
1402
1677