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