import { useEffect, useRef, RefObject, useCallback } from 'react'; const debug = false; type ResizeCallback = () => void; // Define the debounce function with cancel capability // eslint-disable-next-line @typescript-eslint/no-explicit-any function debounce void>( func: T, wait: number ): T & { cancel: () => void } { let timeout: NodeJS.Timeout | null = null; let lastCall = 0; const debounced = function (...args: Parameters): void { 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 (): void { if (timeout) { clearTimeout(timeout); timeout = null; } }; return debounced as T & { cancel: () => void }; } const useResizeObserverAndMutationObserver = ( targetRef: RefObject, scrollToRef: RefObject | null, callback: ResizeCallback ): void => { 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((): void => { debouncedCallback('resize'); }); const mutationObserver = new MutationObserver((): void => { 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 (): void => { 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 = true, fallbackThreshold = 0.33 ): RefObject => { const containerRef = useRef(null); const lastScrollTop = useRef(0); const scrollTimeout = useRef(null); const isUserScrollingUpRef = useRef(false); const checkAndScrollToBottom = useCallback( (isPasteEvent = 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): void => { 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): void => { debug && console.log('Pausing for mouse movement'); handleScroll(ev, 500); }; const pauseClick = (ev: Event): void => { debug && console.log('Pausing for mouse click'); handleScroll(ev, 1000); }; const handlePaste = (): void => { 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, checkAndScrollToBottom]); // Observe container and TextField size, plus DOM changes useResizeObserverAndMutationObserver(containerRef, scrollToRef, checkAndScrollToBottom); return containerRef; }; export { useResizeObserverAndMutationObserver, useAutoScrollToBottom };