import { useEffect, useRef, RefObject, useCallback } from 'react'; const debug: boolean = false; type ResizeCallback = () => void; // Define the debounce function with cancel capability function debounce void>(func: T, wait: number) { let timeout: NodeJS.Timeout | null = null; let lastCall: number = 0; const debounced = function (...args: Parameters) { const now = Date.now(); // Execute immediately if wait time has passed since last call if (now - lastCall >= wait) { lastCall = now; // Clear any existing timeout to prevent stale executions if (timeout) { clearTimeout(timeout); timeout = null; } func(...args); return; } // Schedule for remaining time if no timeout is pending if (!timeout) { timeout = setTimeout(() => { lastCall = Date.now(); func(...args); timeout = null; }, wait - (now - lastCall)); } }; // Add cancel method to clear pending timeout debounced.cancel = function () { if (timeout) { clearTimeout(timeout); timeout = null; } }; return debounced; } const useResizeObserverAndMutationObserver = ( targetRef: RefObject, scrollToRef: RefObject | null, callback: ResizeCallback ) => { const callbackRef = useRef(callback); const resizeObserverRef = useRef(null); const mutationObserverRef = useRef(null); useEffect(() => { callbackRef.current = callback; }, [callback]); useEffect(() => { const container = targetRef.current; const scrollTo = scrollToRef?.current; if (!container) return; const debouncedCallback = debounce((target: string) => { debug && console.debug(`"debouncedCallback(${target})`); requestAnimationFrame(() => callbackRef.current()); }, 500); const resizeObserver = new ResizeObserver((e: any) => { debouncedCallback("resize"); }); const mutationObserver = new MutationObserver((e: any) => { debouncedCallback("mutation"); }); // 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 () => { debouncedCallback.cancel(); 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. */ 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 (isPasteEvent && !scrollTo) { console.error("Paste Event triggered without scrollTo"); } 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(() => { debug && 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(() => { debug && 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 = (ev: Event, pause?: number) => { const currentScrollTop = container.scrollTop; /* If the user is scrolling up *or* they used the scroll wheel and didn't scroll, * they may be zooming in a region; pause scrolling */ isUserScrollingUpRef.current = (currentScrollTop <= lastScrollTop.current) || pause ? true : false; debug && console.debug(`Scrolling up or paused: ${isUserScrollingUpRef.current} ${pause}`); lastScrollTop.current = currentScrollTop; if (scrollTimeout.current) clearTimeout(scrollTimeout.current); scrollTimeout.current = setTimeout(() => { isUserScrollingUpRef.current = false; debug && console.debug(`Scrolling up: ${isUserScrollingUpRef.current}`); }, pause ? pause : 500); }; const pauseScroll = (ev: Event) => { debug && console.log("Pausing for mouse movement"); handleScroll(ev, 500); } const pauseClick = (ev: Event) => { debug && console.log("Pausing for mouse click"); handleScroll(ev, 1000); } const handlePaste = () => { console.log("handlePaste"); // Delay scroll check to ensure DOM updates setTimeout(() => { console.log("scrolling for handlePaste"); requestAnimationFrame(() => checkAndScrollToBottom(true)); }, 100); }; window.addEventListener('mousemove', pauseScroll); window.addEventListener('mousedown', pauseClick); container.addEventListener('scroll', handleScroll); if (scrollTo) { scrollTo.addEventListener('paste', handlePaste); } checkAndScrollToBottom(); return () => { window.removeEventListener('mousedown', pauseClick); window.removeEventListener('mousemove', pauseScroll); 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; }; export { useResizeObserverAndMutationObserver, useAutoScrollToBottom }