89 lines
3.1 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|