/** * 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(); } }; }