/**
* 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;
// IntersectionObserver only emits on state changes. With a 4-viewport
// preload zone, the sentinel typically stays continuously intersecting
// across a whole fetchNextPage cycle: enabled flips false (fetching),
// the IO callback runs but no-ops, enabled flips back true — and no new
// event is emitted because the intersection state never changed. We'd
// stall mid-pagination. Remember the last reported intersection so the
// next `enabled` rising edge can re-fire manually.
let lastIntersecting = false;
function buildObserver(p: NearBottomParams) {
io?.disconnect();
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
lastIntersecting = e.isIntersecting;
}
if (lastIntersecting && current.enabled) current.onHit();
},
{
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;
const enabledRose = !current.enabled && !!next.enabled;
current = next;
if (rootChanged || preloadChanged) {
buildObserver(current);
return;
}
// `enabled` rising while the sentinel is still in the preload
// zone — no IO event coming, so fire manually.
if (enabledRose && lastIntersecting) current.onHit();
},
destroy() {
io?.disconnect();
}
};
}