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