Spaces:
Paused
Paused
| "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<TouchGesture | null>(null); | |
| const lastTapRef = useRef(0); | |
| const pendingTransformRef = useRef<ImageTransform | null>(null); | |
| const rafRef = useRef<number | null>(null); | |
| const [transform, setTransform] = useState<ImageTransform>({ 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<HTMLDivElement>) => { | |
| 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<HTMLDivElement>) => { | |
| 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<HTMLDivElement>) => { | |
| 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 ( | |
| <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}> | |
| <DialogPrimitive.Portal> | |
| <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" /> | |
| <DialogPrimitive.Content | |
| className="fixed inset-0 z-50 flex items-center justify-center outline-none" | |
| onPointerDownOutside={(e) => e.preventDefault()} | |
| > | |
| <DialogPrimitive.Title className="sr-only"> | |
| 图片预览 | |
| </DialogPrimitive.Title> | |
| <div className="absolute top-[calc(env(safe-area-inset-top)+1rem)] right-4 z-10 flex items-center gap-2"> | |
| {current.sizeLabel || current.dimensions ? ( | |
| <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90"> | |
| {[current.sizeLabel, current.dimensions].filter(Boolean).join(" · ")} | |
| </span> | |
| ) : null} | |
| {images.length > 1 && ( | |
| <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90"> | |
| {currentIndex + 1} / {images.length} | |
| </span> | |
| )} | |
| <button | |
| type="button" | |
| onClick={handleDownload} | |
| className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70" | |
| aria-label="下载图片" | |
| > | |
| <Download className="size-4" /> | |
| </button> | |
| <DialogPrimitive.Close className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70"> | |
| <X className="size-4" /> | |
| <span className="sr-only">关闭</span> | |
| </DialogPrimitive.Close> | |
| </div> | |
| {hasPrev && transform.scale <= minScale && ( | |
| <button | |
| type="button" | |
| onClick={goPrev} | |
| className="absolute left-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60" | |
| aria-label="上一张" | |
| > | |
| <ChevronLeft className="size-5" /> | |
| </button> | |
| )} | |
| <div | |
| className="flex h-full w-full touch-none items-center justify-center overflow-hidden" | |
| onClick={() => onOpenChange(false)} | |
| onTouchStart={handleTouchStart} | |
| onTouchMove={handleTouchMove} | |
| onTouchEnd={handleTouchEnd} | |
| onTouchCancel={handleTouchCancel} | |
| > | |
| <img | |
| src={current.src} | |
| alt="" | |
| className={cn( | |
| "max-h-[90vh] max-w-[90vw] rounded-lg object-contain will-change-transform", | |
| isGesturing ? "" : "transition-transform duration-150 ease-out", | |
| transform.scale > 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} | |
| /> | |
| </div> | |
| {hasNext && transform.scale <= minScale && ( | |
| <button | |
| type="button" | |
| onClick={goNext} | |
| className="absolute right-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60" | |
| aria-label="下一张" | |
| > | |
| <ChevronRight className="size-5" /> | |
| </button> | |
| )} | |
| </DialogPrimitive.Content> | |
| </DialogPrimitive.Portal> | |
| </DialogPrimitive.Root> | |
| ); | |
| } | |