2025-12-03 21:56:50 +00:00
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|
|
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
|
|
|
|
|
|
|
|
export interface VirtualizedListProps<T> {
|
|
|
|
|
items: T[];
|
|
|
|
|
itemHeight: number;
|
|
|
|
|
containerHeight: number;
|
|
|
|
|
renderItem: (item: T, index: number) => React.ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
overscan?: number;
|
|
|
|
|
onScroll?: (scrollTop: number) => void;
|
|
|
|
|
onItemsRendered?: (startIndex: number, endIndex: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
export const VirtualizedList = React.forwardRef<
|
|
|
|
|
HTMLDivElement,
|
|
|
|
|
VirtualizedListProps<any>
|
|
|
|
|
>((props, ref) => {
|
|
|
|
|
const {
|
|
|
|
|
items,
|
|
|
|
|
itemHeight,
|
|
|
|
|
containerHeight,
|
|
|
|
|
renderItem,
|
|
|
|
|
className = '',
|
|
|
|
|
overscan = 5,
|
|
|
|
|
onScroll,
|
|
|
|
|
onItemsRendered,
|
|
|
|
|
} = props;
|
|
|
|
|
|
|
|
|
|
const internalRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
// Use forwarded ref if available, otherwise internal fallback
|
|
|
|
|
// This is a simple merge strategy: we need the ref internally for virtualizer
|
|
|
|
|
// So we'll assign to both if forwarded ref is object, or call if function.
|
|
|
|
|
// Actually, easiest is to just use one ref and sync or useImperativeHandle.
|
|
|
|
|
// But virtualizer needs a RefObject.
|
|
|
|
|
|
|
|
|
|
// Let's use internalRef as primary and expose it via useImperativeHandle or sync.
|
|
|
|
|
React.useImperativeHandle(ref, () => internalRef.current as HTMLDivElement);
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
2025-12-22 21:56:37 +00:00
|
|
|
const [_isScrolling, setIsScrolling] = useState(false);
|
|
|
|
|
const scrollOffsetRef = useRef(0);
|
|
|
|
|
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
const virtualizer = useVirtualizer({
|
|
|
|
|
count: items.length,
|
2025-12-13 02:34:34 +00:00
|
|
|
getScrollElement: () => internalRef.current,
|
2025-12-03 21:56:50 +00:00
|
|
|
estimateSize: () => itemHeight,
|
|
|
|
|
overscan,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const virtualItems = virtualizer.getVirtualItems();
|
|
|
|
|
|
|
|
|
|
// Handle scroll events with debouncing
|
|
|
|
|
const handleScroll = useCallback(() => {
|
2025-12-22 21:56:37 +00:00
|
|
|
const scrollTop = internalRef.current?.scrollTop || 0;
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
|
|
|
const _isScrolling =
|
|
|
|
|
Math.abs(scrollTop - (scrollOffsetRef.current || 0)) > 0;
|
|
|
|
|
|
|
|
|
|
setIsScrolling(true); // Keep this to trigger the debounced state
|
2025-12-13 02:34:34 +00:00
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
if (scrollTimeoutRef.current) {
|
|
|
|
|
clearTimeout(scrollTimeoutRef.current);
|
|
|
|
|
}
|
2025-12-13 02:34:34 +00:00
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
scrollTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
setIsScrolling(false);
|
|
|
|
|
}, 150);
|
|
|
|
|
|
2025-12-22 21:56:37 +00:00
|
|
|
scrollOffsetRef.current = scrollTop; // Update scroll offset
|
|
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
if (onScroll && internalRef.current) {
|
|
|
|
|
onScroll(internalRef.current.scrollTop);
|
2025-12-03 21:56:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onItemsRendered && virtualItems.length > 0) {
|
2025-12-22 21:56:37 +00:00
|
|
|
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
2025-12-03 21:56:50 +00:00
|
|
|
const endIndex = virtualItems[virtualItems.length - 1].index;
|
|
|
|
|
onItemsRendered(startIndex, endIndex);
|
|
|
|
|
}
|
2025-12-22 21:56:37 +00:00
|
|
|
}, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]); // Added itemHeight, overscan to dependencies
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-12-13 02:34:34 +00:00
|
|
|
const scrollElement = internalRef.current;
|
2025-12-03 21:56:50 +00:00
|
|
|
if (scrollElement) {
|
|
|
|
|
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
|
|
|
|
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
|
|
|
|
}
|
|
|
|
|
}, [handleScroll]);
|
|
|
|
|
|
|
|
|
|
// Cleanup timeout on unmount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (scrollTimeoutRef.current) {
|
|
|
|
|
clearTimeout(scrollTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const totalSize = virtualizer.getTotalSize();
|
|
|
|
|
const paddingTop = virtualItems.length > 0 ? virtualItems[0]?.start || 0 : 0;
|
2025-12-13 02:34:34 +00:00
|
|
|
const paddingBottom =
|
|
|
|
|
totalSize -
|
|
|
|
|
(virtualItems.length > 0
|
|
|
|
|
? virtualItems[virtualItems.length - 1]?.end || 0
|
|
|
|
|
: 0);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2025-12-13 02:34:34 +00:00
|
|
|
ref={internalRef}
|
2025-12-03 21:56:50 +00:00
|
|
|
className={`overflow-auto ${className}`}
|
|
|
|
|
style={{ height: containerHeight }}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
height: totalSize,
|
|
|
|
|
width: '100%',
|
|
|
|
|
position: 'relative',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{paddingTop > 0 && <div style={{ height: paddingTop }} />}
|
|
|
|
|
{virtualItems.map((virtualItem) => (
|
|
|
|
|
<div
|
|
|
|
|
key={virtualItem.key}
|
|
|
|
|
data-index={virtualItem.index}
|
|
|
|
|
ref={virtualizer.measureElement}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: '100%',
|
|
|
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{renderItem(items[virtualItem.index], virtualItem.index)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{paddingBottom > 0 && <div style={{ height: paddingBottom }} />}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-12-13 02:34:34 +00:00
|
|
|
}) as <T>(
|
|
|
|
|
props: VirtualizedListProps<T> & { ref?: React.Ref<HTMLDivElement> },
|
|
|
|
|
) => React.ReactElement;
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
// Hook for infinite scrolling
|
|
|
|
|
export function useInfiniteScroll<T>(
|
|
|
|
|
items: T[],
|
|
|
|
|
hasNextPage: boolean,
|
|
|
|
|
isFetching: boolean,
|
|
|
|
|
fetchNextPage: () => void,
|
2025-12-13 02:34:34 +00:00
|
|
|
threshold: number = 5,
|
2025-12-03 21:56:50 +00:00
|
|
|
) {
|
|
|
|
|
const [isNearBottom, setIsNearBottom] = useState(false);
|
|
|
|
|
|
2025-12-22 21:56:37 +00:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
2025-12-03 21:56:50 +00:00
|
|
|
const handleItemsRendered = useCallback(
|
|
|
|
|
(startIndex: number, endIndex: number) => {
|
|
|
|
|
const isNearEnd = endIndex >= items.length - threshold;
|
|
|
|
|
setIsNearBottom(isNearEnd);
|
|
|
|
|
},
|
2025-12-13 02:34:34 +00:00
|
|
|
[items.length, threshold],
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isNearBottom && hasNextPage && !isFetching) {
|
|
|
|
|
fetchNextPage();
|
|
|
|
|
}
|
|
|
|
|
}, [isNearBottom, hasNextPage, isFetching, fetchNextPage]);
|
|
|
|
|
|
|
|
|
|
return { handleItemsRendered };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hook for scroll position restoration
|
|
|
|
|
export function useScrollPosition(key: string) {
|
|
|
|
|
const [scrollPosition, setScrollPosition] = useState(0);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const saved = sessionStorage.getItem(`scroll-${key}`);
|
|
|
|
|
if (saved) {
|
|
|
|
|
setScrollPosition(parseInt(saved, 10));
|
|
|
|
|
}
|
|
|
|
|
}, [key]);
|
|
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
const saveScrollPosition = useCallback(
|
|
|
|
|
(position: number) => {
|
|
|
|
|
setScrollPosition(position);
|
|
|
|
|
sessionStorage.setItem(`scroll-${key}`, position.toString());
|
|
|
|
|
},
|
|
|
|
|
[key],
|
|
|
|
|
);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
const restoreScrollPosition = useCallback(
|
|
|
|
|
(element: HTMLElement | null) => {
|
|
|
|
|
if (element && scrollPosition > 0) {
|
|
|
|
|
element.scrollTop = scrollPosition;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[scrollPosition],
|
|
|
|
|
);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
return { scrollPosition, saveScrollPosition, restoreScrollPosition };
|
|
|
|
|
}
|