258 lines
8.2 KiB
TypeScript
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 };
|