// @hidden import React, { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; interface ScrollState { isAtBottom: boolean; autoScrollEnabled: boolean; } interface UseAutoScrollOptions { offset?: number; smooth?: boolean; content?: React.ReactNode; } export function useAutoScroll(options: UseAutoScrollOptions = {}) { const { offset = 20, smooth = false, content } = options; const scrollRef = useRef(null); const lastContentHeight = useRef(0); const userHasScrolled = useRef(false); const [scrollState, setScrollState] = useState({ isAtBottom: false, autoScrollEnabled: true, }); const checkIsAtBottom = useCallback( (element: HTMLElement) => { const { scrollTop, scrollHeight, clientHeight } = element; const distanceToBottom = Math.abs( scrollHeight - scrollTop - clientHeight, ); return distanceToBottom <= offset; }, [offset], ); const scrollToBottom = useCallback( (instant?: boolean) => { if (scrollRef.current) { const targetScrollTop = scrollRef.current.scrollHeight - scrollRef.current.clientHeight; if (instant) { scrollRef.current.scrollTop = targetScrollTop; } else { scrollRef.current.scrollTo({ top: targetScrollTop, behavior: smooth ? "smooth" : "auto", }); } setScrollState({ isAtBottom: true, autoScrollEnabled: true, }); userHasScrolled.current = false; } }, [smooth], ); const handleScroll = useCallback(() => { if (scrollRef.current) { const atBottom = checkIsAtBottom(scrollRef.current); setScrollState((prev) => ({ isAtBottom: atBottom, // Re-enable auto-scroll if at the bottom autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled, })); } }, [checkIsAtBottom]); useEffect(() => { const element = scrollRef.current; if (element) { element.addEventListener("scroll", handleScroll, { passive: true }); } return () => element ? element.removeEventListener("scroll", handleScroll) : undefined; }, [handleScroll]); useEffect(() => { const scrollElement = scrollRef.current; if (!scrollElement) { return; } const currentHeight = scrollElement.scrollHeight; const hasNewContent = currentHeight !== lastContentHeight.current; if (hasNewContent) { if (scrollState.autoScrollEnabled) { requestAnimationFrame(() => { scrollToBottom(lastContentHeight.current === 0); }); } lastContentHeight.current = currentHeight; } }, [content, scrollState.autoScrollEnabled, scrollToBottom]); useEffect(() => { const resizeObserver = new ResizeObserver(() => { if (scrollState.autoScrollEnabled) { scrollToBottom(true); } }); const element = scrollRef.current; if (element) { resizeObserver.observe(element); } return () => resizeObserver.disconnect(); }, [scrollState.autoScrollEnabled, scrollToBottom]); const disableAutoScroll = useCallback(() => { const atBottom = scrollRef.current ? checkIsAtBottom(scrollRef.current) : false; // Only disable if not at bottom if (!atBottom) { userHasScrolled.current = true; setScrollState((prev) => ({ ...prev, autoScrollEnabled: false, })); } }, [checkIsAtBottom]); return { scrollRef, isAtBottom: scrollState.isAtBottom, autoScrollEnabled: scrollState.autoScrollEnabled, scrollToBottom: () => scrollToBottom(false), disableAutoScroll, }; } export const ScrollAreaWithAutoScroll = ({ children, className, }: { children: React.ReactNode; className?: string; }) => { const { scrollRef } = useAutoScroll({ smooth: false, content: children, }); return (
{children}
); };