import { useCallback, useEffect, useRef, useState } from 'react'; /** The nearest ancestor that scrolls vertically (the app's content pane), or the document. */ function scrollParent(el: HTMLElement | null): HTMLElement | null { for (let p = el?.parentElement ?? null; p; p = p.parentElement) { const o = getComputedStyle(p).overflowY; if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p; } return null; } /** * Keeps a section nav in step with scrolling: the active id is the last section whose top has passed the upper * third of the scroll area, or the last section once the area is scrolled to the end. `jump` scrolls to a section * and holds the highlight on it until that scroll settles, so the nav doesn't flicker through the sections in between. */ export function useScrollSpy(ids: readonly string[], prefix: string, initial: string, smooth: boolean) { const [active, setActive] = useState(initial); const hold = useRef<{ id: string; until: number } | null>(null); const key = ids.join('|'); useEffect(() => { const first = document.getElementById(`${prefix}${ids[0]}`); const root = scrollParent(first); const target: HTMLElement | Window = root ?? window; let frame = 0; const update = (): void => { frame = 0; const h = hold.current; if (h && performance.now() < h.until) return; hold.current = null; const top = root ? root.getBoundingClientRect().top : 0; const height = root ? root.clientHeight : window.innerHeight; const atEnd = root ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2 : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2; let current = ids[0]!; if (atEnd) current = ids[ids.length - 1]!; else for (const id of ids) { const el = document.getElementById(`${prefix}${id}`); if (el && el.getBoundingClientRect().top - top <= height / 3) current = id; } setActive(current); }; const onScroll = (): void => { if (!frame) frame = requestAnimationFrame(update); }; const onEnd = (): void => { hold.current = null; onScroll(); }; target.addEventListener('scroll', onScroll, { passive: true }); target.addEventListener('scrollend', onEnd); update(); return () => { target.removeEventListener('scroll', onScroll); target.removeEventListener('scrollend', onEnd); if (frame) cancelAnimationFrame(frame); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, prefix]); const jump = useCallback((id: string, opts?: { instant?: boolean }) => { setActive(id); hold.current = { id, until: performance.now() + 1200 }; document.getElementById(`${prefix}${id}`)?.scrollIntoView({ block: 'start', behavior: smooth && !opts?.instant ? 'smooth' : 'auto' }); }, [prefix, smooth]); return { active, jump }; }