svedit 0.11.1 → 0.13.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 (93) hide show
  1. package/README.md +260 -82
  2. package/dist/Command.svelte.d.ts +54 -29
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +286 -283
  5. package/dist/CustomProperty.svelte +3 -5
  6. package/dist/CustomProperty.svelte.d.ts +3 -6
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/KeyMapper.svelte.d.ts +19 -6
  9. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  10. package/dist/KeyMapper.svelte.js +95 -112
  11. package/dist/Node.svelte +40 -9
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +142 -18
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +1 -1
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +60 -12
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +19 -10
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +20 -6
  27. package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
  28. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  29. package/dist/Session.svelte.d.ts +85 -116
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +543 -571
  32. package/dist/Svedit.svelte +243 -216
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  36. package/dist/TextProperty.svelte.d.ts +5 -0
  37. package/dist/TextProperty.svelte.d.ts.map +1 -0
  38. package/dist/Transaction.svelte.d.ts +101 -126
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +843 -827
  41. package/dist/UnknownNode.svelte +4 -3
  42. package/dist/UnknownNode.svelte.d.ts +5 -9
  43. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  44. package/dist/doc_utils.d.ts +109 -122
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +726 -698
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -18
  50. package/dist/node_visibility.svelte.d.ts +132 -5
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +535 -642
  53. package/dist/paste_utils.d.ts +11 -43
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -149
  56. package/dist/transforms.svelte.d.ts +9 -7
  57. package/dist/transforms.svelte.d.ts.map +1 -1
  58. package/dist/transforms.svelte.js +126 -152
  59. package/dist/types.d.ts +329 -291
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +79 -95
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +392 -305
  65. package/package.json +69 -67
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
  67. package/src/lib/CustomProperty.svelte +3 -5
  68. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  69. package/src/lib/Node.svelte +40 -9
  70. package/src/lib/NodeArrayProperty.svelte +142 -18
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +60 -12
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/Session.svelte.ts +656 -0
  76. package/src/lib/Svedit.svelte +243 -216
  77. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  78. package/src/lib/Transaction.svelte.ts +1020 -0
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/doc_utils.ts +1120 -0
  81. package/src/lib/{index.js → index.ts} +48 -1
  82. package/src/lib/node_visibility.svelte.ts +658 -0
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
  85. package/src/lib/{types.d.ts → types.ts} +250 -103
  86. package/src/lib/utils.ts +660 -0
  87. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  88. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
  89. package/src/lib/Session.svelte.js +0 -575
  90. package/src/lib/Transaction.svelte.js +0 -846
  91. package/src/lib/doc_utils.js +0 -807
  92. package/src/lib/node_visibility.svelte.js +0 -718
  93. package/src/lib/utils.js +0 -465
@@ -1,4 +1,4 @@
1
- <script>
1
+ <script lang="ts" generics="S extends DocumentSchema">
2
2
  import { flushSync, getContext, setContext } from 'svelte';
3
3
  import {
4
4
  get_char_length,
@@ -24,17 +24,21 @@
24
24
  import './styles/svedit-colors.css';
25
25
  import './styles/svedit-animations.css';
26
26
 
27
- /** @import {
28
- * SveditProps,
29
- * Selection,
30
- * TextSelection,
31
- * NodeSelection,
32
- * PropertySelection,
33
- * NodeId
34
- * } from './types.d.ts';
35
- */
27
+ import type {
28
+ DocumentSchema,
29
+ SveditProps,
30
+ Selection,
31
+ NodeSelection,
32
+ TextSelection,
33
+ PropertySelection,
34
+ DocumentNode,
35
+ DocumentPath,
36
+ DynamicRecord,
37
+ NodeHtmlExporter
38
+ } from './types.js';
39
+ import type { KeyMapper } from './KeyMapper.svelte.js';
40
+ import type Session from './Session.svelte.js';
36
41
 
37
- /** @type {SveditProps} */
38
42
  let {
39
43
  session,
40
44
  editable = $bindable(false),
@@ -42,9 +46,9 @@
42
46
  class: css_class,
43
47
  autocapitalize = 'on',
44
48
  spellcheck = 'true'
45
- } = $props();
49
+ }: SveditProps<S> = $props();
46
50
 
47
- let canvas_el;
51
+ let canvas_el: HTMLElement | undefined;
48
52
  let root_node = $derived(session.get(path));
49
53
  let Overlays = $derived(session.config.system_components?.overlays);
50
54
  let NodeSelectionMarkers = $derived(
@@ -54,7 +58,13 @@
54
58
 
55
59
  let is_composing = $state(false);
56
60
  let canvas_focused = $state(false);
57
- let before_composition_selection = null;
61
+ let before_composition_selection: TextSelection | null = null;
62
+ // Selection restoration and selection revealing are separate concerns.
63
+ // DOM changes (for example, toggling an annotation) can require rebuilding
64
+ // the native selection even when the logical selection did not move. Keep
65
+ // the last selection rendered while focused so those rebuilds do not also
66
+ // move the viewport.
67
+ let last_rendered_selection_snapshot: string | null = null;
58
68
  // Set by onselectionchange before it commits a DOM-derived selection
59
69
  // to the model. render_selection consumes-and-clears it to skip
60
70
  // rerender on DOM-driven changes (the DOM is already in place).
@@ -98,8 +108,7 @@
98
108
  function check_duplicate_paths() {
99
109
  if (!canvas_el) return;
100
110
 
101
- /** @type {Record<string, true>} */
102
- const mounted_paths = Object.create(null);
111
+ const mounted_paths: Record<string, true> = Object.create(null);
103
112
 
104
113
  for (const element of canvas_el.querySelectorAll('[data-path]')) {
105
114
  if (element.closest('.svedit-canvas') !== canvas_el) continue;
@@ -117,26 +126,8 @@
117
126
  }
118
127
  }
119
128
 
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
- }
137
-
138
129
  // Get KeyMapper from context (may be undefined if not provided)
139
- const key_mapper = getContext('key_mapper');
130
+ const key_mapper = getContext<KeyMapper>('key_mapper');
140
131
 
141
132
  // Initialize commands and keymap on the session
142
133
  $effect(() => {
@@ -146,7 +137,7 @@
146
137
  /**
147
138
  * @param {InputEvent} event
148
139
  */
149
- async function onbeforeinput(event) {
140
+ async function onbeforeinput(event: InputEvent) {
150
141
  // console.log(`onbeforeinput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
151
142
 
152
143
  if (event.inputType === 'historyUndo' && is_composing) {
@@ -185,21 +176,21 @@
185
176
  }
186
177
 
187
178
  if (event.inputType === 'formatBold' && session.selection?.type === 'text') {
188
- session.apply(session.tr.annotate_text('strong'));
179
+ session.apply(session.tr.toggle_mark('strong'));
189
180
  event.preventDefault();
190
181
  event.stopPropagation();
191
182
  }
192
183
 
193
184
  if (event.inputType === 'formatItalic' && session.selection?.type === 'text') {
194
- session.apply(session.tr.annotate_text('emphasis'));
185
+ session.apply(session.tr.toggle_mark('emphasis'));
195
186
  event.preventDefault();
196
187
  event.stopPropagation();
197
188
  }
198
189
 
199
- // NOTE: underline doesn't make much sense as a semantic annotation,
190
+ // NOTE: underline doesn't make much sense as a semantic mark,
200
191
  // so we rewire `cmd + u` to toggle highlights
201
192
  if (event.inputType === 'formatUnderline' && session.selection?.type === 'text') {
202
- session.apply(session.tr.annotate_text('highlight'));
193
+ session.apply(session.tr.toggle_mark('highlight'));
203
194
  event.preventDefault();
204
195
  event.stopPropagation();
205
196
  }
@@ -277,7 +268,7 @@
277
268
  * Handles composition end events for input methods like dead keys
278
269
  * This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
279
270
  */
280
- function oncompositionend(event) {
271
+ function oncompositionend(event: CompositionEvent) {
281
272
  // console.log('DEBUG: oncompositionend, insert:', event.data, event);
282
273
  if (!canvas_el?.contains(document.activeElement)) return;
283
274
  if (session.selection?.type === 'text') {
@@ -350,7 +341,7 @@
350
341
  * @param {string} fallback_html - HTML for cross-app compatibility
351
342
  * @returns {string} HTML with embedded svedit data
352
343
  */
353
- function create_svedit_html_format(json_data, fallback_html) {
344
+ function create_svedit_html_format(json_data: unknown, fallback_html: string): string {
354
345
  // Use encodeURIComponent to handle Unicode, then base64 encode
355
346
  const json_string = JSON.stringify(json_data);
356
347
  const encoded_data = btoa(encodeURIComponent(json_string));
@@ -367,7 +358,7 @@ ${fallback_html}`;
367
358
  * @param {string} html - HTML content from clipboard
368
359
  * @returns {Object|null} Parsed svedit data or null if not found
369
360
  */
370
- function extract_svedit_data_from_html(html) {
361
+ function extract_svedit_data_from_html(html: string): DynamicRecord | null {
371
362
  const svedit_regex = /data-svedit="([^"]+)"/;
372
363
  const match = html.match(svedit_regex);
373
364
 
@@ -391,21 +382,25 @@ ${fallback_html}`;
391
382
  * @param {Object} node - Node object
392
383
  * @returns {string} HTML representation
393
384
  */
394
- function default_node_html_exporter(node, session, html_exporters) {
385
+ function default_node_html_exporter(
386
+ node: DocumentNode,
387
+ session: Session<S>,
388
+ html_exporters: Record<string, NodeHtmlExporter<S>>
389
+ ): string {
395
390
  let html = '';
396
391
  const node_schema = session.schema[node.type];
397
392
 
398
393
  for (const [prop_name, prop_value] of Object.entries(node)) {
399
394
  if (prop_name === 'id' || prop_name === 'type') continue;
400
395
  const property_definition = node_schema.properties[prop_name];
401
- // Check if this is an annotated_text property (object with text property)
402
- if (property_definition.type === 'annotated_text') {
403
- const text_content = prop_value.text;
396
+ // Check if this is a text property.
397
+ if (property_definition.type === 'text') {
398
+ const text_content = prop_value.content;
404
399
  if (text_content.trim()) {
405
400
  html += `<p>${text_content}</p>`;
406
401
  }
407
402
  } else if (property_definition.type === 'node_array') {
408
- for (const child_id of prop_value) {
403
+ for (const child_id of prop_value.nodes) {
409
404
  const child = session.get(child_id);
410
405
  const child_exporter = html_exporters[child.type] || default_node_html_exporter;
411
406
  html += child_exporter(child, session, html_exporters);
@@ -415,19 +410,19 @@ ${fallback_html}`;
415
410
  return html;
416
411
  }
417
412
 
418
- function default_node_plain_text_exporter(node) {
413
+ function default_node_plain_text_exporter(node: DocumentNode): string {
419
414
  let plain_text = '';
420
415
 
421
416
  for (const [prop_name, prop_value] of Object.entries(node)) {
422
417
  if (prop_name === 'id' || prop_name === 'type') continue;
423
418
 
424
- // Check if this is an annotated_text property (object with text property)
419
+ // Check if this is a text property value.
425
420
  if (
426
421
  typeof prop_value === 'object' &&
427
422
  prop_value !== null &&
428
- typeof prop_value.text === 'string'
423
+ typeof prop_value.content === 'string'
429
424
  ) {
430
- const text_content = prop_value.text;
425
+ const text_content = prop_value.content;
431
426
  if (text_content.trim()) {
432
427
  plain_text += `${text_content.trim()}\n\n`;
433
428
  }
@@ -442,7 +437,7 @@ ${fallback_html}`;
442
437
  * @param {Object[]} nodes - Array of node objects
443
438
  * @returns {string} HTML representation
444
439
  */
445
- function export_html(nodes) {
440
+ function export_html(nodes: DocumentNode[]): string {
446
441
  let html = '';
447
442
 
448
443
  for (const node of nodes) {
@@ -459,7 +454,7 @@ ${fallback_html}`;
459
454
  return html;
460
455
  }
461
456
 
462
- function export_plain_text(nodes) {
457
+ function export_plain_text(nodes: DocumentNode[]): string {
463
458
  let plain_text = '';
464
459
 
465
460
  for (const node of nodes) {
@@ -468,33 +463,11 @@ ${fallback_html}`;
468
463
  return plain_text.trim();
469
464
  }
470
465
 
471
- /**
472
- * @param {NodeId[]} selected_node_ids
473
- */
474
- function prepare_copy_payload(selected_node_ids) {
475
- const nodes = {};
476
-
477
- // Get subgraph for each selected node using session.traverse()
478
- for (const node_id of selected_node_ids) {
479
- const subgraph = session.traverse(node_id);
480
-
481
- // Add all nodes from this subgraph to our nodes collection
482
- for (const node of subgraph) {
483
- if (!nodes[node.id]) {
484
- nodes[node.id] = node;
485
- }
486
- }
487
- }
488
-
489
- // Keep original IDs - we'll generate new ones during paste
490
- return { nodes, main_nodes: selected_node_ids };
491
- }
492
-
493
466
  /**
494
467
  * @param {ClipboardEvent} event
495
468
  * @param {boolean} delete_selection - used by oncut()
496
469
  */
497
- function oncopy(event, delete_selection = false) {
470
+ function oncopy(event: ClipboardEvent, delete_selection = false) {
498
471
  // Only handle copy events if editable and focus is within the canvas
499
472
  if (!editable) return;
500
473
  if (!canvas_el?.contains(document.activeElement)) return;
@@ -502,24 +475,23 @@ ${fallback_html}`;
502
475
  event.preventDefault();
503
476
  event.stopPropagation();
504
477
 
505
- let plain_text, annotated_text, html;
478
+ let plain_text, text, html;
506
479
 
507
480
  if (session.selection?.type === 'text') {
508
481
  plain_text = session.get_selected_plain_text();
509
- annotated_text = session.get_selected_annotated_text();
510
- const fallback_html = `<span>${annotated_text.text}</span>`;
482
+ text = session.get_selected_text();
483
+ const fallback_html = `<span>${text.content}</span>`;
511
484
 
512
485
  // console.log('Text copy:', {
513
- // annotated_text,
486
+ // text,
514
487
  // plain_text,
515
488
  // html
516
489
  // });
517
490
 
518
- html = create_svedit_html_format(annotated_text, fallback_html);
491
+ html = create_svedit_html_format(text, fallback_html);
519
492
  } else if (session.selection?.type === 'node') {
520
- const selected_nodes = session.get_selected_nodes();
521
- const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
522
- const json_data = { nodes, main_nodes };
493
+ const json_data = session.get_selected_annotated_nodes();
494
+ const { nodes, main_nodes } = json_data;
523
495
 
524
496
  // console.log('Node copy:', {
525
497
  // selected_nodes,
@@ -562,13 +534,11 @@ ${fallback_html}`;
562
534
  }
563
535
  }
564
536
 
565
- function oncut(event) {
537
+ function oncut(event: ClipboardEvent) {
566
538
  if (!editable) return;
567
539
  oncopy(event, true);
568
540
  }
569
541
 
570
-
571
-
572
542
  /**
573
543
  * @returns {NodeSelection|null}
574
544
  */
@@ -587,13 +557,13 @@ ${fallback_html}`;
587
557
 
588
558
  const node_array_path = [...path, preferred_property_name];
589
559
  const node_array = session.get(node_array_path);
590
- if (!Array.isArray(node_array)) return null;
560
+ if (!node_array || !Array.isArray(node_array.nodes)) return null;
591
561
 
592
562
  return {
593
- type: /** @type {const} */ ('node'),
563
+ type: 'node' as const,
594
564
  path: node_array_path,
595
- anchor_offset: node_array.length,
596
- focus_offset: node_array.length
565
+ anchor_offset: node_array.nodes.length,
566
+ focus_offset: node_array.nodes.length
597
567
  };
598
568
  }
599
569
 
@@ -620,11 +590,10 @@ ${fallback_html}`;
620
590
  */
621
591
  function get_node_insert_caret_after_text_selection(selection = session.selection) {
622
592
  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;
593
+ const node_index = selection.path.at(-2) as number;
625
594
 
626
595
  return {
627
- type: /** @type {const} */ ('node'),
596
+ type: 'node' as const,
628
597
  path: selection.path.slice(0, -2),
629
598
  anchor_offset: node_index + 1,
630
599
  focus_offset: node_index + 1
@@ -638,8 +607,8 @@ ${fallback_html}`;
638
607
  * @param {Selection} [selection] - Optional selection (node caret) where the payload should be pasted
639
608
  * @returns {boolean} True if the paste operation was successful, false otherwise
640
609
  */
641
- function try_node_paste(pasted_json, selection) {
642
- const { nodes, main_nodes } = pasted_json || {};
610
+ function try_node_paste(pasted_json: DynamicRecord | null, selection?: Selection): boolean {
611
+ const { nodes, main_nodes, marks = [], annotations = [] } = pasted_json || {};
643
612
  if (!nodes || !main_nodes?.length) return false;
644
613
 
645
614
  let tr = session.tr;
@@ -652,7 +621,10 @@ ${fallback_html}`;
652
621
  if (property_definition?.type !== 'node_array') return false;
653
622
 
654
623
  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);
624
+ const target_text_property_name = get_text_property_name(
625
+ default_text_node_type,
626
+ session.schema
627
+ );
656
628
 
657
629
  const nodes_to_insert = [];
658
630
  let rejected = false;
@@ -674,7 +646,11 @@ ${fallback_html}`;
674
646
  the_node: {
675
647
  id: 'the_node',
676
648
  type: default_text_node_type,
677
- [target_text_property_name]: text_content || { text: '', annotations: [] }
649
+ [target_text_property_name]: text_content || {
650
+ content: '',
651
+ marks: [],
652
+ annotations: []
653
+ }
678
654
  }
679
655
  });
680
656
  nodes_to_insert.push(new_node_id);
@@ -689,7 +665,7 @@ ${fallback_html}`;
689
665
  }
690
666
 
691
667
  if (!rejected) {
692
- tr.insert_nodes(nodes_to_insert);
668
+ tr.insert_nodes(nodes_to_insert, marks, annotations, nodes);
693
669
  session.apply(tr);
694
670
  return true;
695
671
  }
@@ -703,7 +679,7 @@ ${fallback_html}`;
703
679
  return false;
704
680
  }
705
681
 
706
- async function onpaste(event) {
682
+ async function onpaste(event: ClipboardEvent) {
707
683
  // Only handle paste events if editable and focus is within the canvas
708
684
  if (!editable) return;
709
685
  if (!canvas_el?.contains(document.activeElement)) return;
@@ -766,7 +742,7 @@ ${fallback_html}`;
766
742
 
767
743
  if (session.selection?.type === 'text') {
768
744
  const property_definition = session.inspect(session.selection.path);
769
- if (property_definition?.type === 'annotated_text' && !property_definition.allow_newlines) {
745
+ if (property_definition?.type === 'text' && !property_definition.allow_newlines) {
770
746
  plain_text = normalize_plain_text_for_single_line_property(plain_text);
771
747
  }
772
748
 
@@ -777,7 +753,10 @@ ${fallback_html}`;
777
753
  const node_array_property_definition = session.inspect(
778
754
  session.selection.path.slice(0, -2)
779
755
  );
780
- const default_text_node_type = get_default_text_node(node_array_property_definition, session.schema);
756
+ const default_text_node_type = get_default_text_node(
757
+ node_array_property_definition,
758
+ session.schema
759
+ );
781
760
  const node_insert_caret = get_node_insert_caret_after_text_selection(session.selection);
782
761
  const plain_text_nodes_payload = create_plain_text_nodes_payload(
783
762
  plain_text_fragments,
@@ -796,7 +775,10 @@ ${fallback_html}`;
796
775
  const target_node_insert_caret = get_target_node_insert_caret(session.selection);
797
776
  if (target_node_insert_caret) {
798
777
  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);
778
+ const default_text_node_type = get_default_text_node(
779
+ node_array_property_definition,
780
+ session.schema
781
+ );
800
782
  pasted_json = create_plain_text_nodes_payload(
801
783
  plain_text_fragments,
802
784
  default_text_node_type,
@@ -831,10 +813,15 @@ ${fallback_html}`;
831
813
  session.apply(session.tr.set(session.selection.path, pasted_json.value));
832
814
  }
833
815
  }
834
- } else if (session.selection?.type === 'text' && pasted_json?.text) {
816
+ } else if (session.selection?.type === 'text' && pasted_json?.content) {
835
817
  // Paste text at a text selection
836
818
  session.apply(
837
- session.tr.insert_text(pasted_json.text, pasted_json.annotations, pasted_json.nodes)
819
+ session.tr.insert_text(
820
+ pasted_json.content,
821
+ pasted_json.marks,
822
+ pasted_json.annotations,
823
+ pasted_json.nodes
824
+ )
838
825
  );
839
826
  } else if (
840
827
  session.selection?.type === 'text' &&
@@ -848,7 +835,12 @@ ${fallback_html}`;
848
835
  );
849
836
  if (text_property) {
850
837
  session.apply(
851
- session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
838
+ session.tr.insert_text(
839
+ text_property.content,
840
+ text_property.marks,
841
+ text_property.annotations,
842
+ pasted_json.nodes
843
+ )
852
844
  );
853
845
  }
854
846
  } else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
@@ -863,8 +855,8 @@ ${fallback_html}`;
863
855
  }
864
856
  }
865
857
 
866
- function render_selection(dom_driven = false) {
867
- const selection = /** @type {Selection} */ (session.selection);
858
+ function render_selection(dom_driven = false, should_scroll_selection_into_view = true) {
859
+ const selection = session.selection as Selection;
868
860
 
869
861
  if (!selection) {
870
862
  // No model selection -> just leave things as they are
@@ -881,7 +873,7 @@ ${fallback_html}`;
881
873
  // reuses gap elements with shifted data-gap-offset), so for those
882
874
  // we always rerender to scroll the cursor into view.
883
875
  const is_empty_text_selection =
884
- selection.type === 'text' && session.get(selection.path).text.length === 0;
876
+ selection.type === 'text' && session.get(selection.path).content.length === 0;
885
877
  if (dom_driven && !is_empty_text_selection) {
886
878
  const prev_selection = __get_selection_from_dom();
887
879
  if (
@@ -893,11 +885,11 @@ ${fallback_html}`;
893
885
  }
894
886
 
895
887
  if (selection?.type === 'text') {
896
- __render_text_selection();
888
+ __render_text_selection(should_scroll_selection_into_view);
897
889
  } else if (selection?.type === 'node') {
898
- __render_node_selection();
890
+ __render_node_selection(should_scroll_selection_into_view);
899
891
  } else if (selection?.type === 'property') {
900
- __render_property_selection();
892
+ __render_property_selection(should_scroll_selection_into_view);
901
893
  } else {
902
894
  console.warn('unsupported selection', $state.snapshot(selection));
903
895
  }
@@ -940,17 +932,15 @@ ${fallback_html}`;
940
932
  * @param {HTMLElement} el
941
933
  * @returns {HTMLElement | null}
942
934
  */
943
- function __resolve_node_from_gap(el) {
944
- const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
935
+ function __resolve_node_from_gap(el: Element): HTMLElement | null {
936
+ const gap = el.closest('[data-gap-array-path]') as HTMLElement | null;
945
937
  if (!gap) return null;
946
938
  const array_path = deserialize_path(gap.dataset.gapArrayPath);
947
939
  const offset = parseInt(gap.dataset.gapOffset, 10);
948
940
  const node_idx = offset > 0 ? offset - 1 : 0;
949
- return /** @type {HTMLElement | null} */ (
950
- canvas_el.querySelector(
951
- `[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`
952
- )
953
- );
941
+ return canvas_el.querySelector<HTMLElement>(
942
+ `[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`
943
+ ) as HTMLElement | null;
954
944
  }
955
945
 
956
946
  /**
@@ -960,12 +950,12 @@ ${fallback_html}`;
960
950
  * @returns {NodeSelection | null} A NodeSelection object if the DOM selection
961
951
  * represents a valid node selection, null otherwise
962
952
  */
963
- function __get_node_selection_from_dom() {
953
+ function __get_node_selection_from_dom(): NodeSelection | null {
964
954
  const dom_selection = window.getSelection();
965
955
  if (dom_selection.rangeCount === 0) return null;
966
956
 
967
- let focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
968
- let anchor_node = /** @type {HTMLElement} */ (dom_selection.anchorNode);
957
+ let focus_node = dom_selection.focusNode as HTMLElement;
958
+ let anchor_node = dom_selection.anchorNode as HTMLElement;
969
959
 
970
960
  // If focus_node or anchor_node not an element node (e.g. a text node), we need
971
961
  // to use the parent element, so we can perform the closest() query on it.
@@ -975,17 +965,17 @@ ${fallback_html}`;
975
965
  // EDGE CASE: Collapsed selection inside an empty node placeholder.
976
966
  // Firefox can place the DOM selection on the placeholder node itself,
977
967
  // 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
- );
968
+ const focus_empty_placeholder = focus_node.closest(
969
+ '.empty-node-placeholder[data-path][data-type="node"]'
970
+ ) as HTMLElement | null;
971
+ const anchor_empty_placeholder = anchor_node.closest(
972
+ '.empty-node-placeholder[data-path][data-type="node"]'
973
+ ) as HTMLElement | null;
984
974
  if (focus_empty_placeholder && focus_empty_placeholder === anchor_empty_placeholder) {
985
975
  const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
986
976
  const array_path = empty_placeholder_path.slice(0, -1);
987
977
  const node_array = session.get(array_path);
988
- if (Array.isArray(node_array) && node_array.length === 0) {
978
+ if (node_array?.nodes?.length === 0) {
989
979
  return {
990
980
  type: 'node',
991
981
  path: array_path,
@@ -997,7 +987,7 @@ ${fallback_html}`;
997
987
 
998
988
  // EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
999
989
  // Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
1000
- const gap_el = /** @type {HTMLElement | null} */ (focus_node.closest('[data-gap-array-path]'));
990
+ const gap_el = focus_node.closest('[data-gap-array-path]') as HTMLElement | null;
1001
991
  if (gap_el && focus_node === anchor_node) {
1002
992
  const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
1003
993
  const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
@@ -1011,12 +1001,12 @@ ${fallback_html}`;
1011
1001
 
1012
1002
  let focus_root =
1013
1003
  __resolve_node_from_gap(focus_node) ??
1014
- /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
1004
+ (focus_node.closest('[data-path][data-type="node"]') as HTMLElement);
1015
1005
  if (!focus_root) return null;
1016
1006
 
1017
1007
  let anchor_root =
1018
1008
  __resolve_node_from_gap(anchor_node) ??
1019
- /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
1009
+ (anchor_node.closest('[data-path][data-type="node"]') as HTMLElement);
1020
1010
  if (!anchor_root) return null;
1021
1011
 
1022
1012
  let focus_root_path = deserialize_path(focus_root.dataset.path);
@@ -1114,17 +1104,17 @@ ${fallback_html}`;
1114
1104
  * @returns {PropertySelection | null} A PropertySelection object if the DOM selection
1115
1105
  * represents a valid property selection, null otherwise
1116
1106
  */
1117
- function __get_property_selection_from_dom() {
1107
+ function __get_property_selection_from_dom(): PropertySelection | null {
1118
1108
  const dom_selection = window.getSelection();
1119
1109
  if (dom_selection.rangeCount === 0) return null;
1120
1110
 
1121
- let focus_root = /** @type {HTMLElement} */ (
1122
- dom_selection.focusNode.parentElement?.closest('[data-path][data-type="property"]')
1123
- );
1111
+ let focus_root = dom_selection.focusNode.parentElement?.closest(
1112
+ '[data-path][data-type="property"]'
1113
+ ) as HTMLElement;
1124
1114
  if (!focus_root) return null;
1125
- let anchor_root = /** @type {HTMLElement} */ (
1126
- dom_selection.anchorNode.parentElement?.closest('[data-path][data-type="property"]')
1127
- );
1115
+ let anchor_root = dom_selection.anchorNode.parentElement?.closest(
1116
+ '[data-path][data-type="property"]'
1117
+ ) as HTMLElement;
1128
1118
  if (!anchor_root) return null;
1129
1119
 
1130
1120
  if (focus_root === anchor_root) {
@@ -1151,10 +1141,13 @@ ${fallback_html}`;
1151
1141
  * @returns {TextSelection | null} A TextSelection object if the DOM selection
1152
1142
  * represents a valid text selection, null otherwise
1153
1143
  */
1154
- function __get_text_selection_from_dom(range = null) {
1155
- let dom_selection;
1156
- let focus_node, anchor_node;
1157
- let focus_offset_in_node; // anchor_offset_in_node;
1144
+ function __get_text_selection_from_dom(
1145
+ range: Range | StaticRange | null = null
1146
+ ): TextSelection | null {
1147
+ let dom_selection: globalThis.Selection | null = null;
1148
+ let focus_node: Node | null;
1149
+ let anchor_node: Node | null;
1150
+ let focus_offset_in_node: number; // anchor_offset_in_node;
1158
1151
 
1159
1152
  if (range) {
1160
1153
  // When range is provided, use it directly
@@ -1173,23 +1166,24 @@ ${fallback_html}`;
1173
1166
  range = dom_selection.getRangeAt(0);
1174
1167
  }
1175
1168
 
1176
- function get_text_root(node) {
1169
+ function get_text_root(node: Node | null): HTMLElement | null {
1177
1170
  if (!node) return null;
1178
1171
 
1179
- if (node.nodeType === Node.ELEMENT_NODE) {
1180
- return /** @type {HTMLElement | null} */ (
1181
- node.closest?.('[data-path][data-type="text"]') ?? null
1182
- );
1172
+ if (node instanceof Element) {
1173
+ return node.closest<HTMLElement>('[data-path][data-type="text"]');
1183
1174
  }
1184
1175
 
1185
- return /** @type {HTMLElement | null} */ (
1186
- node.parentElement?.closest('[data-path][data-type="text"]') ?? null
1187
- );
1176
+ return node.parentElement?.closest<HTMLElement>('[data-path][data-type="text"]') ?? null;
1188
1177
  }
1189
1178
 
1190
- let focus_root, anchor_root;
1179
+ let focus_root: HTMLElement | null;
1180
+ let anchor_root: HTMLElement | null;
1191
1181
 
1192
- if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
1182
+ if (
1183
+ focus_node === anchor_node &&
1184
+ focus_node instanceof HTMLElement &&
1185
+ focus_node.dataset.type === 'text'
1186
+ ) {
1193
1187
  // 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
1194
1188
  focus_root = anchor_root = focus_node;
1195
1189
  } else {
@@ -1210,7 +1204,7 @@ ${fallback_html}`;
1210
1204
 
1211
1205
  // EDGE CASE 1B: Caret after trailing <br> at end of text
1212
1206
  //
1213
- // AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
1207
+ // TextProperty renders a trailing <br> for non-empty or non-focused text.
1214
1208
  // When the user places their caret after this <br>, focusNode is the container
1215
1209
  // element (not a text node), and normal processing would return position 0.
1216
1210
  // We detect this and return the current DOM text length instead.
@@ -1250,7 +1244,7 @@ ${fallback_html}`;
1250
1244
  }
1251
1245
  }
1252
1246
 
1253
- function get_text_offset(container, offset) {
1247
+ function get_text_offset(container: Node, offset: number): number {
1254
1248
  const offset_range = window.document.createRange();
1255
1249
  offset_range.setStart(focus_root, 0);
1256
1250
  offset_range.setEnd(container, offset);
@@ -1277,7 +1271,7 @@ ${fallback_html}`;
1277
1271
  };
1278
1272
  }
1279
1273
 
1280
- function __get_node_element(node_array_path, node_offset) {
1274
+ function __get_node_element(node_array_path: DocumentPath, node_offset: number): Element | null {
1281
1275
  if (!canvas_el) return null;
1282
1276
  return canvas_el.querySelector(
1283
1277
  `[data-path="${serialize_path([...node_array_path, node_offset])}"][data-type="node"]`
@@ -1291,14 +1285,14 @@ ${fallback_html}`;
1291
1285
  * node clipped only by an inner scroll container still counts visible.
1292
1286
  * @param {Element | null | undefined} el
1293
1287
  */
1294
- function __intersects_viewport(el) {
1288
+ function __intersects_viewport(el: Element | null | undefined): boolean {
1295
1289
  if (!el) return false;
1296
1290
  const r = el.getBoundingClientRect();
1297
1291
  return r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
1298
1292
  }
1299
1293
 
1300
- function __render_node_selection() {
1301
- const selection = /** @type {NodeSelection} */ (session.selection);
1294
+ function __render_node_selection(should_scroll_selection_into_view = true) {
1295
+ const selection = session.selection as NodeSelection;
1302
1296
  const node_array_path = selection.path;
1303
1297
  const node_array_path_str = serialize_path(node_array_path);
1304
1298
  const is_collapsed = is_selection_collapsed(selection);
@@ -1312,7 +1306,7 @@ ${fallback_html}`;
1312
1306
  const dom_selection = window.getSelection();
1313
1307
  const range = window.document.createRange();
1314
1308
 
1315
- const gap_selector = (offset) =>
1309
+ const gap_selector = (offset: number) =>
1316
1310
  `[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
1317
1311
 
1318
1312
  if (is_collapsed) {
@@ -1336,7 +1330,10 @@ ${fallback_html}`;
1336
1330
  if (!anchor_sel || !focus_sel) return;
1337
1331
 
1338
1332
  if (is_backward) {
1339
- dom_selection.removeAllRanges();
1333
+ // setBaseAndExtent replaces the current selection, so no
1334
+ // removeAllRanges is needed — every selection-API call forces a
1335
+ // synchronous layout when the DOM is dirty, and this runs right
1336
+ // after the per-change reconcile on every apply.
1340
1337
  dom_selection.setBaseAndExtent(anchor_sel, 1, focus_sel, 1);
1341
1338
  } else {
1342
1339
  range.setStart(anchor_sel, 1);
@@ -1345,6 +1342,7 @@ ${fallback_html}`;
1345
1342
  dom_selection.addRange(range);
1346
1343
  }
1347
1344
  }
1345
+ if (!should_scroll_selection_into_view) return;
1348
1346
 
1349
1347
  // Scroll the cursor into view, but only when it is genuinely
1350
1348
  // off-screen. This runs on every node-selection re-render — a
@@ -1352,28 +1350,49 @@ ${fallback_html}`;
1352
1350
  // — so an unconditional scroll would yank the viewport on each of
1353
1351
  // them. cursor_offset is a gap offset and the gap has no box of
1354
1352
  // 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;
1353
+ const node_array = session.get(node_array_path);
1354
+ if (!node_array?.nodes) return;
1355
+
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 = node_array.nodes.length;
1360
+
1361
+ // node_before is null at offset 0: cursor_offset - 1 would be -1,
1362
+ // and serialize_path rejects a negative index.
1363
+ const node_before =
1364
+ cursor_offset > 0 ? __get_node_element(node_array_path, cursor_offset - 1) : null;
1365
+ const node_after = __get_node_element(node_array_path, cursor_offset);
1366
+
1367
+ let scroll_target = null;
1368
+ let scroll_array_to_start = false;
1369
+ let scroll_array_to_end = false;
1370
+
1371
+ if (cursor_offset === 0) {
1372
+ scroll_array_to_start = true;
1373
+ } else if (cursor_offset >= array_length) {
1374
+ scroll_array_to_end = true;
1375
+ } else {
1376
+ // A range selection's selected node IS node_before; scrolling
1377
+ // node_after would reveal the following node and leave the
1378
+ // selection itself off-screen. For a collapsed caret node_after's
1379
+ // leading edge is the cursor, so that stays the right target.
1380
+ scroll_target = is_collapsed ? node_after : node_before;
1381
+ }
1360
1382
 
1383
+ setTimeout(() => {
1361
1384
  // If either node flanking the cursor is already (even
1362
1385
  // 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);
1386
+ // viewport stable and scroll nothing. Do this layout read in the
1387
+ // deferred callback, after Svelte/browser layout has settled.
1369
1388
  if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
1370
1389
 
1371
- if (cursor_offset === 0) {
1390
+ if (scroll_array_to_start) {
1372
1391
  node_array_el.scrollLeft = 0;
1373
1392
  node_array_el.scrollTop = 0;
1374
1393
  return;
1375
1394
  }
1376
- if (cursor_offset >= array_length) {
1395
+ if (scroll_array_to_end) {
1377
1396
  const max_left = Math.max(0, node_array_el.scrollWidth - node_array_el.clientWidth);
1378
1397
  const max_top = Math.max(0, node_array_el.scrollHeight - node_array_el.clientHeight);
1379
1398
  node_array_el.scrollLeft = max_left;
@@ -1383,19 +1402,14 @@ ${fallback_html}`;
1383
1402
  }
1384
1403
  return;
1385
1404
  }
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' });
1405
+ scroll_target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1392
1406
  }, 0);
1393
1407
  }
1394
1408
 
1395
- function __render_property_selection() {
1409
+ function __render_property_selection(should_scroll_selection_into_view = true) {
1396
1410
  const selection = session.selection;
1397
1411
  // The element that holds the property
1398
- const el = canvas_el.querySelector(
1412
+ const el = canvas_el.querySelector<HTMLElement>(
1399
1413
  `[data-path="${serialize_path(selection.path)}"][data-type="property"]`
1400
1414
  );
1401
1415
 
@@ -1409,36 +1423,33 @@ ${fallback_html}`;
1409
1423
  dom_selection.removeAllRanges();
1410
1424
  dom_selection.addRange(range);
1411
1425
 
1412
- // Scroll the selection into view
1413
- setTimeout(() => {
1414
- el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1415
- }, 0);
1426
+ if (should_scroll_selection_into_view) {
1427
+ setTimeout(() => {
1428
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1429
+ }, 0);
1430
+ }
1416
1431
  }
1417
1432
 
1418
- function __render_text_selection() {
1419
- const selection = /** @type {any} */ (session.selection);
1433
+ function __render_text_selection(should_scroll_selection_into_view = true) {
1434
+ const selection = session.selection as TextSelection;
1420
1435
  // The element that holds the annotated string
1421
- const el = canvas_el.querySelector(
1436
+ const el = canvas_el.querySelector<HTMLElement>(
1422
1437
  `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1423
1438
  );
1424
- const empty_text = session.get(selection.path).text.length === 0;
1439
+ const empty_text = session.get(selection.path).content.length === 0;
1425
1440
  const dom_selection = window.getSelection();
1426
1441
  let current_offset = 0;
1427
- /** @type {HTMLElement | Text} */
1428
- let anchor_node;
1429
- /** @type {HTMLElement | Text} */
1430
- let focus_node;
1431
- /** @type {number} */
1432
- let anchor_node_offset;
1433
- /** @type {number} */
1434
- let focus_node_offset;
1442
+ let anchor_node: HTMLElement | Text;
1443
+ let focus_node: HTMLElement | Text;
1444
+ let anchor_node_offset: number;
1445
+ let focus_node_offset: number;
1435
1446
  const is_backward = selection.anchor_offset > selection.focus_offset;
1436
1447
  const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
1437
1448
  const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
1438
1449
 
1439
1450
  // Helper function to process each node
1440
- function process_node(node) {
1441
- if (node.nodeType === Node.TEXT_NODE) {
1451
+ function process_node(node: Node) {
1452
+ if (node instanceof Text) {
1442
1453
  const node_text = node.textContent;
1443
1454
  const node_char_length = get_char_length(node_text);
1444
1455
 
@@ -1473,7 +1484,7 @@ ${fallback_html}`;
1473
1484
  }
1474
1485
  }
1475
1486
  current_offset += node_char_length;
1476
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1487
+ } else if (node instanceof HTMLElement) {
1477
1488
  for (const child_node of node.childNodes) {
1478
1489
  if (process_node(child_node)) return true; // Stop iteration if end found
1479
1490
  }
@@ -1498,8 +1509,10 @@ ${fallback_html}`;
1498
1509
 
1499
1510
  // Set the range if both start and end were found
1500
1511
  if (anchor_node && focus_node) {
1501
- dom_selection.removeAllRanges();
1502
- // NOTE: Only using setBaseAndExtent() will preserve selection direction
1512
+ // NOTE: Only using setBaseAndExtent() will preserve selection direction.
1513
+ // It also replaces the current selection, so no removeAllRanges is
1514
+ // needed — every selection-API call forces a synchronous layout when
1515
+ // the DOM is dirty, and this runs on every apply.
1503
1516
  dom_selection.setBaseAndExtent(
1504
1517
  anchor_node,
1505
1518
  anchor_node_offset,
@@ -1507,20 +1520,27 @@ ${fallback_html}`;
1507
1520
  focus_node_offset
1508
1521
  );
1509
1522
 
1510
- // Scroll the selection into view
1511
- setTimeout(() => {
1512
- const selectedElement = dom_selection.focusNode.parentElement;
1513
- if (selectedElement) {
1514
- selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1515
- }
1516
- }, 0);
1523
+ if (should_scroll_selection_into_view) {
1524
+ // Scroll the rendered selection into view. Capture the target now:
1525
+ // the browser selection is live and may be cleared before this
1526
+ // deferred callback runs, e.g. when app UI focuses an external input.
1527
+ const selected_element = focus_node.parentElement;
1528
+ setTimeout(() => {
1529
+ if (selected_element?.isConnected) {
1530
+ selected_element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1531
+ }
1532
+ }, 0);
1533
+ }
1517
1534
  }
1518
1535
  }
1519
1536
 
1520
1537
  // Utils
1521
1538
  // --------------------------
1522
1539
 
1523
- function __remove_native_composition_text(selection, inserted_text) {
1540
+ function __remove_native_composition_text(
1541
+ selection: TextSelection | null,
1542
+ inserted_text: string
1543
+ ) {
1524
1544
  if (!selection || selection.type !== 'text' || !inserted_text) return;
1525
1545
 
1526
1546
  const text_el = canvas_el.querySelector(
@@ -1528,11 +1548,14 @@ ${fallback_html}`;
1528
1548
  );
1529
1549
  if (!text_el) return;
1530
1550
 
1531
- const model_text = session.get(selection.path).text;
1551
+ const model_text = session.get(selection.path).content;
1532
1552
  if ((text_el.textContent ?? '') === model_text) return;
1533
1553
 
1534
1554
  let current_offset = 0;
1535
- function get_dom_text_position(root, target_offset) {
1555
+ function get_dom_text_position(
1556
+ root: Node,
1557
+ target_offset: number
1558
+ ): { node: Node; offset: number } | null {
1536
1559
  for (const node of root.childNodes) {
1537
1560
  if (node.nodeType === Node.TEXT_NODE) {
1538
1561
  const node_text = node.textContent ?? '';
@@ -1598,7 +1621,11 @@ ${fallback_html}`;
1598
1621
  const dom_driven = selection_source_is_dom;
1599
1622
  selection_source_is_dom = false;
1600
1623
  if (!canvas_focused) return;
1601
- render_selection(dom_driven);
1624
+ const selection_snapshot = JSON.stringify(session.selection);
1625
+ const should_scroll_selection_into_view =
1626
+ !dom_driven && selection_snapshot !== last_rendered_selection_snapshot;
1627
+ render_selection(dom_driven, should_scroll_selection_into_view);
1628
+ last_rendered_selection_snapshot = selection_snapshot;
1602
1629
  });
1603
1630
  </script>
1604
1631
 
@@ -1625,7 +1652,7 @@ ${fallback_html}`;
1625
1652
  -->
1626
1653
  <div
1627
1654
  class="svedit-canvas {css_class}"
1628
- class:hide-selection={session.selection?.type === 'node'}
1655
+ class:hide-selection={editable && session.selection?.type === 'node'}
1629
1656
  class:node-caret={session.selection?.type === 'node' &&
1630
1657
  session.selection.anchor_offset === session.selection.focus_offset}
1631
1658
  class:property-selection={session.selection?.type === 'property'}