Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Implementation to use shadcn scroll area #66

Open
itsHTK opened this issue Jan 17, 2025 · 0 comments
Open

Feat: Implementation to use shadcn scroll area #66

itsHTK opened this issue Jan 17, 2025 · 0 comments

Comments

@itsHTK
Copy link

itsHTK commented Jan 17, 2025

This issue proposes modifications to make the useAutoScroll hook and ChatMessageList component compatible with shadcn ScrollArea while maintaining all existing functionality.

useAutoScroll Hook:

import { useCallback, useEffect, useRef, useState } from "react";

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 containerRef = useRef<HTMLDivElement>(null);
  const lastContentHeight = useRef(0);
  const userHasScrolled = useRef(false);

  const [scrollState, setScrollState] = useState<ScrollState>({
    isAtBottom: true,
    autoScrollEnabled: true,
  });

  // Helper to get the viewport element
  const getViewportElement = useCallback((): HTMLElement | null => {
    if (!containerRef.current) return null;
    return containerRef.current.querySelector('[data-radix-scroll-area-viewport]');
  }, []);

  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) => {
        const viewportElement = getViewportElement();
        if (!viewportElement) return;

        const targetScrollTop =
            viewportElement.scrollHeight - viewportElement.clientHeight;

        if (instant) {
          viewportElement.scrollTop = targetScrollTop;
        } else {
          viewportElement.scrollTo({
            top: targetScrollTop,
            behavior: smooth ? "smooth" : "auto",
          });
        }

        setScrollState({
          isAtBottom: true,
          autoScrollEnabled: true,
        });
        userHasScrolled.current = false;
      },
      [smooth, getViewportElement]
  );

  const handleScroll = useCallback(() => {
    const viewportElement = getViewportElement();
    if (!viewportElement) return;

    const atBottom = checkIsAtBottom(viewportElement);

    setScrollState((prev) => ({
      isAtBottom: atBottom,
      // Re-enable auto-scroll if at the bottom
      autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
    }));
  }, [checkIsAtBottom, getViewportElement]);

  useEffect(() => {
    const viewportElement = getViewportElement();
    if (!viewportElement) return;

    viewportElement.addEventListener("scroll", handleScroll, { passive: true });
    return () => viewportElement.removeEventListener("scroll", handleScroll);
  }, [handleScroll, getViewportElement]);

  useEffect(() => {
    const viewportElement = getViewportElement();
    if (!viewportElement) return;

    const currentHeight = viewportElement.scrollHeight;
    const hasNewContent = currentHeight !== lastContentHeight.current;

    if (hasNewContent) {
      if (scrollState.autoScrollEnabled) {
        requestAnimationFrame(() => {
          scrollToBottom(lastContentHeight.current === 0);
        });
      }
      lastContentHeight.current = currentHeight;
    }
  }, [content, scrollState.autoScrollEnabled, scrollToBottom, getViewportElement]);

  useEffect(() => {
    const viewportElement = getViewportElement();
    if (!viewportElement) return;

    const resizeObserver = new ResizeObserver(() => {
      if (scrollState.autoScrollEnabled) {
        scrollToBottom(true);
      }
    });

    resizeObserver.observe(viewportElement);
    return () => resizeObserver.disconnect();
  }, [scrollState.autoScrollEnabled, scrollToBottom, getViewportElement]);

  const disableAutoScroll = useCallback(() => {
    const viewportElement = getViewportElement();
    const atBottom = viewportElement ? checkIsAtBottom(viewportElement) : false;

    // Only disable if not at bottom
    if (!atBottom) {
      userHasScrolled.current = true;
      setScrollState((prev) => ({
        ...prev,
        autoScrollEnabled: false,
      }));
    }
  }, [checkIsAtBottom, getViewportElement]);

  return {
    containerRef,
    isAtBottom: scrollState.isAtBottom,
    autoScrollEnabled: scrollState.autoScrollEnabled,
    scrollToBottom: () => scrollToBottom(false),
    disableAutoScroll,
  };
}

Modify ChatMessageList component:

import * as React from "react";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {useAutoScroll} from "@/components/ui/chat/hooks/useAutoScroll";

interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
    smooth?: boolean;
}

const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>(
    ({ className, children, smooth = true, ...props }, _ref) => {
        const {
            containerRef,
            isAtBottom,
            autoScrollEnabled,
            scrollToBottom,
            disableAutoScroll,
        } = useAutoScroll({
            smooth,
            content: children,
        });

        return (
            <div className="relative w-full h-full" ref={containerRef}>
                <ScrollArea className={`w-full h-full ${className}`}>
                    <div className="flex flex-col gap-2 p-4">{children}</div>
                </ScrollArea>
                {!isAtBottom && (
                    <Button
                        onClick={scrollToBottom}
                        size="icon"
                        variant="outline"
                        className="absolute bottom-2 left-1/2 transform -translate-x-1/2 inline-flex rounded-full shadow-md"
                        aria-label="Scroll to bottom"
                    >
                        <ArrowDown className="h-4 w-4" />
                    </Button>
                )}
            </div>
        );
    }
);

ChatMessageList.displayName = "ChatMessageList";

export { ChatMessageList };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant