import { useRef, useCallback } from 'react'; interface ScrollOptions { duration?: number; easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier'; cubicBezier?: [number, number, number, number]; bottomThreshold?: number; } export function useSnapScroll(options: ScrollOptions = {}) { const { duration = 800, easing = 'ease-in-out', cubicBezier = [0.42, 0, 0.58, 1], bottomThreshold = 50, // pixels from bottom to consider "scrolled to bottom" } = options; const autoScrollRef = useRef(true); const scrollNodeRef = useRef(); const onScrollRef = useRef<() => void>(); const observerRef = useRef(); const animationFrameRef = useRef(); const lastScrollTopRef = useRef(0); const smoothScroll = useCallback( (element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => { const startPosition = element.scrollTop; const distance = targetPosition - startPosition; const startTime = performance.now(); const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1]; const cubicBezierFunction = (t: number): number => { const [, y1, , y2] = bezierPoints; /* * const cx = 3 * x1; * const bx = 3 * (x2 - x1) - cx; * const ax = 1 - cx - bx; */ const cy = 3 * y1; const by = 3 * (y2 - y1) - cy; const ay = 1 - cy - by; // const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t; const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t; return sampleCurveY(t); }; const animation = (currentTime: number) => { const elapsedTime = currentTime - startTime; const progress = Math.min(elapsedTime / duration, 1); const easedProgress = cubicBezierFunction(progress); const newPosition = startPosition + distance * easedProgress; // Only scroll if auto-scroll is still enabled if (autoScrollRef.current) { element.scrollTop = newPosition; } if (progress < 1 && autoScrollRef.current) { animationFrameRef.current = requestAnimationFrame(animation); } }; if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } animationFrameRef.current = requestAnimationFrame(animation); }, [cubicBezier], ); const isScrolledToBottom = useCallback( (element: HTMLDivElement): boolean => { const { scrollTop, scrollHeight, clientHeight } = element; return scrollHeight - scrollTop - clientHeight <= bottomThreshold; }, [bottomThreshold], ); const messageRef = useCallback( (node: HTMLDivElement | null) => { if (node) { const observer = new ResizeObserver(() => { if (autoScrollRef.current && scrollNodeRef.current) { const { scrollHeight, clientHeight } = scrollNodeRef.current; const scrollTarget = scrollHeight - clientHeight; smoothScroll(scrollNodeRef.current, scrollTarget, duration, easing); } }); observer.observe(node); observerRef.current = observer; } else { observerRef.current?.disconnect(); observerRef.current = undefined; if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = undefined; } } }, [duration, easing, smoothScroll], ); const scrollRef = useCallback( (node: HTMLDivElement | null) => { if (node) { onScrollRef.current = () => { const { scrollTop } = node; // Detect scroll direction const isScrollingUp = scrollTop < lastScrollTopRef.current; // Update auto-scroll based on scroll direction and position if (isScrollingUp) { // Disable auto-scroll when scrolling up autoScrollRef.current = false; } else if (isScrolledToBottom(node)) { // Re-enable auto-scroll when manually scrolled to bottom autoScrollRef.current = true; } // Store current scroll position for next comparison lastScrollTopRef.current = scrollTop; }; node.addEventListener('scroll', onScrollRef.current); scrollNodeRef.current = node; } else { if (onScrollRef.current && scrollNodeRef.current) { scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current); } if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = undefined; } scrollNodeRef.current = undefined; onScrollRef.current = undefined; } }, [isScrolledToBottom], ); return [messageRef, scrollRef] as const; }