svedit 0.12.0 → 0.14.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 (88) hide show
  1. package/README.md +35 -22
  2. package/dist/Command.svelte.d.ts +38 -30
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +271 -326
  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 +16 -13
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +39 -22
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +2 -2
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +12 -6
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +36 -14
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +21 -7
  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 +81 -129
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +542 -648
  32. package/dist/Svedit.svelte +146 -140
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/TextProperty.svelte +37 -23
  36. package/dist/TextProperty.svelte.d.ts +3 -5
  37. package/dist/TextProperty.svelte.d.ts.map +1 -1
  38. package/dist/Transaction.svelte.d.ts +64 -127
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +839 -1001
  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 +67 -155
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +619 -863
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -17
  50. package/dist/node_visibility.svelte.d.ts +110 -9
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +473 -544
  53. package/dist/paste_utils.d.ts +11 -44
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -152
  56. package/dist/styles/svedit-colors.css +8 -5
  57. package/dist/transforms.svelte.d.ts +9 -7
  58. package/dist/transforms.svelte.d.ts.map +1 -1
  59. package/dist/transforms.svelte.js +125 -150
  60. package/dist/types.d.ts +294 -309
  61. package/dist/types.d.ts.map +1 -0
  62. package/dist/types.js +1 -0
  63. package/dist/utils.d.ts +40 -119
  64. package/dist/utils.d.ts.map +1 -1
  65. package/dist/utils.js +346 -473
  66. package/package.json +70 -70
  67. package/src/lib/{Command.svelte.js → Command.svelte.ts} +29 -17
  68. package/src/lib/CustomProperty.svelte +3 -5
  69. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  70. package/src/lib/Node.svelte +16 -13
  71. package/src/lib/NodeArrayProperty.svelte +39 -22
  72. package/src/lib/NodeCaret.svelte +2 -2
  73. package/src/lib/NodeGap.svelte +12 -6
  74. package/src/lib/NodeGapMarkers.svelte +36 -14
  75. package/src/lib/NodeSelectionMarkers.svelte +21 -7
  76. package/src/lib/{Session.svelte.js → Session.svelte.ts} +130 -127
  77. package/src/lib/Svedit.svelte +146 -140
  78. package/src/lib/TextProperty.svelte +37 -23
  79. package/src/lib/{Transaction.svelte.js → Transaction.svelte.ts} +139 -143
  80. package/src/lib/UnknownNode.svelte +4 -3
  81. package/src/lib/{doc_utils.js → doc_utils.ts} +247 -206
  82. package/src/lib/{index.js → index.ts} +48 -0
  83. package/src/lib/{node_visibility.svelte.js → node_visibility.svelte.ts} +68 -92
  84. package/src/lib/{paste_utils.js → paste_utils.ts} +47 -58
  85. package/src/lib/styles/svedit-colors.css +8 -5
  86. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +39 -21
  87. package/src/lib/{types.d.ts → types.ts} +161 -66
  88. package/src/lib/{utils.js → utils.ts} +110 -129
@@ -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
- // array_indices / 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) {
@@ -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,7 +382,11 @@ ${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
 
@@ -415,7 +410,7 @@ ${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)) {
@@ -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) {
@@ -472,7 +467,7 @@ ${fallback_html}`;
472
467
  * @param {ClipboardEvent} event
473
468
  * @param {boolean} delete_selection - used by oncut()
474
469
  */
475
- function oncopy(event, delete_selection = false) {
470
+ function oncopy(event: ClipboardEvent, delete_selection = false) {
476
471
  // Only handle copy events if editable and focus is within the canvas
477
472
  if (!editable) return;
478
473
  if (!canvas_el?.contains(document.activeElement)) return;
@@ -539,7 +534,7 @@ ${fallback_html}`;
539
534
  }
540
535
  }
541
536
 
542
- function oncut(event) {
537
+ function oncut(event: ClipboardEvent) {
543
538
  if (!editable) return;
544
539
  oncopy(event, true);
545
540
  }
@@ -565,7 +560,7 @@ ${fallback_html}`;
565
560
  if (!node_array || !Array.isArray(node_array.nodes)) return null;
566
561
 
567
562
  return {
568
- type: /** @type {const} */ ('node'),
563
+ type: 'node' as const,
569
564
  path: node_array_path,
570
565
  anchor_offset: node_array.nodes.length,
571
566
  focus_offset: node_array.nodes.length
@@ -595,11 +590,10 @@ ${fallback_html}`;
595
590
  */
596
591
  function get_node_insert_caret_after_text_selection(selection = session.selection) {
597
592
  if (selection?.type !== 'text') return null;
598
- const node_index = parseInt(String(selection.path.at(-2)), 10);
599
- if (Number.isNaN(node_index)) return null;
593
+ const node_index = selection.path.at(-2) as number;
600
594
 
601
595
  return {
602
- type: /** @type {const} */ ('node'),
596
+ type: 'node' as const,
603
597
  path: selection.path.slice(0, -2),
604
598
  anchor_offset: node_index + 1,
605
599
  focus_offset: node_index + 1
@@ -613,7 +607,7 @@ ${fallback_html}`;
613
607
  * @param {Selection} [selection] - Optional selection (node caret) where the payload should be pasted
614
608
  * @returns {boolean} True if the paste operation was successful, false otherwise
615
609
  */
616
- function try_node_paste(pasted_json, selection) {
610
+ function try_node_paste(pasted_json: DynamicRecord | null, selection?: Selection): boolean {
617
611
  const { nodes, main_nodes, marks = [], annotations = [] } = pasted_json || {};
618
612
  if (!nodes || !main_nodes?.length) return false;
619
613
 
@@ -653,10 +647,10 @@ ${fallback_html}`;
653
647
  id: 'the_node',
654
648
  type: default_text_node_type,
655
649
  [target_text_property_name]: text_content || {
656
- content: '',
657
- marks: [],
658
- annotations: []
659
- }
650
+ content: '',
651
+ marks: [],
652
+ annotations: []
653
+ }
660
654
  }
661
655
  });
662
656
  nodes_to_insert.push(new_node_id);
@@ -685,7 +679,7 @@ ${fallback_html}`;
685
679
  return false;
686
680
  }
687
681
 
688
- async function onpaste(event) {
682
+ async function onpaste(event: ClipboardEvent) {
689
683
  // Only handle paste events if editable and focus is within the canvas
690
684
  if (!editable) return;
691
685
  if (!canvas_el?.contains(document.activeElement)) return;
@@ -861,8 +855,8 @@ ${fallback_html}`;
861
855
  }
862
856
  }
863
857
 
864
- function render_selection(dom_driven = false) {
865
- 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;
866
860
 
867
861
  if (!selection) {
868
862
  // No model selection -> just leave things as they are
@@ -891,11 +885,11 @@ ${fallback_html}`;
891
885
  }
892
886
 
893
887
  if (selection?.type === 'text') {
894
- __render_text_selection();
888
+ __render_text_selection(should_scroll_selection_into_view);
895
889
  } else if (selection?.type === 'node') {
896
- __render_node_selection();
890
+ __render_node_selection(should_scroll_selection_into_view);
897
891
  } else if (selection?.type === 'property') {
898
- __render_property_selection();
892
+ __render_property_selection(should_scroll_selection_into_view);
899
893
  } else {
900
894
  console.warn('unsupported selection', $state.snapshot(selection));
901
895
  }
@@ -938,17 +932,15 @@ ${fallback_html}`;
938
932
  * @param {HTMLElement} el
939
933
  * @returns {HTMLElement | null}
940
934
  */
941
- function __resolve_node_from_gap(el) {
942
- 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;
943
937
  if (!gap) return null;
944
938
  const array_path = deserialize_path(gap.dataset.gapArrayPath);
945
939
  const offset = parseInt(gap.dataset.gapOffset, 10);
946
940
  const node_idx = offset > 0 ? offset - 1 : 0;
947
- return /** @type {HTMLElement | null} */ (
948
- canvas_el.querySelector(
949
- `[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`
950
- )
951
- );
941
+ return canvas_el.querySelector<HTMLElement>(
942
+ `[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`
943
+ ) as HTMLElement | null;
952
944
  }
953
945
 
954
946
  /**
@@ -958,12 +950,12 @@ ${fallback_html}`;
958
950
  * @returns {NodeSelection | null} A NodeSelection object if the DOM selection
959
951
  * represents a valid node selection, null otherwise
960
952
  */
961
- function __get_node_selection_from_dom() {
953
+ function __get_node_selection_from_dom(): NodeSelection | null {
962
954
  const dom_selection = window.getSelection();
963
955
  if (dom_selection.rangeCount === 0) return null;
964
956
 
965
- let focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
966
- 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;
967
959
 
968
960
  // If focus_node or anchor_node not an element node (e.g. a text node), we need
969
961
  // to use the parent element, so we can perform the closest() query on it.
@@ -973,12 +965,12 @@ ${fallback_html}`;
973
965
  // EDGE CASE: Collapsed selection inside an empty node placeholder.
974
966
  // Firefox can place the DOM selection on the placeholder node itself,
975
967
  // which otherwise looks like selecting node index 0 in an empty array.
976
- const focus_empty_placeholder = /** @type {HTMLElement | null} */ (
977
- focus_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
978
- );
979
- const anchor_empty_placeholder = /** @type {HTMLElement | null} */ (
980
- anchor_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
981
- );
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;
982
974
  if (focus_empty_placeholder && focus_empty_placeholder === anchor_empty_placeholder) {
983
975
  const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
984
976
  const array_path = empty_placeholder_path.slice(0, -1);
@@ -995,7 +987,7 @@ ${fallback_html}`;
995
987
 
996
988
  // EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
997
989
  // Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
998
- 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;
999
991
  if (gap_el && focus_node === anchor_node) {
1000
992
  const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
1001
993
  const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
@@ -1009,12 +1001,12 @@ ${fallback_html}`;
1009
1001
 
1010
1002
  let focus_root =
1011
1003
  __resolve_node_from_gap(focus_node) ??
1012
- /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
1004
+ (focus_node.closest('[data-path][data-type="node"]') as HTMLElement);
1013
1005
  if (!focus_root) return null;
1014
1006
 
1015
1007
  let anchor_root =
1016
1008
  __resolve_node_from_gap(anchor_node) ??
1017
- /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
1009
+ (anchor_node.closest('[data-path][data-type="node"]') as HTMLElement);
1018
1010
  if (!anchor_root) return null;
1019
1011
 
1020
1012
  let focus_root_path = deserialize_path(focus_root.dataset.path);
@@ -1112,17 +1104,17 @@ ${fallback_html}`;
1112
1104
  * @returns {PropertySelection | null} A PropertySelection object if the DOM selection
1113
1105
  * represents a valid property selection, null otherwise
1114
1106
  */
1115
- function __get_property_selection_from_dom() {
1107
+ function __get_property_selection_from_dom(): PropertySelection | null {
1116
1108
  const dom_selection = window.getSelection();
1117
1109
  if (dom_selection.rangeCount === 0) return null;
1118
1110
 
1119
- let focus_root = /** @type {HTMLElement} */ (
1120
- dom_selection.focusNode.parentElement?.closest('[data-path][data-type="property"]')
1121
- );
1111
+ let focus_root = dom_selection.focusNode.parentElement?.closest(
1112
+ '[data-path][data-type="property"]'
1113
+ ) as HTMLElement;
1122
1114
  if (!focus_root) return null;
1123
- let anchor_root = /** @type {HTMLElement} */ (
1124
- dom_selection.anchorNode.parentElement?.closest('[data-path][data-type="property"]')
1125
- );
1115
+ let anchor_root = dom_selection.anchorNode.parentElement?.closest(
1116
+ '[data-path][data-type="property"]'
1117
+ ) as HTMLElement;
1126
1118
  if (!anchor_root) return null;
1127
1119
 
1128
1120
  if (focus_root === anchor_root) {
@@ -1149,10 +1141,13 @@ ${fallback_html}`;
1149
1141
  * @returns {TextSelection | null} A TextSelection object if the DOM selection
1150
1142
  * represents a valid text selection, null otherwise
1151
1143
  */
1152
- function __get_text_selection_from_dom(range = null) {
1153
- let dom_selection;
1154
- let focus_node, anchor_node;
1155
- 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;
1156
1151
 
1157
1152
  if (range) {
1158
1153
  // When range is provided, use it directly
@@ -1171,23 +1166,24 @@ ${fallback_html}`;
1171
1166
  range = dom_selection.getRangeAt(0);
1172
1167
  }
1173
1168
 
1174
- function get_text_root(node) {
1169
+ function get_text_root(node: Node | null): HTMLElement | null {
1175
1170
  if (!node) return null;
1176
1171
 
1177
- if (node.nodeType === Node.ELEMENT_NODE) {
1178
- return /** @type {HTMLElement | null} */ (
1179
- node.closest?.('[data-path][data-type="text"]') ?? null
1180
- );
1172
+ if (node instanceof Element) {
1173
+ return node.closest<HTMLElement>('[data-path][data-type="text"]');
1181
1174
  }
1182
1175
 
1183
- return /** @type {HTMLElement | null} */ (
1184
- node.parentElement?.closest('[data-path][data-type="text"]') ?? null
1185
- );
1176
+ return node.parentElement?.closest<HTMLElement>('[data-path][data-type="text"]') ?? null;
1186
1177
  }
1187
1178
 
1188
- let focus_root, anchor_root;
1179
+ let focus_root: HTMLElement | null;
1180
+ let anchor_root: HTMLElement | null;
1189
1181
 
1190
- 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
+ ) {
1191
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
1192
1188
  focus_root = anchor_root = focus_node;
1193
1189
  } else {
@@ -1248,7 +1244,7 @@ ${fallback_html}`;
1248
1244
  }
1249
1245
  }
1250
1246
 
1251
- function get_text_offset(container, offset) {
1247
+ function get_text_offset(container: Node, offset: number): number {
1252
1248
  const offset_range = window.document.createRange();
1253
1249
  offset_range.setStart(focus_root, 0);
1254
1250
  offset_range.setEnd(container, offset);
@@ -1275,7 +1271,7 @@ ${fallback_html}`;
1275
1271
  };
1276
1272
  }
1277
1273
 
1278
- function __get_node_element(node_array_path, node_offset) {
1274
+ function __get_node_element(node_array_path: DocumentPath, node_offset: number): Element | null {
1279
1275
  if (!canvas_el) return null;
1280
1276
  return canvas_el.querySelector(
1281
1277
  `[data-path="${serialize_path([...node_array_path, node_offset])}"][data-type="node"]`
@@ -1289,14 +1285,14 @@ ${fallback_html}`;
1289
1285
  * node clipped only by an inner scroll container still counts visible.
1290
1286
  * @param {Element | null | undefined} el
1291
1287
  */
1292
- function __intersects_viewport(el) {
1288
+ function __intersects_viewport(el: Element | null | undefined): boolean {
1293
1289
  if (!el) return false;
1294
1290
  const r = el.getBoundingClientRect();
1295
1291
  return r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
1296
1292
  }
1297
1293
 
1298
- function __render_node_selection() {
1299
- const selection = /** @type {NodeSelection} */ (session.selection);
1294
+ function __render_node_selection(should_scroll_selection_into_view = true) {
1295
+ const selection = session.selection as NodeSelection;
1300
1296
  const node_array_path = selection.path;
1301
1297
  const node_array_path_str = serialize_path(node_array_path);
1302
1298
  const is_collapsed = is_selection_collapsed(selection);
@@ -1310,7 +1306,7 @@ ${fallback_html}`;
1310
1306
  const dom_selection = window.getSelection();
1311
1307
  const range = window.document.createRange();
1312
1308
 
1313
- const gap_selector = (offset) =>
1309
+ const gap_selector = (offset: number) =>
1314
1310
  `[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
1315
1311
 
1316
1312
  if (is_collapsed) {
@@ -1346,6 +1342,7 @@ ${fallback_html}`;
1346
1342
  dom_selection.addRange(range);
1347
1343
  }
1348
1344
  }
1345
+ if (!should_scroll_selection_into_view) return;
1349
1346
 
1350
1347
  // Scroll the cursor into view, but only when it is genuinely
1351
1348
  // off-screen. This runs on every node-selection re-render — a
@@ -1409,10 +1406,10 @@ ${fallback_html}`;
1409
1406
  }, 0);
1410
1407
  }
1411
1408
 
1412
- function __render_property_selection() {
1409
+ function __render_property_selection(should_scroll_selection_into_view = true) {
1413
1410
  const selection = session.selection;
1414
1411
  // The element that holds the property
1415
- const el = canvas_el.querySelector(
1412
+ const el = canvas_el.querySelector<HTMLElement>(
1416
1413
  `[data-path="${serialize_path(selection.path)}"][data-type="property"]`
1417
1414
  );
1418
1415
 
@@ -1426,36 +1423,33 @@ ${fallback_html}`;
1426
1423
  dom_selection.removeAllRanges();
1427
1424
  dom_selection.addRange(range);
1428
1425
 
1429
- // Scroll the selection into view
1430
- setTimeout(() => {
1431
- el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1432
- }, 0);
1426
+ if (should_scroll_selection_into_view) {
1427
+ setTimeout(() => {
1428
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1429
+ }, 0);
1430
+ }
1433
1431
  }
1434
1432
 
1435
- function __render_text_selection() {
1436
- const selection = /** @type {any} */ (session.selection);
1433
+ function __render_text_selection(should_scroll_selection_into_view = true) {
1434
+ const selection = session.selection as TextSelection;
1437
1435
  // The element that holds the annotated string
1438
- const el = canvas_el.querySelector(
1436
+ const el = canvas_el.querySelector<HTMLElement>(
1439
1437
  `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1440
1438
  );
1441
1439
  const empty_text = session.get(selection.path).content.length === 0;
1442
1440
  const dom_selection = window.getSelection();
1443
1441
  let current_offset = 0;
1444
- /** @type {HTMLElement | Text} */
1445
- let anchor_node;
1446
- /** @type {HTMLElement | Text} */
1447
- let focus_node;
1448
- /** @type {number} */
1449
- let anchor_node_offset;
1450
- /** @type {number} */
1451
- 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;
1452
1446
  const is_backward = selection.anchor_offset > selection.focus_offset;
1453
1447
  const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
1454
1448
  const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
1455
1449
 
1456
1450
  // Helper function to process each node
1457
- function process_node(node) {
1458
- if (node.nodeType === Node.TEXT_NODE) {
1451
+ function process_node(node: Node) {
1452
+ if (node instanceof Text) {
1459
1453
  const node_text = node.textContent;
1460
1454
  const node_char_length = get_char_length(node_text);
1461
1455
 
@@ -1490,7 +1484,7 @@ ${fallback_html}`;
1490
1484
  }
1491
1485
  }
1492
1486
  current_offset += node_char_length;
1493
- } else if (node.nodeType === Node.ELEMENT_NODE) {
1487
+ } else if (node instanceof HTMLElement) {
1494
1488
  for (const child_node of node.childNodes) {
1495
1489
  if (process_node(child_node)) return true; // Stop iteration if end found
1496
1490
  }
@@ -1526,22 +1520,27 @@ ${fallback_html}`;
1526
1520
  focus_node_offset
1527
1521
  );
1528
1522
 
1529
- // Scroll the rendered selection into view. Capture the target now:
1530
- // the browser selection is live and may be cleared before this
1531
- // deferred callback runs, e.g. when app UI focuses an external input.
1532
- const selected_element = focus_node.parentElement;
1533
- setTimeout(() => {
1534
- if (selected_element?.isConnected) {
1535
- selected_element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1536
- }
1537
- }, 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
+ }
1538
1534
  }
1539
1535
  }
1540
1536
 
1541
1537
  // Utils
1542
1538
  // --------------------------
1543
1539
 
1544
- function __remove_native_composition_text(selection, inserted_text) {
1540
+ function __remove_native_composition_text(
1541
+ selection: TextSelection | null,
1542
+ inserted_text: string
1543
+ ) {
1545
1544
  if (!selection || selection.type !== 'text' || !inserted_text) return;
1546
1545
 
1547
1546
  const text_el = canvas_el.querySelector(
@@ -1553,7 +1552,10 @@ ${fallback_html}`;
1553
1552
  if ((text_el.textContent ?? '') === model_text) return;
1554
1553
 
1555
1554
  let current_offset = 0;
1556
- 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 {
1557
1559
  for (const node of root.childNodes) {
1558
1560
  if (node.nodeType === Node.TEXT_NODE) {
1559
1561
  const node_text = node.textContent ?? '';
@@ -1619,7 +1621,11 @@ ${fallback_html}`;
1619
1621
  const dom_driven = selection_source_is_dom;
1620
1622
  selection_source_is_dom = false;
1621
1623
  if (!canvas_focused) return;
1622
- 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;
1623
1629
  });
1624
1630
  </script>
1625
1631
 
@@ -1677,7 +1683,7 @@ ${fallback_html}`;
1677
1683
 
1678
1684
  <style>
1679
1685
  .svedit-canvas {
1680
- caret-color: var(--svedit-caret-color);
1686
+ caret-color: var(--editing);
1681
1687
  caret-shape: bar;
1682
1688
  /* Default to vertical/ column flow with: --row: 0; (the most common case)
1683
1689
  Prevents silent failures when developers forget to set the row property in their top level node component.
@@ -1693,7 +1699,7 @@ ${fallback_html}`;
1693
1699
  the :where(), but the wrapper zeroes its specificity contribution.
1694
1700
  Final specificity is just (0,0,1), trivially beatable. */
1695
1701
  :where(.svedit-canvas) :global(::selection) {
1696
- background: var(--svedit-editing-fill);
1702
+ background: var(--editing-muted);
1697
1703
  }
1698
1704
 
1699
1705
  @media not (pointer: coarse) {