|
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, |
|
} = options; |
|
|
|
const autoScrollRef = useRef(true); |
|
const scrollNodeRef = useRef<HTMLDivElement>(); |
|
const onScrollRef = useRef<() => void>(); |
|
const observerRef = useRef<ResizeObserver>(); |
|
const animationFrameRef = useRef<number>(); |
|
const lastScrollTopRef = useRef<number>(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 cy = 3 * y1; |
|
const by = 3 * (y2 - y1) - cy; |
|
const ay = 1 - cy - by; |
|
|
|
|
|
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; |
|
|
|
|
|
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; |
|
|
|
|
|
const isScrollingUp = scrollTop < lastScrollTopRef.current; |
|
|
|
|
|
if (isScrollingUp) { |
|
|
|
autoScrollRef.current = false; |
|
} else if (isScrolledToBottom(node)) { |
|
|
|
autoScrollRef.current = true; |
|
} |
|
|
|
|
|
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; |
|
} |
|
|