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,29 +1,121 @@
1
- <script>
1
+ <script lang="ts">
2
2
  import { getContext, setContext } from 'svelte';
3
3
  import UnknownNode from './UnknownNode.svelte';
4
- import { serialize_path } from './utils.js';
4
+ import { serialize_path, calculate_fragment_ranges } from './utils.js';
5
5
  import DefaultNodeGap from './NodeGap.svelte';
6
6
  import DefaultNodeGapMarkers from './NodeGapMarkers.svelte';
7
+ import type {
8
+ NodeArrayPropertyProps,
9
+ NodeArrayAttachmentContext,
10
+ NodeArrayFragment,
11
+ NodeId,
12
+ Attachment,
13
+ Mark,
14
+ NodeArrayRenderContext,
15
+ SveditRenderContext
16
+ } from './types.js';
7
17
 
8
- /** @import { NodeArrayPropertyProps } from './types.d.ts'; */
9
-
10
- const svedit = getContext('svedit');
18
+ const svedit = getContext<SveditRenderContext>('svedit');
11
19
  let NodeGap = $derived(svedit.session.config.system_components?.node_gap ?? DefaultNodeGap);
12
20
  let NodeGapMarkers = $derived(
13
21
  svedit.session.config.system_components?.node_gap_markers ?? DefaultNodeGapMarkers
14
22
  );
15
23
 
16
- /** @type {NodeArrayPropertyProps} */
17
- let { path, tag = 'div', class: css_class, style = '', ...rest } = $props();
24
+ let {
25
+ path,
26
+ tag = 'div',
27
+ class: css_class,
28
+ style = '',
29
+ ...rest
30
+ }: NodeArrayPropertyProps = $props();
18
31
 
19
32
  // Pre-joined once per path change; reused by data-path and by every
20
33
  // NodeGap's should_position_gap call to avoid N+1 joins per render.
21
34
  let path_str = $derived(serialize_path(path));
22
35
 
23
- let node_ids = $derived(svedit.session.get(path));
24
- let nodes = $derived(node_ids.map((id) => svedit.session.get(id)));
36
+ let raw_value = $derived(svedit.session.get(path));
37
+ let node_ids = $derived(raw_value.nodes);
38
+ let marks = $derived(raw_value.marks);
39
+ let annotations = $derived(raw_value.annotations);
40
+
41
+ let fragments = $derived(get_fragments(node_ids, marks));
42
+
43
+ function get_fragments(node_ids: NodeId[], marks: Mark[]): NodeArrayFragment[] {
44
+ const ranges = calculate_fragment_ranges(node_ids.length, marks);
45
+ const fragments: NodeArrayFragment[] = [];
46
+
47
+ for (const range of ranges) {
48
+ const nodes_slice = node_ids.slice(range.start_offset, range.end_offset);
49
+
50
+ if (range.type === 'mark') {
51
+ const node = svedit.session.get(range.node_id);
52
+ fragments.push({
53
+ type: 'mark',
54
+ node,
55
+ nodes: nodes_slice,
56
+ start_index: range.start_offset,
57
+ mark_index: range.mark_index!
58
+ });
59
+ } else if (range.type === 'content') {
60
+ fragments.push({
61
+ type: 'nodes',
62
+ nodes: nodes_slice,
63
+ start_index: range.start_offset
64
+ });
65
+ }
66
+ }
67
+ return fragments;
68
+ }
69
+
70
+ function get_node_key(node_id: NodeId, index: number): string {
71
+ let occurrence = 0;
72
+ for (let current_index = 0; current_index < index; current_index++) {
73
+ if (node_ids[current_index] === node_id) occurrence += 1;
74
+ }
75
+ return `${node_id}:${occurrence}`;
76
+ }
77
+
78
+ function get_range_context(
79
+ range: Attachment,
80
+ index: number,
81
+ node_index: number
82
+ ): NodeArrayAttachmentContext {
83
+ const node = svedit.session.get(range.node_id);
84
+ const is_start = node_index === range.start_offset;
85
+ const is_end = node_index === range.end_offset - 1;
86
+
87
+ return {
88
+ ...range,
89
+ index,
90
+ node,
91
+ is_start,
92
+ is_middle: !is_start && !is_end,
93
+ is_end
94
+ };
95
+ }
96
+
97
+ function get_covering_ranges(
98
+ ranges: Attachment[],
99
+ node_index: number
100
+ ): NodeArrayAttachmentContext[] {
101
+ return ranges
102
+ .map((range, index) => ({ range, index }))
103
+ .filter(({ range }) => range.start_offset <= node_index && node_index < range.end_offset)
104
+ .map(({ range, index }) => get_range_context(range, index, node_index));
105
+ }
106
+
107
+ function get_annotation_contexts(node_index: number): NodeArrayAttachmentContext[] {
108
+ return get_covering_ranges(annotations, node_index);
109
+ }
25
110
 
26
- // Mirrors the AnnotatedTextProperty pattern: a `focused` class follows
111
+ function get_mark(node_index: number): NodeArrayAttachmentContext | null {
112
+ // The single mark wrapping this node, or null. Mark exclusivity
113
+ // guarantees at most one. Annotations never wrap and are exposed via
114
+ // `annotations` instead.
115
+ return get_covering_ranges(marks, node_index)[0] ?? null;
116
+ }
117
+
118
+ // Mirrors the TextProperty pattern: a `focused` class follows
27
119
  // the model selection. Used by the CSS rule below to hide the empty-array
28
120
  // NodeGap while the caret is in the placeholder, so its <br> isn't a
29
121
  // second arrow-key stop (issue #260).
@@ -32,9 +124,18 @@
32
124
  serialize_path(svedit.session.selection.path) === path_str
33
125
  );
34
126
 
35
- setContext('node_array_meta', {
127
+ setContext<NodeArrayRenderContext>('node_array_meta', {
36
128
  get length() {
37
129
  return node_ids.length;
130
+ },
131
+ // Lets Node self-serve the marks and annotations covering a child, so
132
+ // node wrappers can carry range classes without every node component
133
+ // having to thread the `mark`/`annotations` props through.
134
+ mark_for(node_index: number) {
135
+ return get_mark(node_index);
136
+ },
137
+ annotations_for(node_index: number) {
138
+ return get_annotation_contexts(node_index);
38
139
  }
39
140
  });
40
141
  </script>
@@ -48,6 +149,7 @@
48
149
  data-path={path_str}
49
150
  style="anchor-name: --{path_str};{style ? ` ${style}` : ''}"
50
151
  {...rest}
152
+ {@attach svedit.visibility_registry.track_array(path_str)}
51
153
  >
52
154
  {#if node_ids.length === 0 && svedit.editable}
53
155
  <div
@@ -55,17 +157,39 @@
55
157
  data-path={serialize_path([...path, 0])}
56
158
  data-type="node"
57
159
  style="anchor-name: --{serialize_path([...path, 0])};"
160
+ {@attach svedit.visibility_registry.track_node(serialize_path([...path, 0]))}
58
161
  >
59
162
  <NodeGap array_path={path} offset={0} count={0} empty />
60
163
  </div>
61
164
  {/if}
62
- {#each nodes as node, index (index)}
63
- <NodeGap array_path={path} offset={index} count={nodes.length} />
64
- {@const Component = svedit.session.config.node_components[node.type]}
65
- {#if Component}
66
- <Component path={[...path, index]} />
67
- {:else}
68
- <UnknownNode path={[...path, index]} />
165
+ {#snippet render_nodes(nodes_slice: NodeId[], start_index: number)}
166
+ {#each nodes_slice as id, slice_index (get_node_key(id, start_index + slice_index))}
167
+ {@const index = start_index + slice_index}
168
+ {@const node = svedit.session.get(id)}
169
+ {@const mark = get_mark(index)}
170
+ {@const annotations = get_annotation_contexts(index)}
171
+ <NodeGap array_path={path} offset={index} count={node_ids.length} />
172
+ {@const Component = svedit.session.config.node_components[node.type]}
173
+ {#if Component}
174
+ <Component path={[...path, index]} {mark} {annotations} />
175
+ {:else}
176
+ <UnknownNode path={[...path, index]} />
177
+ {/if}
178
+ {/each}
179
+ {/snippet}
180
+
181
+ {#each fragments as fragment, fragment_index (fragment_index)}
182
+ {#if fragment.type === 'nodes'}
183
+ {@render render_nodes(fragment.nodes, fragment.start_index)}
184
+ {:else if fragment.type === 'mark'}
185
+ {@const MarkComponent = svedit.session.config.node_components[fragment.node.type]}
186
+ {#if MarkComponent}
187
+ <MarkComponent path={[...path, 'marks', fragment.mark_index, 'node_id']}>
188
+ {@render render_nodes(fragment.nodes, fragment.start_index)}
189
+ </MarkComponent>
190
+ {:else}
191
+ {@render render_nodes(fragment.nodes, fragment.start_index)}
192
+ {/if}
69
193
  {/if}
70
194
  {/each}
71
195
  {#if node_ids.length > 0}
@@ -1,4 +1,4 @@
1
- <script>
1
+ <script lang="ts">
2
2
  /**
3
3
  * Visual insertion caret rendered inside the active insertion marker.
4
4
  *
@@ -1,6 +1,6 @@
1
- <script module>
2
- function cycle_z_index(/** @type {MouseEvent} */ e) {
3
- const el = /** @type {HTMLElement} */ (e.currentTarget);
1
+ <script module lang="ts">
2
+ function cycle_z_index(e: MouseEvent) {
3
+ const el = e.currentTarget as HTMLElement;
4
4
  if (el.hasAttribute('data-sent-to-back')) {
5
5
  document.querySelectorAll('.svedit-selectable[data-sent-to-back]').forEach((prev) => {
6
6
  prev.removeAttribute('data-sent-to-back');
@@ -10,9 +10,11 @@
10
10
  }
11
11
  </script>
12
12
 
13
- <script>
13
+ <script lang="ts">
14
14
  import { serialize_path } from './utils.js';
15
15
  import { getContext } from 'svelte';
16
+ import { should_position_gap } from './node_visibility.svelte.js';
17
+ import type { DocumentPath, SveditRenderContext } from './types.js';
16
18
 
17
19
  /**
18
20
  * ┌─────────────────────────────────────────────────────────────────┐
@@ -37,10 +39,13 @@
37
39
  *
38
40
  * Always present in the DOM when editable (stable structure for
39
41
  * selection anchoring, scrollTo, etc.). Anchor positioning is
40
- * activated lazily via the `.positioned` class, toggled imperatively
41
- * by the IO callback / MO / bootstrap in node_visibility — keeps
42
- * NodeGap allocation-free of per-instance Svelte signals, which
43
- * matters at 1000+ NodeGaps per scroll frame.
42
+ * activated lazily via the `.positioned` class, derived reactively
43
+ * from the visibility registry's array_indices / edge_map. Svelte's
44
+ * reactive collections track reads at per-key granularity, so a
45
+ * registry write only
46
+ * re-evaluates the adjacent gaps. Applying the class declaratively
47
+ * (instead of imperative classList toggles) means it survives DOM
48
+ * recreation without a document change (e.g. dev-mode HMR).
44
49
  *
45
50
  * `position-visibility: anchors-visible` does NOT skip anchor
46
51
  * resolution — the browser resolves all anchor() then hides the
@@ -54,14 +59,35 @@
54
59
  * sure your custom component also handles read-only mode by rendering a
55
60
  * plain `.node-gap` placeholder without editable internals.
56
61
  */
57
- const svedit = getContext('svedit');
58
- let { array_path, offset, count, empty = false } = $props();
62
+ const svedit = getContext<SveditRenderContext>('svedit');
63
+ let {
64
+ array_path,
65
+ offset,
66
+ count,
67
+ empty = false
68
+ }: { array_path: DocumentPath; offset: number; count: number; empty?: boolean } = $props();
59
69
  let is_editable = $derived(svedit.editable);
60
70
 
61
71
  let is_first = $derived(offset === 0);
62
72
  let is_last = $derived(offset === count);
63
73
  let type = $derived(is_first ? 'gap-before' : 'gap-after');
64
74
 
75
+ let path_str = $derived(serialize_path(array_path));
76
+ // Two-derived split is load-bearing: get_array_indices lazily
77
+ // creates the set, and Svelte doesn't track deps on state created
78
+ // inside the reading derived — so acquire here, read in `positioned`.
79
+ let near_indices = $derived(svedit.visibility_registry.get_array_indices(path_str));
80
+ let positioned = $derived(
81
+ is_editable &&
82
+ should_position_gap(
83
+ near_indices,
84
+ svedit.visibility_registry.edge_map.get(path_str),
85
+ offset,
86
+ is_last,
87
+ empty
88
+ )
89
+ );
90
+
65
91
  let gap_style = $derived.by(() => {
66
92
  if (!is_editable) return '';
67
93
  const prev_idx = offset - 1;
@@ -87,8 +113,9 @@
87
113
  class:gap-after={!is_first}
88
114
  class:empty
89
115
  class:last={is_last}
116
+ class:positioned
90
117
  data-type={type}
91
- data-gap-array-path={serialize_path(array_path)}
118
+ data-gap-array-path={path_str}
92
119
  data-gap-offset={offset}
93
120
  style={gap_style}
94
121
  >
@@ -111,9 +138,30 @@
111
138
  }
112
139
 
113
140
  /* ------------------------------------------------------------------ */
114
- /* Un-positioned: minimal in-DOM presence, no anchor resolution */
141
+ /* Un-positioned: no layout box at all */
115
142
  /* ------------------------------------------------------------------ */
116
143
 
144
+ /*
145
+ * display: none is load-bearing for large documents. A zero-size
146
+ * absolutely-positioned selectable still belongs to the containing
147
+ * block's out-of-flow list, and the browser lays out EVERY such box
148
+ * on EVERY layout pass — measured ~430ms per pass at 2000 nodes
149
+ * (~3500 gaps) in Chrome vs ~23ms with the boxes removed. That cost
150
+ * hits every keystroke (the per-change reconcile reads a rect for
151
+ * every node, and selection rendering forces layout), every window
152
+ * resize frame, and every scroll-triggered layout.
153
+ *
154
+ * Off-screen gaps therefore contribute no layout box. DOM structure
155
+ * stays stable (the .node-gap wrapper and selectable elements remain),
156
+ * and DOM Ranges may still point into display:none elements, so
157
+ * programmatic node selections targeting off-screen gaps keep working.
158
+ * Empty-array gaps are excluded: they must stay clickable/visible even
159
+ * before reconcile positions them.
160
+ */
161
+ :global(.node-gap:not(.positioned):not(.empty) .svedit-selectable) {
162
+ display: none;
163
+ }
164
+
117
165
  :global(.node-gap:not(.positioned) .svedit-selectable) {
118
166
  position: absolute;
119
167
  pointer-events: none;
@@ -1,7 +1,8 @@
1
- <script>
1
+ <script lang="ts">
2
2
  import { getContext } from 'svelte';
3
3
  import NodeCaret from './NodeCaret.svelte';
4
4
  import { PATH_SEPARATOR, serialize_path } from './utils.js';
5
+ import type { DocumentPath, SveditRenderContext } from './types.js';
5
6
 
6
7
  /**
7
8
  * ┌─────────────────────────────────────────────────────────────────┐
@@ -28,9 +29,9 @@
28
29
  * re-evaluates when THIS array's visible-index set changes.
29
30
  */
30
31
 
31
- let { path, count } = $props();
32
+ let { path, count }: { path: DocumentPath; count: number } = $props();
32
33
 
33
- const svedit = getContext('svedit');
34
+ const svedit = getContext<SveditRenderContext>('svedit');
34
35
  let path_str = $derived(serialize_path(path));
35
36
  let visible = $derived(svedit.visibility_registry.get_array_indices(path_str));
36
37
 
@@ -51,8 +52,16 @@
51
52
  ? `;--_f:${anchor_prefix}${PATH_SEPARATOR}0;--_s:${anchor_prefix}${PATH_SEPARATOR}1`
52
53
  : '';
53
54
 
54
- /** @type {Array<{key:string,offset:number,type:string,vars:string,is_first:boolean,is_last:boolean,has_pair:boolean}>} */
55
- const result = [];
55
+ type GapMarker = {
56
+ key: string;
57
+ offset: number;
58
+ type: string;
59
+ vars: string;
60
+ is_first: boolean;
61
+ is_last: boolean;
62
+ has_pair: boolean;
63
+ };
64
+ const result: GapMarker[] = [];
56
65
 
57
66
  if (count === 0) {
58
67
  if (visible.has(0)) {
@@ -73,15 +82,15 @@
73
82
  // 1000 nodes with ~30 visible, this drops 1001 SvelteSet/SvelteMap
74
83
  // reads per re-derivation to ~30. Filter stale indices >= count
75
84
  // that may briefly remain after a node delete (until MO fires).
76
- const sorted = [];
85
+ const sorted: number[] = [];
77
86
  for (const i of visible) {
78
87
  if (i >= 0 && i < count) sorted.push(i);
79
88
  }
80
89
  sorted.sort((a, b) => a - b);
81
90
  if (sorted.length === 0) return result;
82
91
 
83
- const offsets = [];
84
- function add_offset(offset) {
92
+ const offsets: number[] = [];
93
+ function add_offset(offset: number) {
85
94
  if (!offsets.includes(offset)) offsets.push(offset);
86
95
  }
87
96
 
@@ -112,7 +121,7 @@
112
121
  ? `${g_prefix}${PATH_SEPARATOR}0-gap-before`
113
122
  : `${g_prefix}${PATH_SEPARATOR}${offset - 1}-gap-after`;
114
123
 
115
- let type, vars;
124
+ let type: string, vars: string;
116
125
  if (is_first || is_last) {
117
126
  type = 'gap-edge';
118
127
  const adjacent = is_first
@@ -646,4 +655,4 @@
646
655
  outline: 1px solid blue;
647
656
  outline-offset: 0.5px;
648
657
  } */
649
- </style>
658
+ </style>
@@ -1,12 +1,13 @@
1
- <script>
1
+ <script lang="ts">
2
2
  import { getContext } from 'svelte';
3
3
  import { serialize_path } from './utils.js';
4
+ import type { DocumentPath, SveditContext } from './types.js';
4
5
 
5
- const svedit = getContext('svedit');
6
+ const svedit = getContext<SveditContext>('svedit');
6
7
  let selected_node_paths = $derived(get_selected_node_paths());
7
8
 
8
- function get_selected_node_paths() {
9
- const paths = [];
9
+ function get_selected_node_paths(): DocumentPath[] | undefined {
10
+ const paths: DocumentPath[] = [];
10
11
  const selection = svedit.session.selection;
11
12
  if (!selection) return;
12
13
  if (selection.type !== 'node' || selection.anchor_offset === selection.focus_offset) return;
@@ -36,10 +37,23 @@
36
37
  <style>
37
38
  .selected-node-overlay,
38
39
  .selected-property-overlay {
40
+ /* Selection frame: a single 1px outline, offset -0.5px so the
41
+ hairline centers on the node's edge and neighboring frames merge
42
+ into one shared line. Alternatives tried and rejected:
43
+ - Plain 1px border: adjacent selected nodes stack their borders
44
+ into a 2px seam between them, which looks ugly.
45
+ - Thick translucent frame (8px border) plus an inner hairline
46
+ outline: multiple nested lines take more cognitive effort to
47
+ parse than a single line.
48
+ - Inset box-shadow rings (1px stroke + 8px translucent): same
49
+ layered-frame look, same objection.
50
+ Performance note: outlines don't participate in layout, so
51
+ selection changes can't trigger reflows. */
39
52
  position: absolute;
40
53
  background: var(--svedit-editing-fill);
41
- border: 1px solid var(--svedit-editing-stroke);
42
- border-radius: 2px;
54
+ outline: 1px solid var(--svedit-editing-stroke);
55
+ outline-offset: -0.5px;
56
+ border-radius: 1px;
43
57
  top: anchor(top);
44
58
  left: anchor(left);
45
59
  bottom: anchor(bottom);