backstory/frontend/src/hooks/useAutoScrollToBottom.tsx

258 lines
8.2 KiB
TypeScript

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<T extends (...args: any[]) => void>(
func: T,
wait: number
): T & { cancel: () => void } {
let timeout: NodeJS.Timeout | null = null;
let lastCall = 0;
const debounced = function (...args: Parameters<T>): 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<HTMLElement | null>,
scrollToRef: RefObject<HTMLElement | null> | null,
callback: ResizeCallback
): void => {
const callbackRef = useRef(callback);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(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<HTMLElement | null>,
smooth = true,
fallbackThreshold = 0.33
): 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 = 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 };