/**
* Fires `onHit` whenever the attached element scrolls near the bottom of
* its scroll container. Mirrors PhotoPrism's infinite-scroll trigger from
* `frontend/src/page/photos.vue`: an IntersectionObserver on a sentinel
* div with a rootMargin equal to ~4 viewport heights, so the next page is
* fetched well before the user actually reaches the end.
*
* Usage: attach to a sentinel
placed at the bottom of the scroll
* area. The host gates calls via `enabled` (= `hasNextPage && !isFetching`).
*
*
*/
export interface NearBottomParams {
onHit: () => void;
/** When false the observer ignores intersections (use for the
* hasNextPage + !isFetchingNextPage gate). */
enabled?: boolean;
/** Pre-load distance in pixels. PhotoPrism uses `innerHeight * 4`;
* we default to the same. Caller can pass a number for tests. */
preloadPx?: number;
/** Optional scroll root (defaults to the viewport). Pass the
* scrolling ancestor when the page itself doesn't scroll, which is
* our case — the timeline scrolls inside ``. */
root?: Element | null;
}
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
let current: NearBottomParams = params;
let io: IntersectionObserver | null = null;
function buildObserver(p: NearBottomParams) {
io?.disconnect();
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
io = new IntersectionObserver(
(entries) => {
if (!current.enabled) return;
for (const e of entries) {
if (e.isIntersecting) {
current.onHit();
return;
}
}
},
{
root: p.root ?? null,
// Inflate the root's bottom edge so we trip well before
// the sentinel actually enters the viewport.
rootMargin: `0px 0px ${preload}px 0px`
}
);
io.observe(node);
}
buildObserver(current);
return {
update(next: NearBottomParams) {
const rootChanged = next.root !== current.root;
const preloadChanged = next.preloadPx !== current.preloadPx;
current = next;
// `enabled` and `onHit` are read live inside the callback,
// so they don't require rebuilding the observer. Root and
// preloadPx are baked in at construction.
if (rootChanged || preloadChanged) buildObserver(current);
},
destroy() {
io?.disconnect();
}
};
}