import {useCallback, useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; import {clamp} from './dualStory'; export function useScrollStory(section: RefObject, stage: RefObject, enabled: boolean, flow: boolean) { const [progress, setProgress] = useState(0); const current = useRef(0); const commit = useCallback((p: number) => { current.current = p; setProgress(p); }, []); const position = useCallback(() => { if (!section.current || !stage.current) return 0; const top = parseFloat(getComputedStyle(stage.current).top) || 0; const span = section.current.offsetHeight - stage.current.offsetHeight; return span > 0 ? clamp((top - section.current.getBoundingClientRect().top) / span) : 0; }, [section, stage]); useEffect(() => { if (flow || !enabled) return; let frame = 0, last = 0, visible = false, disposed = false; let target = current.current; const stop = () => { cancelAnimationFrame(frame); frame = 0; last = 0; }; const tick = (now: number) => { frame = 0; if (!visible || document.hidden || disposed) return; const dt = last ? Math.min(64, now - last) : 16; last = now; const next = current.current + (target - current.current) * (1 - Math.exp(-dt / 85)); if (Math.abs(target - next) < .00008) { commit(target); last = 0; } else { commit(next); frame = requestAnimationFrame(tick); } }; const update = () => { if (!visible || document.hidden || disposed) return; target = position(); if (!frame && target !== current.current) frame = requestAnimationFrame(tick); }; const visibility = () => { if (document.hidden) stop(); else update(); }; const observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) update(); else stop(); }); if (stage.current) observer.observe(stage.current); const resize = new ResizeObserver(update); if (section.current) resize.observe(section.current); resize.observe(document.body); window.addEventListener('scroll', update, {passive:true}); window.addEventListener('resize', update); document.addEventListener('visibilitychange', visibility); document.fonts.ready.then(update); return () => { disposed = true; stop(); observer.disconnect(); resize.disconnect(); window.removeEventListener('scroll', update); window.removeEventListener('resize', update); document.removeEventListener('visibilitychange', visibility); }; }, [enabled, flow, position, commit, section, stage]); const seek = useCallback((p: number) => { if (!section.current || !stage.current || flow) return; const offset = parseFloat(getComputedStyle(stage.current).top) || 0; const span = section.current.offsetHeight - stage.current.offsetHeight; // Explicit controls remain usable while paused; scrolling never advances a paused scene. commit(clamp(p)); window.scrollTo({top:section.current.getBoundingClientRect().top + scrollY - offset + span * clamp(p), behavior:'instant'}); }, [section, stage, flow, commit]); return {progress, seek}; }