/** * 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") * * Calls onVisibilityChange(bufferSet, visibleSet) whenever either set changes. */ export class ViewportTracker { /** * @param {HTMLElement} root - scroll container (#canvas-container) * @param {HTMLElement[]} pageWrappers - array of .page-wrapper elements * @param {Function} onVisibilityChange - (bufferSet: Set, visibleSet: Set) => void */ constructor(root, pageWrappers, onVisibilityChange) { this._onVisibilityChange = onVisibilityChange; this._visibleSet = new Set(); this._bufferSet = new Set(); this._visibleObserver = null; this._bufferObserver = null; this._observe(root, pageWrappers); } _observe(root, pageWrappers) { // Both observers update their respective sets and then schedule a single // notification via rAF. This prevents a stale notify when the two // observers fire in separate microtasks for the same layout change — // e.g. bufferObserver removes a page from bufferSet before visibleObserver // has had a chance to also remove it from visibleSet, which would let // reconcile() incorrectly tear down a still-visible canvas. this._rafPending = null; const scheduleNotify = () => { 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._onVisibilityChange(new Set(this._bufferSet), new Set(this._visibleSet)); }); }; this._visibleObserver = new IntersectionObserver( entries => { for (const e of entries) { const page = parseInt(e.target.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.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. */ observe(wrap) { this._visibleObserver?.observe(wrap); this._bufferObserver?.observe(wrap); } disconnect() { this._visibleObserver?.disconnect(); this._bufferObserver?.disconnect(); this._visibleSet.clear(); this._bufferSet.clear(); } }