146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
import { forwardRef, useEffect, useRef, useState } from "react";
|
|
import type { ImageData } from "../types.ts";
|
|
|
|
interface ImageCarouselProps {
|
|
images: ImageData["images"];
|
|
currentIndex: number;
|
|
onTogglePlay: () => void;
|
|
}
|
|
|
|
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
|
({ images, currentIndex, onTogglePlay }, ref) => {
|
|
const [offset, setOffset] = useState(0);
|
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
|
const playedVideos = useRef<Set<string>>(new Set());
|
|
|
|
// 虚拟滚动:只渲染当前图片和前后各一张
|
|
const visibleIndices = (() => {
|
|
const indices: number[] = [];
|
|
if (currentIndex > 0) indices.push(currentIndex - 1);
|
|
indices.push(currentIndex);
|
|
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
|
|
return indices;
|
|
})();
|
|
|
|
// 当 currentIndex 变化时,触发滚动动画
|
|
useEffect(() => {
|
|
setIsTransitioning(true);
|
|
setOffset(-currentIndex * 100);
|
|
|
|
const timer = setTimeout(() => {
|
|
setIsTransitioning(false);
|
|
}, 300); // 与 CSS transition 时间匹配
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [currentIndex]);
|
|
|
|
// 管理动图播放:进入视口时播放一次
|
|
useEffect(() => {
|
|
const currentImage = images[currentIndex];
|
|
if (!currentImage?.animated) return;
|
|
|
|
const videoKey = currentImage.id;
|
|
const videoEl = videoRefs.current.get(videoKey);
|
|
|
|
if (videoEl) {
|
|
// 检查是否已经播放过
|
|
if (!playedVideos.current.has(videoKey)) {
|
|
// 重置并播放
|
|
videoEl.currentTime = 0;
|
|
videoEl.play().catch(() => {});
|
|
playedVideos.current.add(videoKey);
|
|
} else {
|
|
// 已播放过,重置到开头但不自动播放
|
|
videoEl.currentTime = 0;
|
|
videoEl.play().catch(() => {});
|
|
}
|
|
}
|
|
}, [currentIndex, images]);
|
|
|
|
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
|
|
useEffect(() => {
|
|
const currentImage = images[currentIndex];
|
|
if (currentImage?.animated) {
|
|
const videoKey = currentImage.id;
|
|
|
|
// 清除其他视频的播放记录
|
|
playedVideos.current.forEach(key => {
|
|
if (key !== videoKey) {
|
|
playedVideos.current.delete(key);
|
|
}
|
|
});
|
|
}
|
|
}, [currentIndex, images]);
|
|
|
|
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
|
|
if (el) {
|
|
videoRefs.current.set(imageId, el);
|
|
} else {
|
|
videoRefs.current.delete(imageId);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="relative h-full w-full overflow-hidden"
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
className="flex h-full w-full"
|
|
style={{
|
|
transform: `translateX(${offset}%)`,
|
|
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
|
|
}}
|
|
>
|
|
{visibleIndices.map((i) => {
|
|
const img = images[i];
|
|
return (
|
|
<div
|
|
key={img.id}
|
|
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
|
style={{
|
|
transform: `translateX(${i * 100}%)`,
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: 0,
|
|
width: '100%',
|
|
}}
|
|
onClick={onTogglePlay}
|
|
>
|
|
{img.animated ? (
|
|
<video
|
|
ref={(el) => handleVideoRef(el, img.id)}
|
|
src={img.animated}
|
|
muted
|
|
playsInline
|
|
className="max-w-full max-h-full object-contain"
|
|
onEnded={(e) => {
|
|
// 播放结束后停留在最后一帧
|
|
e.currentTarget.pause();
|
|
}}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={img.url}
|
|
alt={`image-${i + 1}`}
|
|
className="max-w-full max-h-full object-contain"
|
|
style={{
|
|
width: img.width ? `${img.width}px` : undefined,
|
|
height: img.height ? `${img.height}px` : undefined,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
ImageCarousel.displayName = "ImageCarousel";
|