import React from 'react'; import { animate, type AnimationPlaybackControls } from 'motion'; type ScrollEngineOptions = { containerRef: React.RefObject; isMobile: boolean; }; type ScrollOptions = { instant?: boolean; followBottom?: boolean; // Dynamically track bottom during streaming persistFollow?: boolean; }; type ScrollEngineResult = { handleScroll: () => void; scrollToPosition: (position: number, options?: ScrollOptions) => void; forceManualMode: () => void; cancelFollow: () => void; isAtTop: boolean; isFollowingBottom: boolean; isManualOverrideActive: () => boolean; getScrollTop: () => number; getScrollHeight: () => number; getClientHeight: () => number; }; // Spring config for one-shot scroll-to-bottom (button click, session switch). const FAST_SPRING = { type: 'spring' as const, visualDuration: 0.35, bounce: 0, }; // Exponential smoothing factor for the follow-bottom rAF loop. // Each frame: scrollTop += (target - scrollTop) * LERP_FACTOR // ~0.12-0.18 gives a smooth camera-follow feel at 60fps. const LERP_FACTOR = 0.14; // When the remaining distance is below this, snap exactly to bottom. const SNAP_EPSILON = 0.5; const FOLLOW_STABLE_FRAME_LIMIT = 8; export const useScrollEngine = ({ containerRef, }: ScrollEngineOptions): ScrollEngineResult => { const [isAtTop, setIsAtTop] = React.useState(true); const [isFollowingBottom, setIsFollowingBottom] = React.useState(false); const atTopRef = React.useRef(true); const manualOverrideRef = React.useRef(false); // One-shot spring animation (for scroll-to-bottom button etc.) const scrollAnimRef = React.useRef(undefined); // Continuous follow-bottom rAF loop (for streaming) const followRafRef = React.useRef(null); const followActiveRef = React.useRef(false); const followPersistRef = React.useRef(false); const followObserversRef = React.useRef<{ resize: ResizeObserver; mutation: MutationObserver } | null>(null); const cancelSpring = React.useCallback(() => { if (scrollAnimRef.current) { scrollAnimRef.current.stop(); scrollAnimRef.current = undefined; } }, []); const teardownFollowObservers = React.useCallback(() => { const observers = followObserversRef.current; if (!observers) return; observers.resize.disconnect(); observers.mutation.disconnect(); followObserversRef.current = null; }, []); const cancelFollow = React.useCallback(() => { teardownFollowObservers(); if (followRafRef.current !== null && typeof window !== 'undefined') { window.cancelAnimationFrame(followRafRef.current); followRafRef.current = null; } followActiveRef.current = false; followPersistRef.current = false; setIsFollowingBottom(false); }, [teardownFollowObservers]); const cancelAll = React.useCallback(() => { cancelSpring(); cancelFollow(); }, [cancelSpring, cancelFollow]); // One burst of the lerp loop — runs until scrollTop catches up to bottom, then stops. // Re-invoked by observers when persist mode content grows. const runFollowBurst = React.useCallback(() => { if (followActiveRef.current) return; const container = containerRef.current; if (!container) return; followActiveRef.current = true; if (!followPersistRef.current) { setIsFollowingBottom(true); } let stableFrames = 0; const tick = () => { const c = containerRef.current; if (!c || !followActiveRef.current) { followActiveRef.current = false; followRafRef.current = null; if (!followPersistRef.current) { setIsFollowingBottom(false); } return; } const target = c.scrollHeight - c.clientHeight; const current = c.scrollTop; const delta = target - current; if (Math.abs(delta) <= SNAP_EPSILON) { c.scrollTop = target; stableFrames += 1; if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) { followActiveRef.current = false; followRafRef.current = null; if (!followPersistRef.current) { setIsFollowingBottom(false); } return; } followRafRef.current = window.requestAnimationFrame(tick); return; } stableFrames = 0; c.scrollTop = current + delta * LERP_FACTOR; followRafRef.current = window.requestAnimationFrame(tick); }; followRafRef.current = window.requestAnimationFrame(tick); }, [containerRef]); // Observer-driven persist mode — RAF bursts only fire when content actually changes. // No idle CPU cost while waiting for the next token. const setupFollowObservers = React.useCallback(() => { const container = containerRef.current; if (!container || followObserversRef.current) return; const onChange = () => { if (!followPersistRef.current) return; runFollowBurst(); }; const resize = new ResizeObserver(onChange); const inner = container.firstElementChild; if (inner instanceof Element) { resize.observe(inner); } resize.observe(container); const mutation = new MutationObserver(onChange); mutation.observe(container, { childList: true, subtree: true }); followObserversRef.current = { resize, mutation }; }, [containerRef, runFollowBurst]); const startFollowLoop = React.useCallback((persist = false) => { const wasPersist = followPersistRef.current; followPersistRef.current = persist || wasPersist; if (followPersistRef.current) { if (!wasPersist) { setIsFollowingBottom(true); } setupFollowObservers(); } runFollowBurst(); }, [runFollowBurst, setupFollowObservers]); const scrollToPosition = React.useCallback( (position: number, options?: ScrollOptions) => { const container = containerRef.current; if (!container) return; const target = Math.max(0, position); const preferInstant = options?.instant ?? false; const followBottom = options?.followBottom ?? false; const persistFollow = options?.persistFollow ?? false; manualOverrideRef.current = false; // Instant scroll (session switch, etc.) if (typeof window === 'undefined' || preferInstant) { cancelAll(); container.scrollTop = target; if (followBottom && typeof window !== 'undefined') { startFollowLoop(persistFollow); } const atTop = target <= 1; if (atTopRef.current !== atTop) { atTopRef.current = atTop; setIsAtTop(atTop); } return; } // Follow-bottom mode: start the continuous lerp loop if (followBottom) { cancelSpring(); startFollowLoop(persistFollow); return; } // One-shot scroll: stop everything and use spring animation cancelAll(); const distance = Math.abs(target - container.scrollTop); if (distance <= SNAP_EPSILON) { container.scrollTop = target; const atTop = target <= 1; if (atTopRef.current !== atTop) { atTopRef.current = atTop; setIsAtTop(atTop); } return; } scrollAnimRef.current = animate(container.scrollTop, target, { ...FAST_SPRING, onUpdate: (v) => { container.scrollTop = v; }, onComplete: () => { scrollAnimRef.current = undefined; }, }); }, [cancelAll, cancelSpring, containerRef, setIsAtTop, startFollowLoop] ); const forceManualMode = React.useCallback(() => { manualOverrideRef.current = true; }, []); const markManualOverride = React.useCallback(() => { manualOverrideRef.current = true; cancelFollow(); }, [cancelFollow]); const isManualOverrideActive = React.useCallback(() => { return manualOverrideRef.current; }, []); const getScrollTop = React.useCallback(() => { return containerRef.current?.scrollTop ?? 0; }, [containerRef]); const getScrollHeight = React.useCallback(() => { return containerRef.current?.scrollHeight ?? 0; }, [containerRef]); const getClientHeight = React.useCallback(() => { return containerRef.current?.clientHeight ?? 0; }, [containerRef]); const handleScroll = React.useCallback(() => { const container = containerRef.current; if (!container) return; if (manualOverrideRef.current && scrollAnimRef.current) { cancelSpring(); } const atTop = container.scrollTop <= 1; if (atTopRef.current !== atTop) { atTopRef.current = atTop; setIsAtTop(atTop); } }, [cancelSpring, containerRef]); React.useEffect(() => { const container = containerRef.current; if (!container) return; container.addEventListener('wheel', markManualOverride, { passive: true }); container.addEventListener('touchstart', markManualOverride, { passive: true }); return () => { container.removeEventListener('wheel', markManualOverride); container.removeEventListener('touchstart', markManualOverride); }; }, [containerRef, markManualOverride]); React.useEffect(() => { return () => { cancelAll(); }; }, [cancelAll]); return React.useMemo( () => ({ handleScroll, scrollToPosition, forceManualMode, cancelFollow, isAtTop, isFollowingBottom, isManualOverrideActive, getScrollTop, getScrollHeight, getClientHeight, }), [ handleScroll, scrollToPosition, forceManualMode, cancelFollow, isAtTop, isFollowingBottom, isManualOverrideActive, getScrollTop, getScrollHeight, getClientHeight, ] ); }; export type { ScrollEngineResult, ScrollEngineOptions, ScrollOptions };