import { useEffect, useRef, RefObject, useCallback } from 'react'; type ResizeCallback = () => void; const useResizeObserverAndMutationObserver = ( targetRef: RefObject, scrollToRef: RefObject, callback: ResizeCallback ) => { const callbackRef = useRef(callback); const resizeObserverRef = useRef(null); const mutationObserverRef = useRef(null); const debounceTimeout = useRef(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, smooth: boolean = true, fallbackThreshold: number = 0.33, contentUpdateTrigger?: any ): RefObject => { const containerRef = useRef(null); const lastScrollTop = useRef(0); const scrollTimeout = useRef(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; };