Files
brittle/src-tauri/assets/viewer-src/src/viewport-tracker.ts
2026-04-03 18:45:08 +02:00

89 lines
3.1 KiB
TypeScript

/**
* 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<number>,
visibleSet: ReadonlySet<number>,
) => void;
export class ViewportTracker {
private readonly _onChange: OnVisibilityChange;
private readonly _visibleSet: Set<number> = new Set();
private readonly _bufferSet: Set<number> = 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;
}
}
}