backstory/frontend/src/useAutoScrollToBottom.tsx

173 lines
5.8 KiB
TypeScript

import { useEffect, useRef, RefObject, useCallback } from 'react';
type ResizeCallback = () => void;
const useResizeObserverAndMutationObserver = (
targetRef: RefObject<HTMLElement | null>,
scrollToRef: RefObject<HTMLElement | null>,
callback: ResizeCallback
) => {
const callbackRef = useRef(callback);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
const container = targetRef.current;
const scrollTo = scrollToRef.current;
if (!container) return;
const debouncedCallback = (entries: ResizeObserverEntry[] | undefined) => {
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(() => {
requestAnimationFrame(() => callbackRef.current());
}, 50);
};
const resizeObserver = new ResizeObserver(debouncedCallback);
const mutationObserver = new MutationObserver(() => { debouncedCallback(undefined); });
// Observe container size
resizeObserver.observe(container);
// Observe TextField size if available
if (scrollTo) {
resizeObserver.observe(scrollTo);
}
// Observe child additions/removals and text content changes
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
resizeObserverRef.current = resizeObserver;
mutationObserverRef.current = mutationObserver;
return () => {
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [targetRef, scrollToRef]);
};
/**
* Auto-scroll hook for scrollable containers.
* Scrolls to the bottom of the container on paste or when TextField is fully/partially visible.
*/
export const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>,
smooth: boolean = true,
fallbackThreshold: number = 0.33,
contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null);
const lastScrollTop = useRef(0);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = false) => {
const container = containerRef.current;
if (!container) return;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = (isPasteEvent || isTextFieldVisible) && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
console.debug('Scrolling to container bottom:', {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
}
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
console.debug('Scrolling to container bottom (fallback):', { scrollHeight });
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
}
}
}, [fallbackThreshold, smooth, scrollToRef]);
useEffect(() => {
const container = containerRef.current;
const scrollTo = scrollToRef.current;
if (!container) return;
const handleScroll = () => {
const currentScrollTop = container.scrollTop;
isUserScrollingUpRef.current = currentScrollTop < lastScrollTop.current;
lastScrollTop.current = currentScrollTop;
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
scrollTimeout.current = setTimeout(() => {
isUserScrollingUpRef.current = false;
}, 500);
};
const handlePaste = () => {
// Delay scroll check to ensure DOM updates
setTimeout(() => {
requestAnimationFrame(() => checkAndScrollToBottom(true));
}, 0);
};
container.addEventListener('scroll', handleScroll);
if (scrollTo) {
scrollTo.addEventListener('paste', handlePaste);
}
checkAndScrollToBottom();
return () => {
container.removeEventListener('scroll', handleScroll);
if (scrollTo) {
scrollTo.removeEventListener('paste', handlePaste);
}
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
};
}, [smooth, scrollToRef, fallbackThreshold, contentUpdateTrigger, checkAndScrollToBottom]);
// Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, scrollToRef, checkAndScrollToBottom);
return containerRef;
};