bolt.diy/app/lib/hooks/useSnapScroll.ts
Anirban Kar a0ea69fd74
Some checks failed
Docker Publish / docker-build-publish (push) Has been cancelled
Update Stable Branch / prepare-release (push) Has been cancelled
fix: auto scroll fix, scroll allow user to scroll up during ai response (#1299)
2025-02-12 01:11:14 +05:30

156 lines
4.8 KiB
TypeScript

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<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 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;
}