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
@@ -0,0 +1,658 @@
1
+ /**
2
+ * Dual-observer viewport visibility tracking for node arrays.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * CSS anchor positioning is O(N) — the browser must resolve anchor()
7
+ * functions for every positioned element on every layout pass. Gap
8
+ * markers (NodeGapMarkers) also grow at O(N). At ~200+ nodes this
9
+ * causes frame drops during scroll/resize; at 500+ it's unusable.
10
+ *
11
+ * ## Design principle: structurally stable DOM
12
+ *
13
+ * NodeGap elements are always present in the DOM when editable. The
14
+ * `.positioned` class (which activates anchor() resolution) is gated
15
+ * by viewport visibility. Off-screen NodeGaps remain as zero-size
16
+ * absolute elements with no anchor cost.
17
+ *
18
+ * ## Element registration via attachments
19
+ *
20
+ * Node elements (Node.svelte and the empty-array placeholder) register
21
+ * themselves with `{@attach registry.track_node(path)}`; node-array
22
+ * containers use `{@attach registry.track_array(path)}`. Attachments
23
+ * re-run whenever Svelte recreates the element or its path changes, so
24
+ * observation and per-path state follow the DOM's actual lifecycle —
25
+ * including DOM recreation without a document change (e.g. dev-mode
26
+ * HMR component swaps), which a document-driven reconciliation pass
27
+ * would miss.
28
+ *
29
+ * During keyed updates several elements can change paths at once
30
+ * (e.g. 0→1 while a new element claims 0), and Svelte doesn't
31
+ * guarantee attachment ordering across elements. Per-path ownership
32
+ * maps make unregistration collision-safe: a teardown only clears a
33
+ * path's state if that element still owns the path.
34
+ *
35
+ * ## Two observers
36
+ *
37
+ * **Overscan IO** (`rootMargin: 500px`, threshold `0`) — fills the
38
+ * per-array `array_indices` sets. NodeGap derives its `.positioned`
39
+ * class reactively from them. Registration seeds
40
+ * the state synchronously from one getBoundingClientRect (so there's
41
+ * no unpositioned flash on mount); the IO owns subsequent viewport
42
+ * transitions, including layout-shift-induced ones.
43
+ *
44
+ * **Viewport IO** (`rootMargin: 0`, threshold `[0, 1]`) — toggles
45
+ * view classes (`.in-view`, `.fully-in-view`, …) on node elements.
46
+ * OPT-IN via `session.config.view_classes`. A separate observer is
47
+ * required because the overscan IO fires its thresholds while nodes
48
+ * are still 500px outside the real viewport.
49
+ *
50
+ * **Document scroll listener** (capture, passive) — fills `edge_map`,
51
+ * a per-array `{first, last}` flag indicating whether the leading/
52
+ * trailing node has reached the matching container edge within
53
+ * EDGE_TOLERANCE_PX. IntersectionObserver doesn't re-fire on scrolls
54
+ * inside non-root scroll containers (the intersection ratio doesn't
55
+ * cross any threshold), so this is the only reliable way to track
56
+ * horizontal-overflow scroll position. The handler is hot but cheap:
57
+ * a single `.closest()` early-out (page scroll → null → returns
58
+ * immediately) and a RAF flush that reads only scrollLeft / scrollWidth /
59
+ * clientWidth on the dirty array.
60
+ *
61
+ * **Document changes** — content changes alter an array's scroll
62
+ * extent without recreating the array element, so after Svelte
63
+ * updates the DOM all registered arrays re-sync their edge state in
64
+ * one batch.
65
+ *
66
+ * **Array ResizeObserver** — box-size changes (window resize, sidebar
67
+ * toggle, responsive breakpoint) can flip an array between fitting
68
+ * and overflowing without a scroll event or document change; a shared
69
+ * ResizeObserver on registered arrays re-syncs their edge state.
70
+ *
71
+ * ## Reactivity
72
+ *
73
+ * State lives in per-array SvelteSets (array_indices, the single
74
+ * source of truth for node nearness) and a SvelteMap (edge_map).
75
+ * Consumers (NodeGap and NodeGapMarkers, both via
76
+ * $derived) read via .has()/.get() — Svelte's runtime tracks at
77
+ * per-key granularity, so only consumers whose specific dependencies
78
+ * changed re-evaluate. Because the `.positioned` class is applied
79
+ * declaratively by NodeGap, it survives DOM recreation that happens
80
+ * without a document change (e.g. dev-mode HMR component swaps).
81
+ *
82
+ * View classes are toggled imperatively (no Svelte consumers — they're
83
+ * pure CSS hooks for app code).
84
+ *
85
+ * @module
86
+ */
87
+
88
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
89
+ import { tick, untrack } from 'svelte';
90
+ import { PATH_SEPARATOR } from './utils.js';
91
+
92
+ /**
93
+ * Overscan margin around the viewport (px). Nodes within this distance
94
+ * of the viewport get anchor positioning activated and gap markers
95
+ * computed. 500px provides enough buffer for fast scrolling without
96
+ * a measurable FPS hit.
97
+ */
98
+ const OVERSCAN_PX = 500;
99
+
100
+ /**
101
+ * Distance (in CSS pixels) the leading/trailing edge of an edge node is
102
+ * allowed to overshoot the array container's matching edge while still
103
+ * counting as "at the edge". Picks up small overflow from sub-pixel
104
+ * layout and gives a forgiving hit area near the boundary so a 1-2px
105
+ * jitter from scroll/animation doesn't cause the edge gap to flicker.
106
+ */
107
+ const EDGE_TOLERANCE_PX = 10;
108
+
109
+ /**
110
+ * Single source of truth for "which node DOM elements are near or in
111
+ * the viewport". Owns the IntersectionObservers and the reactive maps
112
+ * consumed by NodeGap and NodeGapMarkers. Elements register through
113
+ * the track_node / track_array attachments.
114
+ */
115
+ class VisibilityRegistry {
116
+ /**
117
+ * Per-array set of near ("in overscan zone") child indices — the
118
+ * single source of truth for node nearness. NodeGap reads via
119
+ * `.has(index)`, NodeGapMarkers iterates the set for its array.
120
+ * Stable references — once a SvelteSet is created for an array path
121
+ * it survives re-renders so consumers stay subscribed.
122
+ *
123
+ */
124
+ #array_indices: Map<string, SvelteSet<number>> = new Map();
125
+
126
+ /**
127
+ * Per-array edge-proximity state. `first` is true when the first
128
+ * node's leading edge is within EDGE_TOLERANCE_PX of the array
129
+ * container's leading edge; `last` is the symmetric trailing-edge
130
+ * check on the last node. Recomputed on scroll, on array box
131
+ * resize and after document changes because IntersectionObserver
132
+ * only fires on intersection-ratio threshold crossings and would
133
+ * otherwise miss horizontal-overflow scroll changes that don't
134
+ * change the visibility ratio.
135
+ *
136
+ * NodeGap and NodeGapMarkers read this to gate edge gaps so they
137
+ * only render when the edge node has actually reached the matching
138
+ * end of its container.
139
+ *
140
+ */
141
+ edge_map: SvelteMap<string, { first: boolean; last: boolean }> = new SvelteMap();
142
+
143
+ #io: IntersectionObserver | null = null;
144
+
145
+ #view_io: IntersectionObserver | null = null;
146
+
147
+ /**
148
+ * Re-syncs edge state when a registered array's box changes size
149
+ * (window resize, sidebar toggle, responsive breakpoint, late-
150
+ * loading content). Size changes can flip an array between fitting
151
+ * and overflowing without firing a scroll event or a document
152
+ * change, so neither of the other two triggers would catch them.
153
+ * Writes are change-detected, so redundant firings are no-ops.
154
+ *
155
+ */
156
+ #array_ro: ResizeObserver | null = null;
157
+
158
+ /**
159
+ * Registered node elements and the path each was registered under.
160
+ * The attachment closure carries the path, so a stale data-path
161
+ * attribute can never desync state.
162
+ *
163
+ */
164
+ #registered_paths: Map<HTMLElement, string> = new Map();
165
+
166
+ /**
167
+ * Which element currently owns a node path. Guards unregistration
168
+ * against keyed-update collisions (see module doc).
169
+ *
170
+ */
171
+ #path_owners: Map<string, HTMLElement> = new Map();
172
+
173
+ #near_nodes: Set<HTMLElement> = new Set();
174
+
175
+ /** Registered node-array container elements. */
176
+ #array_els: Set<Element> = new Set();
177
+
178
+ /** Which element currently owns an array path. */
179
+ #array_owners: Map<string, Element> = new Map();
180
+
181
+ /**
182
+ * When true, a second IO (rootMargin 0, actual viewport) toggles
183
+ * `.in-view` / `.seen` / `.fully-in-view` / `.visible-top` /
184
+ * `.visible-bottom` on node elements — useful for scrollytelling
185
+ * and reveal animations.
186
+ *
187
+ * A separate observer is required because the overscan IO (rootMargin
188
+ * 500px) fires its thresholds while nodes are still outside the real
189
+ * viewport — by the time a node scrolls into view, both thresholds
190
+ * have already been crossed and no further callback fires.
191
+ *
192
+ * Opt out via `session.config.view_classes = false` to skip the
193
+ * second observer entirely.
194
+ */
195
+ view_classes = true;
196
+
197
+ /**
198
+ * When true, the overscan IO populates array_indices and NodeGap gates
199
+ * `.positioned` by viewport proximity. When false, the overscan IO
200
+ * is skipped and every registered node counts as near, so every gap
201
+ * is `.positioned` and every marker renders.
202
+ *
203
+ * Disabling makes anchor positioning O(N) over every node in the
204
+ * document and is intended for debugging only; large documents
205
+ * (hundreds of nodes) can become unresponsive without the culling.
206
+ *
207
+ * Opt out via `session.config.visibility_culling = false`.
208
+ */
209
+ visibility_culling = true;
210
+
211
+ start() {
212
+ if (typeof window === 'undefined') return;
213
+ // Overscan IO: threshold [0] is enough — #process_near only reads
214
+ // entry.isIntersecting (a boolean) and writes are change-detected,
215
+ // so extra firings at threshold[1] would be no-ops.
216
+ if (this.visibility_culling && !this.#io) {
217
+ this.#io = new IntersectionObserver((entries) => this.#process_near(entries), {
218
+ rootMargin: `${OVERSCAN_PX}px`,
219
+ threshold: [0]
220
+ });
221
+ }
222
+ // View IO: threshold [0, 1] is load-bearing — #apply_view_classes
223
+ // toggles .fully-in-view, which flips when the node crosses the
224
+ // fully-visible boundary at ratio 1.
225
+ if (this.view_classes && !this.#view_io) {
226
+ this.#view_io = new IntersectionObserver((entries) => this.#process_view(entries), {
227
+ threshold: [0, 1]
228
+ });
229
+ }
230
+ if (!this.#array_ro) {
231
+ this.#array_ro = new ResizeObserver((entries) => {
232
+ for (const entry of entries) this.sync_edge_state(entry.target);
233
+ });
234
+ }
235
+ }
236
+
237
+ stop() {
238
+ this.#io?.disconnect();
239
+ this.#io = null;
240
+ this.#view_io?.disconnect();
241
+ this.#view_io = null;
242
+ this.#array_ro?.disconnect();
243
+ this.#array_ro = null;
244
+ this.#registered_paths.clear();
245
+ this.#path_owners.clear();
246
+ this.#near_nodes.clear();
247
+ this.#array_els.clear();
248
+ this.#array_owners.clear();
249
+ this.edge_map.clear();
250
+ for (const set of this.#array_indices.values()) set.clear();
251
+ }
252
+
253
+ /**
254
+ * Returns the (lazily created, stable) SvelteSet for an array path.
255
+ * Callers should hold the returned reference for the lifetime of the
256
+ * subscription — the same set is returned on subsequent calls so
257
+ * Svelte's per-set tracking remains intact across re-evaluations.
258
+ *
259
+ * IMPORTANT: hold the returned set in its own $derived and read
260
+ * membership from a different one (see NodeGap / NodeGapMarkers).
261
+ * Svelte does not track dependencies on state created inside the
262
+ * currently-evaluating reaction, so a consumer that lazily creates
263
+ * the set and reads `.has()` in the same derived never subscribes
264
+ * and never re-evaluates.
265
+ *
266
+ */
267
+ get_array_indices(array_path_str: string): SvelteSet<number> {
268
+ let set = this.#array_indices.get(array_path_str);
269
+ if (!set) {
270
+ set = new SvelteSet();
271
+ this.#array_indices.set(array_path_str, set);
272
+ }
273
+ return set;
274
+ }
275
+
276
+ /**
277
+ * Attachment factory for node elements (Node.svelte and the
278
+ * empty-array placeholder). Registers the element under `path`,
279
+ * seeds its near / view-class state synchronously from one BCR, and
280
+ * hands the element to the IntersectionObservers for subsequent
281
+ * viewport transitions. Re-runs when the element is recreated or
282
+ * its path changes; the teardown unregisters collision-safely.
283
+ *
284
+ */
285
+ track_node(path: string) {
286
+ return (el: HTMLElement) => {
287
+ this.#register_node(el, path);
288
+ return () => this.#unregister_node(el, path);
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Attachment factory for node-array container elements. Registered
294
+ * arrays get their edge state synced on mount, on scroll (via the
295
+ * document scroll listener) and after document changes.
296
+ *
297
+ */
298
+ track_array(path: string) {
299
+ return (el: Element) => {
300
+ this.start();
301
+ this.#array_els.add(el);
302
+ this.#array_owners.set(path, el);
303
+ this.#array_ro?.observe(el);
304
+ // Sync synchronously — the RO's initial callback fires a frame
305
+ // later and would flash edge gaps un-positioned on mount.
306
+ this.sync_edge_state(el);
307
+ return () => {
308
+ this.#array_els.delete(el);
309
+ this.#array_ro?.unobserve(el);
310
+ untrack(() => {
311
+ if (this.#array_owners.get(path) === el) {
312
+ this.#array_owners.delete(path);
313
+ this.edge_map.delete(path);
314
+ }
315
+ });
316
+ };
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Whether an element is a registered node-array container of this
322
+ * registry instance. Scopes the document-level scroll listener to
323
+ * this editor without any DOM traversal.
324
+ *
325
+ */
326
+ has_array(el: Element): boolean {
327
+ return this.#array_els.has(el);
328
+ }
329
+
330
+ /**
331
+ * Re-sync edge state for every registered array. Called after
332
+ * document changes: content changes alter scroll extents without
333
+ * recreating the array element, so the attachments can't see them.
334
+ */
335
+ sync_all_edge_states() {
336
+ for (const el of this.#array_els) {
337
+ if (el.isConnected) this.sync_edge_state(el);
338
+ }
339
+ }
340
+
341
+ #register_node(el: HTMLElement, path: string): void {
342
+ this.start();
343
+ untrack(() => {
344
+ this.#registered_paths.set(el, path);
345
+ this.#path_owners.set(path, el);
346
+ this.#io?.observe(el);
347
+ this.#view_io?.observe(el);
348
+
349
+ // Seed state synchronously so freshly mounted (or recreated)
350
+ // elements don't wait a frame for the IO's initial callback —
351
+ // that would flash gaps un-positioned. The IO owns all later
352
+ // transitions.
353
+ const needs_rect = this.visibility_culling || this.view_classes;
354
+ const bcr = needs_rect ? el.getBoundingClientRect() : null;
355
+ const vh = window.innerHeight;
356
+ const vw = window.innerWidth;
357
+ const is_near =
358
+ !this.visibility_culling ||
359
+ (bcr!.bottom > -OVERSCAN_PX &&
360
+ bcr!.top < vh + OVERSCAN_PX &&
361
+ bcr!.right > -OVERSCAN_PX &&
362
+ bcr!.left < vw + OVERSCAN_PX);
363
+ if (is_near) this.#add_near_node(el, path);
364
+
365
+ if (this.view_classes) {
366
+ const in_viewport = bcr!.bottom > 0 && bcr!.top < vh && bcr!.right > 0 && bcr!.left < vw;
367
+ this.#apply_view_classes(el, in_viewport, bcr!, vh);
368
+ }
369
+ });
370
+ }
371
+
372
+ #unregister_node(el: HTMLElement, path: string): void {
373
+ this.#registered_paths.delete(el);
374
+ this.#near_nodes.delete(el);
375
+ this.#io?.unobserve(el);
376
+ this.#view_io?.unobserve(el);
377
+ untrack(() => {
378
+ // Ownership guard: during keyed updates another element may
379
+ // have already claimed this path — its state must survive.
380
+ if (this.#path_owners.get(path) === el) {
381
+ this.#path_owners.delete(path);
382
+ const split = this.#split_path(path);
383
+ if (split) this.#array_indices.get(split.array_path)?.delete(split.index);
384
+ }
385
+ });
386
+ }
387
+
388
+ #add_near_node(el: HTMLElement, path: string): void {
389
+ this.#near_nodes.add(el);
390
+ const split = this.#split_path(path);
391
+ if (split) this.get_array_indices(split.array_path).add(split.index);
392
+ }
393
+
394
+ #remove_near_node(el: HTMLElement, path: string): void {
395
+ this.#near_nodes.delete(el);
396
+ if (this.#path_owners.get(path) !== el) return;
397
+ const split = this.#split_path(path);
398
+ if (split) this.#array_indices.get(split.array_path)?.delete(split.index);
399
+ }
400
+
401
+ /**
402
+ * Update edge_map for an array from its scrollLeft/scrollWidth/
403
+ * clientWidth (and the y-axis equivalents). Returns true when the
404
+ * state actually changed. Uses only the array's own scroll metrics
405
+ * — no BCRs, no per-node measurements, no querying children — so
406
+ * each call is ~6 property reads on one element regardless of how
407
+ * many nodes the array has.
408
+ *
409
+ * For a non-overflowing array (scrollWidth === clientWidth, etc.)
410
+ * both checks pass, so first/last gaps stay visible as expected.
411
+ * For an overflowing array, first is true only when scrolled to
412
+ * the start and last only when scrolled to the end (within
413
+ * EDGE_TOLERANCE_PX).
414
+ *
415
+ * Writes to edge_map are wrapped in untrack() so this can be
416
+ * called transitively from $effect contexts without self-
417
+ * invalidation.
418
+ *
419
+ */
420
+ sync_edge_state(array_el: Element): boolean {
421
+ if (!array_el) return false;
422
+ const path = (array_el as HTMLElement).dataset.path;
423
+ if (!path) return false;
424
+
425
+ // Check computed overflow BEFORE touching scroll metrics. Reading
426
+ // scrollLeft/scrollWidth forces a synchronous layout when the DOM is
427
+ // dirty, while getComputedStyle only forces style recalc. Non-clipping
428
+ // arrays — the common case (a plain column body) — resolve to
429
+ // first=last=true without any scroll-metric read.
430
+ const style = getComputedStyle(array_el);
431
+ const clips_x = style.overflowX !== 'visible';
432
+ const clips_y = style.overflowY !== 'visible';
433
+
434
+ // Only use scroll metrics on axes where this array actually clips/scrolls.
435
+ // Normal wrapping/grid node arrays often have harmless layout overflow
436
+ // (scrollWidth > clientWidth) while overflow remains visible; treating
437
+ // that as scroll position would incorrectly hide edge gaps.
438
+ let first = true;
439
+ let last = true;
440
+ if (clips_x) {
441
+ const sl = array_el.scrollLeft;
442
+ first = sl <= EDGE_TOLERANCE_PX;
443
+ last = sl + array_el.clientWidth >= array_el.scrollWidth - EDGE_TOLERANCE_PX;
444
+ }
445
+ if (clips_y) {
446
+ const st = array_el.scrollTop;
447
+ first = first && st <= EDGE_TOLERANCE_PX;
448
+ last = last && st + array_el.clientHeight >= array_el.scrollHeight - EDGE_TOLERANCE_PX;
449
+ }
450
+
451
+ return untrack(() => {
452
+ const prev = this.edge_map.get(path);
453
+ if (prev && prev.first === first && prev.last === last) return false;
454
+ this.edge_map.set(path, { first, last });
455
+ return true;
456
+ });
457
+ }
458
+
459
+ #apply_view_classes(
460
+ el: HTMLElement,
461
+ in_viewport: boolean,
462
+ bcr: DOMRectReadOnly,
463
+ vh: number
464
+ ): void {
465
+ const cl = el.classList;
466
+ cl.toggle('in-view', in_viewport);
467
+ if (in_viewport) {
468
+ cl.add('seen');
469
+ const top_clipped = bcr.top < -0.5;
470
+ const bottom_clipped = bcr.bottom > vh + 0.5;
471
+ cl.toggle('fully-in-view', !top_clipped && !bottom_clipped);
472
+ cl.toggle('visible-top', !top_clipped && bottom_clipped);
473
+ cl.toggle('visible-bottom', top_clipped && !bottom_clipped);
474
+ } else {
475
+ cl.remove('fully-in-view', 'visible-top', 'visible-bottom');
476
+ }
477
+ }
478
+
479
+ #split_path(path: string): { array_path: string; index: number } | null {
480
+ const sep = path.lastIndexOf(PATH_SEPARATOR);
481
+ if (sep < 0) return null;
482
+ const index = parseInt(path.slice(sep + PATH_SEPARATOR.length), 10);
483
+ if (Number.isNaN(index)) return null;
484
+ return { array_path: path.slice(0, sep), index };
485
+ }
486
+
487
+ /**
488
+ * Overscan IO callback. Manages the array_indices sets; NodeGap
489
+ * reacts to the map writes and updates .positioned declaratively.
490
+ *
491
+ */
492
+ #process_near(entries: IntersectionObserverEntry[]): void {
493
+ for (const entry of entries) {
494
+ const el = entry.target as HTMLElement;
495
+ const path = this.#registered_paths.get(el);
496
+ if (!path || !el.isConnected) continue;
497
+
498
+ const is_near = entry.isIntersecting;
499
+ if (is_near === this.#near_nodes.has(el)) continue;
500
+ if (is_near) this.#add_near_node(el, path);
501
+ else this.#remove_near_node(el, path);
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Viewport IO callback. Toggles view classes on node elements.
507
+ * Runs on a separate observer with rootMargin 0 so thresholds
508
+ * fire at actual viewport edges, not the overscan boundary.
509
+ *
510
+ */
511
+ #process_view(entries: IntersectionObserverEntry[]): void {
512
+ const vh = window.innerHeight;
513
+ for (const entry of entries) {
514
+ const el = entry.target as HTMLElement;
515
+ if (!this.#registered_paths.has(el) || !el.isConnected) continue;
516
+ this.#apply_view_classes(el, entry.isIntersecting, entry.boundingClientRect, vh);
517
+ }
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Install the visibility registry on the svedit context.
523
+ *
524
+ * Elements register themselves via the track_node / track_array
525
+ * attachments, so registration follows the DOM's actual lifecycle.
526
+ * The IntersectionObservers own viewport changes; document changes
527
+ * only trigger an edge-state re-sync over the registered arrays.
528
+ *
529
+ * @param svedit - Svedit context (session, editable)
530
+ */
531
+ export function create_node_visibility(svedit: {
532
+ session: {
533
+ config?: { view_classes?: boolean; visibility_culling?: boolean };
534
+ doc: unknown;
535
+ };
536
+ editable: boolean;
537
+ visibility_registry?: VisibilityRegistryApi;
538
+ }): void {
539
+ const registry = new VisibilityRegistry();
540
+ registry.view_classes = svedit.session?.config?.view_classes !== false;
541
+ registry.visibility_culling = svedit.session?.config?.visibility_culling !== false;
542
+ svedit.visibility_registry = registry;
543
+
544
+ $effect(() => {
545
+ if (typeof window === 'undefined') return;
546
+ registry.start();
547
+
548
+ /**
549
+ * Scroll listener for nested scroll containers. IntersectionObserver
550
+ * doesn't reliably re-fire when the user scrolls inside a non-root
551
+ * scrollable element (Chrome's IO samples on viewport scroll, not on
552
+ * intermediate scroll-container scrolls), so we listen for scroll
553
+ * events on the document in capture phase. The handler is hot but
554
+ * cheap: a single `.closest()` check exits in O(1) for the common
555
+ * case (page scroll → target is document/body, not an array) and
556
+ * coalesces array scrolls into one RAF that reads only scrollLeft /
557
+ * scrollWidth / clientWidth — no per-node BCRs.
558
+ */
559
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive RAF queue.
560
+ const pending_scroll_arrays: Set<Element> = new Set();
561
+ let scroll_raf = 0;
562
+ function flush_scroll_sync() {
563
+ scroll_raf = 0;
564
+ for (const arr of pending_scroll_arrays) {
565
+ if (arr.isConnected) registry.sync_edge_state(arr);
566
+ }
567
+ pending_scroll_arrays.clear();
568
+ }
569
+ function on_scroll(event: Event) {
570
+ const target = event.target as Element | null;
571
+ const arr = target?.closest?.('[data-type="node_array"]');
572
+ if (!arr || !registry.has_array(arr)) return;
573
+ pending_scroll_arrays.add(arr);
574
+ if (!scroll_raf) scroll_raf = requestAnimationFrame(flush_scroll_sync);
575
+ }
576
+ document.addEventListener('scroll', on_scroll, { capture: true, passive: true });
577
+
578
+ return () => {
579
+ document.removeEventListener('scroll', on_scroll, true);
580
+ cancelAnimationFrame(scroll_raf);
581
+ pending_scroll_arrays.clear();
582
+ registry.stop();
583
+ };
584
+ });
585
+
586
+ $effect(() => {
587
+ if (typeof window === 'undefined') return;
588
+
589
+ // Structural inputs whose changes can alter array scroll extents
590
+ // without recreating array elements. Selection intentionally does
591
+ // not participate; NodeGapMarkers tracks it directly for caret state.
592
+ svedit.session.doc;
593
+ svedit.editable;
594
+
595
+ let cancelled = false;
596
+ tick().then(() => {
597
+ if (!cancelled) registry.sync_all_edge_states();
598
+ });
599
+
600
+ return () => {
601
+ cancelled = true;
602
+ };
603
+ });
604
+ }
605
+
606
+ export type VisibilityRegistryApi = {
607
+ edge_map: SvelteMap<string, { first: boolean; last: boolean }>;
608
+ get_array_indices(array_path_str: string): SvelteSet<number>;
609
+ track_node(path: string): (el: HTMLElement) => () => void;
610
+ track_array(path: string): (el: Element) => () => void;
611
+ };
612
+
613
+ /**
614
+ * Pure visibility check for a gap's `.positioned` class. NodeGap calls
615
+ * this from a $derived, so the `near.has()` reads are tracked at
616
+ * per-key granularity — a gap re-evaluates only when its own
617
+ * dependencies change. Applying the class declaratively means it
618
+ * survives DOM recreation without a document change (e.g. dev-mode
619
+ * HMR component swaps).
620
+ *
621
+ * `near` must come from `get_array_indices` held in a SEPARATE
622
+ * $derived (see the note there): membership reads on a set created
623
+ * within the same derived are not tracked by Svelte.
624
+ *
625
+ * @param near - near child indices of the array
626
+ */
627
+ export function should_position_gap(
628
+ near: SvelteSet<number>,
629
+ edge_state: { first: boolean; last: boolean } | undefined,
630
+ offset: number,
631
+ is_last: boolean,
632
+ empty: boolean
633
+ ): boolean {
634
+ if (!near) return false;
635
+
636
+ if (empty) {
637
+ return near.has(0);
638
+ }
639
+
640
+ // Edge gaps gate on nearness AND edge state. edge_state is the
641
+ // scroll-aware check that the adjacent edge node has actually
642
+ // reached the matching end of its array container (within
643
+ // EDGE_TOLERANCE_PX). We can't rely on the IO here because it only
644
+ // fires on intersection-ratio threshold crossings and misses scroll
645
+ // changes within a horizontal-overflow container that don't change
646
+ // the visibility ratio.
647
+ if (offset === 0) {
648
+ if (!near.has(0)) return false;
649
+ return edge_state?.first === true;
650
+ }
651
+
652
+ if (is_last) {
653
+ if (!near.has(offset - 1)) return false;
654
+ return edge_state?.last === true;
655
+ }
656
+
657
+ return near.has(offset - 1) && near.has(offset);
658
+ }