173 lines
5.8 KiB
TypeScript
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;
|
|
}; |