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