返回 JoyAI-Echo
ImageLightbox.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / ImageLightbox.tsx
1 import { useCallback, useEffect, useMemo } from "react";
2 import * as DialogPrimitive from "@radix-ui/react-dialog";
3 import { ChevronLeft, ChevronRight, X } from "lucide-react";
4 import { useTranslation } from "react-i18next";
5
6 import { cn } from "@/lib/utils";
7 import type { UIImage } from "@/lib/types";
8
9 interface ImageLightboxProps {
10 images: UIImage[];
11 index: number | null;
12 onIndexChange: (index: number) => void;
13 onOpenChange: (open: boolean) => void;
14 }
15
16 /**
17 * Modal image viewer. Uses the Radix Dialog primitives directly so we can
18 * fill the viewport (the shared `DialogContent` wrapper caps at max-w-lg,
19 * which is much too small for a photo preview).
20 *
21 * Implementation notes:
22 * - `translate3d` + `will-change: transform` promote the image to a GPU
23 * compositing layer so open/swap stays at 60 FPS on long threads.
24 * - Adjacent images are rendered in hidden `<img>` tags so the browser
25 * decodes them eagerly; pressing left/right feels instant.
26 * - Radix handles `Escape` + focus trapping; we only wire up ←/→ + Home/End.
27 * - Respects `prefers-reduced-motion` by dropping the fade + zoom-in
28 * keyframes via `motion-reduce:*` variants.
29 */
30 export function ImageLightbox({
31 images,
32 index,
33 onIndexChange,
34 onOpenChange,
35 }: ImageLightboxProps) {
36 const { t } = useTranslation();
37 const open = index !== null;
38 const total = images.length;
39 const current = index !== null ? images[index] : null;
40
41 const go = useCallback(
42 (delta: number) => {
43 if (index === null || total <= 1) return;
44 const next = (index + delta + total) % total;
45 onIndexChange(next);
46 },
47 [index, onIndexChange, total],
48 );
49
50 useEffect(() => {
51 if (!open) return;
52 const onKey = (e: KeyboardEvent) => {
53 if (e.key === "ArrowLeft") {
54 e.preventDefault();
55 go(-1);
56 } else if (e.key === "ArrowRight") {
57 e.preventDefault();
58 go(1);
59 } else if (e.key === "Home") {
60 e.preventDefault();
61 onIndexChange(0);
62 } else if (e.key === "End") {
63 e.preventDefault();
64 onIndexChange(total - 1);
65 }
66 };
67 window.addEventListener("keydown", onKey);
68 return () => window.removeEventListener("keydown", onKey);
69 }, [go, onIndexChange, open, total]);
70
71 // Neighbours we want the browser to decode eagerly.
72 const preload = useMemo(() => {
73 if (index === null || total <= 1) return [] as UIImage[];
74 const prev = images[(index - 1 + total) % total];
75 const next = images[(index + 1) % total];
76 return [prev, next].filter((i) => i && i.url);
77 }, [images, index, total]);
78
79 if (!current || !current.url) return null;
80
81 const hasMany = total > 1;
82 const counter = hasMany ? `${index! + 1} / ${total}` : null;
83
84 return (
85 <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
86 <DialogPrimitive.Portal>
87 <DialogPrimitive.Overlay
88 className={cn(
89 "fixed inset-0 z-50 bg-black/80 backdrop-blur-sm",
90 "data-[state=open]:animate-in data-[state=closed]:animate-out",
91 "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
92 "motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
93 )}
94 />
95 <DialogPrimitive.Content
96 aria-label={current.name ?? t("lightbox.title")}
97 className={cn(
98 "fixed inset-0 z-50 flex items-center justify-center",
99 "focus:outline-none",
100 "data-[state=open]:animate-in data-[state=closed]:animate-out",
101 "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
102 "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
103 "motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
104 )}
105 >
106 <DialogPrimitive.Title className="sr-only">
107 {current.name ?? t("lightbox.title")}
108 </DialogPrimitive.Title>
109
110 <div
111 className="relative flex max-h-[92vh] max-w-[94vw] items-center justify-center"
112 style={{
113 transform: "translate3d(0,0,0)",
114 willChange: "transform",
115 }}
116 >
117 <img
118 key={current.url}
119 src={current.url}
120 alt={current.name ?? ""}
121 decoding="async"
122 draggable={false}
123 className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl"
124 />
125 </div>
126
127 {hasMany ? (
128 <>
129 <NavButton
130 side="left"
131 label={t("lightbox.prev")}
132 onClick={(e) => {
133 e.stopPropagation();
134 go(-1);
135 }}
136 />
137 <NavButton
138 side="right"
139 label={t("lightbox.next")}
140 onClick={(e) => {
141 e.stopPropagation();
142 go(1);
143 }}
144 />
145 <div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-3 py-1 text-xs font-medium text-white/90 tabular-nums">
146 {counter}
147 </div>
148 </>
149 ) : null}
150
151 <DialogPrimitive.Close
152 aria-label={t("lightbox.close")}
153 className={cn(
154 "absolute right-4 top-4 grid h-9 w-9 place-items-center rounded-full",
155 "bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
156 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
157 "transition-colors motion-reduce:transition-none",
158 )}
159 >
160 <X className="h-4 w-4" aria-hidden />
161 </DialogPrimitive.Close>
162
163 {/* Invisible preload — browser decodes adjacent images so prev/next swap is instant. */}
164 <div aria-hidden className="hidden">
165 {preload.map((img, i) => (
166 <img key={`${img.url}-${i}`} src={img.url} alt="" />
167 ))}
168 </div>
169 </DialogPrimitive.Content>
170 </DialogPrimitive.Portal>
171 </DialogPrimitive.Root>
172 );
173 }
174
175 interface NavButtonProps {
176 side: "left" | "right";
177 label: string;
178 onClick: React.MouseEventHandler<HTMLButtonElement>;
179 }
180
181 function NavButton({ side, label, onClick }: NavButtonProps) {
182 const Icon = side === "left" ? ChevronLeft : ChevronRight;
183 return (
184 <button
185 type="button"
186 onClick={onClick}
187 aria-label={label}
188 className={cn(
189 "absolute top-1/2 -translate-y-1/2 grid h-11 w-11 place-items-center rounded-full",
190 "bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
191 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
192 "transition-colors motion-reduce:transition-none",
193 side === "left" ? "left-4" : "right-4",
194 )}
195 >
196 <Icon className="h-5 w-5" aria-hidden />
197 </button>
198 );
199 }
200
200 lines Plain Text