ouroboros / source /src /useScrollStory.ts
REXWind's picture
Publish the Ouroboros scientific showcase to CladeTeam
03677d0 verified
Raw History Blame Contribute Delete
3.07 kB
import {useCallback, useEffect, useRef, useState} from 'react';
import type {RefObject} from 'react';
import {clamp} from './dualStory';
export function useScrollStory(section: RefObject<HTMLDivElement | null>, stage: RefObject<HTMLDivElement | null>, 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};
}