Files
brittle/src-tauri/assets/viewer/viewport-tracker.js

86 lines
3.0 KiB
JavaScript

/**
* 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<number>, visibleSet: Set<number>) => 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();
}
}