import { useCallback, useRef } from 'react'; import { throttle } from '~/.client/utils/throttle'; interface ScrollOptions { duration?: number; easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier'; cubicBezier?: [number, number, number, number]; bottomThreshold?: number; throttleTime?: number; } export function useSnapScroll(options: ScrollOptions = {}) { const { duration = 800, easing = 'ease-in-out', cubicBezier = [0.42, 0, 0.58, 1], bottomThreshold = 50, throttleTime = 200, } = options; const autoScrollRef = useRef(true); const scrollNodeRef = useRef(null); const onScrollRef = useRef<() => void>(); const observerRef = useRef(null); const timeoutRef = useRef(null); const animationFrameRef = useRef(0); const lastScrollTopRef = useRef(0); const throttledSmoothScrollRef = useRef<(...args: any[]) => void>(); 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 (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (progress < 1 && autoScrollRef.current) { timeoutRef.current = setTimeout(() => { animation(performance.now()); }); } }; if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { animation(performance.now()); }); }, [cubicBezier], ); // 创建节流版本的 smoothScroll 函数 useCallback(() => { throttledSmoothScrollRef.current = throttle( (element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => { smoothScroll(element, targetPosition, duration, easingFunction); }, throttleTime, ); }, [smoothScroll, throttleTime])(); 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 && throttledSmoothScrollRef.current) { const { scrollHeight, clientHeight } = scrollNodeRef.current; const scrollTarget = scrollHeight - clientHeight; throttledSmoothScrollRef.current(scrollNodeRef.current, scrollTarget, duration, easing); } }); observer.observe(node); observerRef.current = observer; return; } observerRef.current?.disconnect(); observerRef.current = null; if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = 0; } }, [duration, easing], ); 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 = 0; } scrollNodeRef.current = null; onScrollRef.current = undefined; } }, [isScrolledToBottom], ); return [messageRef, scrollRef] as const; }