295 lines
7.3 KiB
TypeScript
295 lines
7.3 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|