返回 VideoClaw
ImageLightbox.tsx
根目录 / video-claw / video-claw / frontend / components / stages / ImageLightbox.tsx
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
192 lines Plain Text