"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { ChevronLeft, ChevronRight, Download, X } from "lucide-react"; import { cn } from "@/lib/utils"; type LightboxImage = { id: string; src: string; sizeLabel?: string; dimensions?: string; }; type ImageLightboxProps = { images: LightboxImage[]; currentIndex: number; open: boolean; onOpenChange: (open: boolean) => void; onIndexChange: (index: number) => void; }; type ImageTransform = { scale: number; x: number; y: number; }; type TouchGesture = | { type: "swipe"; startX: number; startY: number; } | { type: "pan"; startX: number; startY: number; startTransform: ImageTransform; } | { type: "pinch"; startDistance: number; startCenterX: number; startCenterY: number; startTransform: ImageTransform; }; const minScale = 1; const maxScale = 4; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function getTouchDistance(touches: TouchList) { const first = touches[0]; const second = touches[1]; return Math.hypot(first.clientX - second.clientX, first.clientY - second.clientY); } function getTouchCenter(touches: TouchList) { const first = touches[0]; const second = touches[1]; return { x: (first.clientX + second.clientX) / 2, y: (first.clientY + second.clientY) / 2, }; } function normalizeTransform(transform: ImageTransform) { if (transform.scale <= minScale) { return { scale: minScale, x: 0, y: 0 }; } const maxX = window.innerWidth * (transform.scale - 1) * 0.5; const maxY = window.innerHeight * (transform.scale - 1) * 0.5; return { scale: transform.scale, x: clamp(transform.x, -maxX, maxX), y: clamp(transform.y, -maxY, maxY), }; } export function ImageLightbox({ images, currentIndex, open, onOpenChange, onIndexChange, }: ImageLightboxProps) { const gestureRef = useRef(null); const lastTapRef = useRef(0); const pendingTransformRef = useRef(null); const rafRef = useRef(null); const [transform, setTransform] = useState({ scale: 1, x: 0, y: 0 }); const [isGesturing, setIsGesturing] = useState(false); const current = images[currentIndex]; const hasPrev = currentIndex > 0; const hasNext = currentIndex < images.length - 1; const cancelScheduledTransform = useCallback(() => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } pendingTransformRef.current = null; }, []); const scheduleTransform = useCallback((next: ImageTransform) => { pendingTransformRef.current = next; if (rafRef.current != null) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = null; const pending = pendingTransformRef.current; pendingTransformRef.current = null; if (pending) { setTransform(pending); } }); }, []); const flushScheduledTransform = useCallback(() => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } const pending = pendingTransformRef.current; pendingTransformRef.current = null; if (pending) { setTransform(pending); } }, []); const resetTransform = useCallback(() => { cancelScheduledTransform(); setTransform({ scale: 1, x: 0, y: 0 }); setIsGesturing(false); gestureRef.current = null; }, [cancelScheduledTransform]); const goPrev = useCallback(() => { if (hasPrev) onIndexChange(currentIndex - 1); }, [hasPrev, currentIndex, onIndexChange]); const goNext = useCallback(() => { if (hasNext) onIndexChange(currentIndex + 1); }, [hasNext, currentIndex, onIndexChange]); useEffect(() => { resetTransform(); }, [current?.id, open, resetTransform]); useEffect(() => { return () => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; }, []); useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") { e.preventDefault(); goPrev(); } else if (e.key === "ArrowRight") { e.preventDefault(); goNext(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [open, goPrev, goNext]); const handleDownload = useCallback(() => { if (!current) return; const link = document.createElement("a"); link.href = current.src; link.download = `image-${current.id}.png`; link.click(); }, [current]); const toggleZoom = useCallback(() => { setTransform((currentTransform) => currentTransform.scale > minScale ? { scale: 1, x: 0, y: 0 } : { scale: 2.5, x: 0, y: 0 }, ); }, []); const handleTouchStart = useCallback( (event: React.TouchEvent) => { if (event.touches.length === 2) { event.preventDefault(); const startDistance = getTouchDistance(event.touches); if (startDistance < 1) { gestureRef.current = null; return; } const center = getTouchCenter(event.touches); cancelScheduledTransform(); setIsGesturing(true); gestureRef.current = { type: "pinch", startDistance, startCenterX: center.x, startCenterY: center.y, startTransform: transform, }; return; } if (event.touches.length !== 1) { gestureRef.current = null; return; } const touch = event.touches[0]; if (transform.scale > minScale) { cancelScheduledTransform(); setIsGesturing(true); gestureRef.current = { type: "pan", startX: touch.clientX, startY: touch.clientY, startTransform: transform, }; } else { gestureRef.current = { type: "swipe", startX: touch.clientX, startY: touch.clientY, }; } }, [transform, cancelScheduledTransform], ); const handleTouchMove = useCallback( (event: React.TouchEvent) => { const gesture = gestureRef.current; if (!gesture) return; if (gesture.type === "pinch" && event.touches.length === 2) { event.preventDefault(); const targetScale = clamp( (getTouchDistance(event.touches) / gesture.startDistance) * gesture.startTransform.scale, minScale, maxScale, ); const effectiveRatio = targetScale / gesture.startTransform.scale; const center = getTouchCenter(event.touches); const viewportCenterX = window.innerWidth / 2; const viewportCenterY = window.innerHeight / 2; const nextX = center.x - viewportCenterX - (gesture.startCenterX - viewportCenterX - gesture.startTransform.x) * effectiveRatio; const nextY = center.y - viewportCenterY - (gesture.startCenterY - viewportCenterY - gesture.startTransform.y) * effectiveRatio; scheduleTransform( normalizeTransform({ scale: targetScale, x: nextX, y: nextY }), ); return; } if (gesture.type === "pan" && event.touches.length === 1) { event.preventDefault(); const touch = event.touches[0]; scheduleTransform( normalizeTransform({ scale: gesture.startTransform.scale, x: gesture.startTransform.x + touch.clientX - gesture.startX, y: gesture.startTransform.y + touch.clientY - gesture.startY, }), ); return; } if (event.touches.length !== 1) { gestureRef.current = null; } }, [scheduleTransform], ); const handleTouchEnd = useCallback( (event: React.TouchEvent) => { flushScheduledTransform(); setIsGesturing(false); const gesture = gestureRef.current; gestureRef.current = null; if (!gesture) return; if (gesture.type !== "swipe" || event.changedTouches.length !== 1) { return; } const touch = event.changedTouches[0]; const deltaX = touch.clientX - gesture.startX; const deltaY = touch.clientY - gesture.startY; const now = Date.now(); if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && now - lastTapRef.current < 280) { event.preventDefault(); lastTapRef.current = 0; toggleZoom(); return; } lastTapRef.current = now; if (Math.abs(deltaX) < 48 || Math.abs(deltaX) < Math.abs(deltaY) * 1.4) { return; } if (deltaX > 0) { goPrev(); } else { goNext(); } }, [goPrev, goNext, toggleZoom, flushScheduledTransform], ); const handleTouchCancel = useCallback(() => { cancelScheduledTransform(); setIsGesturing(false); gestureRef.current = null; }, [cancelScheduledTransform]); if (!current) return null; return ( e.preventDefault()} > 图片预览
{current.sizeLabel || current.dimensions ? ( {[current.sizeLabel, current.dimensions].filter(Boolean).join(" · ")} ) : null} {images.length > 1 && ( {currentIndex + 1} / {images.length} )} 关闭
{hasPrev && transform.scale <= minScale && ( )}
onOpenChange(false)} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchCancel} > minScale ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in", )} style={{ transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`, }} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => { e.stopPropagation(); toggleZoom(); }} draggable={false} />
{hasNext && transform.scale <= minScale && ( )}
); }