| 1 | 'use client'; |
| 2 | |
| 3 | import React, { useCallback, useEffect, useState } from 'react'; |
| 4 | import { X, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; |
| 5 | import { assetUrl } from './utils'; |
| 6 | |
| 7 | interface ImageLightboxProps { |
| 8 | images: string[]; // 所有可浏览的图片路径 |
| 9 | initialIndex: number; // 初始展示的索引 |
| 10 | onClose: () => void; |
| 11 | } |
| 12 | |
| 13 | export default function ImageLightbox({ images, initialIndex, onClose }: ImageLightboxProps) { |
| 14 | const [index, setIndex] = useState(initialIndex); |
| 15 | const [scale, setScale] = useState(1); |
| 16 | const [translate, setTranslate] = useState({ x: 0, y: 0 }); |
| 17 | const [dragging, setDragging] = useState(false); |
| 18 | const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); |
| 19 | |
| 20 | const current = images[index]; |
| 21 | const hasPrev = index > 0; |
| 22 | const hasNext = index < images.length - 1; |
| 23 | |
| 24 | const resetView = useCallback(() => { |
| 25 | setScale(1); |
| 26 | setTranslate({ x: 0, y: 0 }); |
| 27 | }, []); |
| 28 | |
| 29 | const goPrev = useCallback(() => { |
| 30 | if (hasPrev) { setIndex(i => i - 1); resetView(); } |
| 31 | }, [hasPrev, resetView]); |
| 32 | |
| 33 | const goNext = useCallback(() => { |
| 34 | if (hasNext) { setIndex(i => i + 1); resetView(); } |
| 35 | }, [hasNext, resetView]); |
| 36 | |
| 37 | const zoomIn = useCallback(() => setScale(s => Math.min(s * 1.5, 5)), []); |
| 38 | const zoomOut = useCallback(() => { |
| 39 | setScale(s => { |
| 40 | const next = Math.max(s / 1.5, 1); |
| 41 | if (next === 1) setTranslate({ x: 0, y: 0 }); |
| 42 | return next; |
| 43 | }); |
| 44 | }, []); |
| 45 | |
| 46 | // 键盘导航 |
| 47 | useEffect(() => { |
| 48 | const handler = (e: KeyboardEvent) => { |
| 49 | switch (e.key) { |
| 50 | case 'Escape': onClose(); break; |
| 51 | case 'ArrowLeft': goPrev(); break; |
| 52 | case 'ArrowRight': goNext(); break; |
| 53 | case '+': case '=': zoomIn(); break; |
| 54 | case '-': zoomOut(); break; |
| 55 | case '0': resetView(); break; |
| 56 | } |
| 57 | }; |
| 58 | window.addEventListener('keydown', handler); |
| 59 | return () => window.removeEventListener('keydown', handler); |
| 60 | }, [onClose, goPrev, goNext, zoomIn, zoomOut, resetView]); |
| 61 | |
| 62 | // 滚轮缩放 |
| 63 | const handleWheel = useCallback((e: React.WheelEvent) => { |
| 64 | e.preventDefault(); |
| 65 | if (e.deltaY < 0) { |
| 66 | setScale(s => Math.min(s * 1.15, 5)); |
| 67 | } else { |
| 68 | setScale(s => { |
| 69 | const next = Math.max(s / 1.15, 1); |
| 70 | if (next === 1) setTranslate({ x: 0, y: 0 }); |
| 71 | return next; |
| 72 | }); |
| 73 | } |
| 74 | }, []); |
| 75 | |
| 76 | // 拖拽平移(缩放 > 1 时) |
| 77 | const handlePointerDown = useCallback((e: React.PointerEvent) => { |
| 78 | if (scale <= 1) return; |
| 79 | setDragging(true); |
| 80 | setDragStart({ x: e.clientX - translate.x, y: e.clientY - translate.y }); |
| 81 | (e.target as HTMLElement).setPointerCapture(e.pointerId); |
| 82 | }, [scale, translate]); |
| 83 | |
| 84 | const handlePointerMove = useCallback((e: React.PointerEvent) => { |
| 85 | if (!dragging) return; |
| 86 | setTranslate({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); |
| 87 | }, [dragging, dragStart]); |
| 88 | |
| 89 | const handlePointerUp = useCallback(() => setDragging(false), []); |
| 90 | |
| 91 | // 禁止背景滚动 |
| 92 | useEffect(() => { |
| 93 | document.body.style.overflow = 'hidden'; |
| 94 | return () => { document.body.style.overflow = ''; }; |
| 95 | }, []); |
| 96 | |
| 97 | return ( |
| 98 | <div className="fixed inset-0 z-[9999] flex items-center justify-center"> |
| 99 | {/* 遮罩 */} |
| 100 | <div |
| 101 | className="absolute inset-0 bg-black/80 backdrop-blur-sm" |
| 102 | onClick={onClose} |
| 103 | /> |
| 104 | |
| 105 | {/* 顶部工具栏 */} |
| 106 | <div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-3"> |
| 107 | <div className="text-white/70 text-sm font-medium"> |
| 108 | {images.length > 1 && `${index + 1} / ${images.length}`} |
| 109 | </div> |
| 110 | <div className="flex items-center gap-1"> |
| 111 | <button onClick={zoomOut} className="w-8 h-8 rounded-full flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="缩小 (-)"> |
| 112 | <ZoomOut className="w-4 h-4" /> |
| 113 | </button> |
| 114 | <span className="text-white/50 text-xs w-12 text-center">{Math.round(scale * 100)}%</span> |
| 115 | <button onClick={zoomIn} className="w-8 h-8 rounded-full flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="放大 (+)"> |
| 116 | <ZoomIn className="w-4 h-4" /> |
| 117 | </button> |
| 118 | <button onClick={resetView} className="w-8 h-8 rounded-full flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="重置 (0)"> |
| 119 | <RotateCcw className="w-4 h-4" /> |
| 120 | </button> |
| 121 | <div className="w-px h-5 bg-white/20 mx-1" /> |
| 122 | <button onClick={onClose} className="w-8 h-8 rounded-full flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="关闭 (Esc)"> |
| 123 | <X className="w-5 h-5" /> |
| 124 | </button> |
| 125 | </div> |
| 126 | </div> |
| 127 | |
| 128 | {/* 左右切换 */} |
| 129 | {hasPrev && ( |
| 130 | <button |
| 131 | onClick={goPrev} |
| 132 | className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors" |
| 133 | > |
| 134 | <ChevronLeft className="w-6 h-6" /> |
| 135 | </button> |
| 136 | )} |
| 137 | {hasNext && ( |
| 138 | <button |
| 139 | onClick={goNext} |
| 140 | className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors" |
| 141 | > |
| 142 | <ChevronRight className="w-6 h-6" /> |
| 143 | </button> |
| 144 | )} |
| 145 | |
| 146 | {/* 图片 */} |
| 147 | <div |
| 148 | className="relative z-[1] max-w-[90vw] max-h-[85vh] select-none" |
| 149 | onWheel={handleWheel} |
| 150 | onPointerDown={handlePointerDown} |
| 151 | onPointerMove={handlePointerMove} |
| 152 | onPointerUp={handlePointerUp} |
| 153 | style={{ cursor: scale > 1 ? (dragging ? 'grabbing' : 'grab') : 'zoom-in' }} |
| 154 | > |
| 155 | <img |
| 156 | src={assetUrl(current)} |
| 157 | alt={`image ${index + 1}`} |
| 158 | className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl" |
| 159 | style={{ |
| 160 | transform: `scale(${scale}) translate(${translate.x / scale}px, ${translate.y / scale}px)`, |
| 161 | transition: dragging ? 'none' : 'transform 0.2s ease', |
| 162 | }} |
| 163 | draggable={false} |
| 164 | onClick={(e) => { |
| 165 | if (scale === 1) { |
| 166 | e.stopPropagation(); |
| 167 | zoomIn(); |
| 168 | } |
| 169 | }} |
| 170 | /> |
| 171 | </div> |
| 172 | |
| 173 | {/* 底部缩略图条 */} |
| 174 | {images.length > 1 && ( |
| 175 | <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex gap-2 bg-black/40 rounded-full px-3 py-2"> |
| 176 | {images.map((path, i) => ( |
| 177 | <button |
| 178 | key={path} |
| 179 | onClick={() => { setIndex(i); resetView(); }} |
| 180 | className={`w-10 h-10 rounded overflow-hidden border-2 transition-all ${ |
| 181 | i === index ? 'border-white shadow-lg scale-110' : 'border-transparent opacity-60 hover:opacity-100' |
| 182 | }`} |
| 183 | > |
| 184 | <img src={assetUrl(path)} alt={`thumb ${i + 1}`} className="w-full h-full object-cover" /> |
| 185 | </button> |
| 186 | ))} |
| 187 | </div> |
| 188 | )} |
| 189 | </div> |
| 190 | ); |
| 191 | } |
| 192 |