/** * ViewportTracker — dual IntersectionObserver for page visibility detection. * * - visibleSet: pages currently on screen (rootMargin "0px") * - bufferSet: pages within ~2 viewport heights above/below (rootMargin "200% 0px") * * Both observers schedule a single rAF-deferred notification to prevent stale * data when the two observers fire in separate microtasks for the same layout * change (e.g. buffer removes a page before visible has also removed it). * * Hard invariant enforced before every callback: every visible page is also * in the buffer set. */ type OnVisibilityChange = ( bufferSet: ReadonlySet, visibleSet: ReadonlySet, ) => void; export class ViewportTracker { private readonly _onChange: OnVisibilityChange; private readonly _visibleSet: Set = new Set(); private readonly _bufferSet: Set = new Set(); private _visibleObserver: IntersectionObserver | null = null; private _bufferObserver: IntersectionObserver | null = null; private _rafPending: number | null = null; constructor( root: HTMLElement, pageWrappers: HTMLElement[], onChange: OnVisibilityChange, ) { this._onChange = onChange; this._observe(root, pageWrappers); } private _observe(root: HTMLElement, pageWrappers: HTMLElement[]): void { const scheduleNotify = (): void => { if (this._rafPending !== null) return; this._rafPending = requestAnimationFrame(() => { this._rafPending = null; // Hard invariant: every visible page must also be in the buffer. for (const p of this._visibleSet) this._bufferSet.add(p); this._onChange(new Set(this._bufferSet), new Set(this._visibleSet)); }); }; this._visibleObserver = new IntersectionObserver(entries => { for (const e of entries) { const page = parseInt((e.target as HTMLElement).dataset["page"]!, 10); if (e.isIntersecting) this._visibleSet.add(page); else this._visibleSet.delete(page); } scheduleNotify(); }, { root, rootMargin: "0px", threshold: 0 }); this._bufferObserver = new IntersectionObserver(entries => { for (const e of entries) { const page = parseInt((e.target as HTMLElement).dataset["page"]!, 10); if (e.isIntersecting) this._bufferSet.add(page); else this._bufferSet.delete(page); } scheduleNotify(); }, { root, rootMargin: "200% 0px", threshold: 0 }); for (const wrap of pageWrappers) { this._visibleObserver.observe(wrap); this._bufferObserver.observe(wrap); } } /** Observe a newly-added page wrapper (unused currently, kept for extensibility). */ observe(wrap: HTMLElement): void { this._visibleObserver?.observe(wrap); this._bufferObserver?.observe(wrap); } disconnect(): void { this._visibleObserver?.disconnect(); this._bufferObserver?.disconnect(); this._visibleSet.clear(); this._bufferSet.clear(); if (this._rafPending !== null) { cancelAnimationFrame(this._rafPending); this._rafPending = null; } } }