core/apps/webapp/app/components/use-auto-scroll.tsx
Harshith Mullapudi 54e535d57d
Feat: v2 (#12)
* Feat: v2

* feat: add chat functionality

* First cut: integrations

* Feat: add conversation API

* Enhance conversation handling and memory management

* Feat: added conversation

---------

Co-authored-by: Manoj K <saimanoj58@gmail.com>
2025-07-08 22:41:00 +05:30

170 lines
4.1 KiB
TypeScript

// @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<HTMLDivElement>(null);
const lastContentHeight = useRef(0);
const userHasScrolled = useRef(false);
const [scrollState, setScrollState] = useState<ScrollState>({
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: true,
content: children,
});
return (
<div
ref={scrollRef}
className={cn(
"flex grow flex-col items-center overflow-y-auto",
className,
)}
>
<div className="flex h-full w-full max-w-[97ch] flex-col pb-4">
{children}
</div>
</div>
);
};