iloom-flatten/src/components/VirtualList.tsx

295 lines
7.3 KiB
TypeScript
Raw Normal View History

import React, { useRef, useEffect, useState, useCallback } from 'react';
import { motion } from 'framer-motion';
interface VirtualListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
itemHeight: number;
containerHeight: number;
overscan?: number;
className?: string;
onEndReached?: () => void;
endReachedThreshold?: number;
}
export function VirtualList<T>({
items,
renderItem,
itemHeight,
containerHeight,
overscan = 5,
className = '',
onEndReached,
endReachedThreshold = 200,
}: VirtualListProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const totalHeight = items.length * itemHeight;
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
const visibleItems = items.slice(startIndex, endIndex);
const offsetY = startIndex * itemHeight;
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const newScrollTop = e.currentTarget.scrollTop;
setScrollTop(newScrollTop);
setIsScrolling(true);
// Clear previous timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Set new timeout to detect scroll end
scrollTimeoutRef.current = setTimeout(() => {
setIsScrolling(false);
}, 150);
// Check if end is reached
if (onEndReached) {
const scrollHeight = e.currentTarget.scrollHeight;
const clientHeight = e.currentTarget.clientHeight;
const scrollBottom = scrollHeight - newScrollTop - clientHeight;
if (scrollBottom < endReachedThreshold) {
onEndReached();
}
}
},
[onEndReached, endReachedThreshold]
);
useEffect(() => {
return () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, []);
return (
<div
ref={containerRef}
className={`overflow-auto ${className}`}
style={{ height: containerHeight }}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div
style={{
position: 'absolute',
top: offsetY,
left: 0,
right: 0,
}}
>
{visibleItems.map((item, index) => {
const actualIndex = startIndex + index;
return (
<motion.div
key={actualIndex}
initial={isScrolling ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.02 }}
style={{ height: itemHeight }}
>
{renderItem(item, actualIndex)}
</motion.div>
);
})}
</div>
</div>
</div>
);
}
// 简化版虚拟列表(用于卡片布局)
interface VirtualGridProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
itemHeight: number;
containerHeight: number;
columns: number;
gap?: number;
className?: string;
}
export function VirtualGrid<T>({
items,
renderItem,
itemHeight,
containerHeight,
columns,
gap = 16,
className = '',
}: VirtualGridProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const rowHeight = itemHeight + gap;
const totalRows = Math.ceil(items.length / columns);
const totalHeight = totalRows * rowHeight;
const visibleCount = Math.ceil(containerHeight / rowHeight);
const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2);
const endRow = Math.min(
totalRows,
Math.ceil((scrollTop + containerHeight) / rowHeight) + 2
);
const startIndex = startRow * columns;
const endIndex = Math.min(items.length, endRow * columns);
const visibleItems = items.slice(startIndex, endIndex);
const offsetY = startRow * rowHeight;
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
return (
<div
ref={containerRef}
className={`overflow-auto ${className}`}
style={{ height: containerHeight }}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div
style={{
position: 'absolute',
top: offsetY,
left: 0,
right: 0,
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: `${gap}px`,
}}
>
{visibleItems.map((item, index) => {
const actualIndex = startIndex + index;
return (
<div key={actualIndex} style={{ height: itemHeight }}>
{renderItem(item, actualIndex)}
</div>
);
})}
</div>
</div>
</div>
);
}
// 懒加载图片组件
interface LazyImageProps {
src: string;
alt: string;
className?: string;
placeholder?: React.ReactNode;
onLoad?: () => void;
onError?: () => void;
}
export function LazyImage({
src,
alt,
className = '',
placeholder,
onLoad,
onError,
}: LazyImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '50px' }
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
const handleLoad = () => {
setIsLoaded(true);
onLoad?.();
};
const handleError = () => {
onError?.();
};
return (
<div ref={imgRef} className={`relative ${className}`}>
{!isLoaded && placeholder}
{isInView && (
<img
src={src}
alt={alt}
className={`${className} ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
style={{ transition: 'opacity 0.3s' }}
onLoad={handleLoad}
onError={handleError}
/>
)}
</div>
);
}
// 懒加载组件包装器
interface LazyComponentProps {
children: React.ReactNode;
fallback?: React.ReactNode;
threshold?: number;
}
export function LazyComponent({
children,
fallback = null,
threshold = 0,
}: LazyComponentProps) {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [threshold]);
return (
<div ref={ref}>
{isVisible ? children : fallback}
</div>
);
}