xiaolongmr commited on
Commit
264ce60
·
1 Parent(s): 35b6295

Use Fancybox for image lightbox

Browse files
Files changed (1) hide show
  1. web/src/components/image-lightbox.tsx +173 -373
web/src/components/image-lightbox.tsx CHANGED
@@ -1,11 +1,6 @@
1
  "use client";
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
- import * as DialogPrimitive from "@radix-ui/react-dialog";
5
- import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
6
-
7
- import { downloadImageSrc } from "@/lib/image-url";
8
- import { cn } from "@/lib/utils";
9
 
10
  type LightboxImage = {
11
  id: string;
@@ -22,66 +17,126 @@ type ImageLightboxProps = {
22
  onIndexChange: (index: number) => void;
23
  };
24
 
25
- type ImageTransform = {
26
- scale: number;
27
- x: number;
28
- y: number;
 
29
  };
30
 
31
- type TouchGesture =
32
- | {
33
- type: "swipe";
34
- startX: number;
35
- startY: number;
36
- }
37
- | {
38
- type: "pan";
39
- startX: number;
40
- startY: number;
41
- startTransform: ImageTransform;
42
- }
43
- | {
44
- type: "pinch";
45
- startDistance: number;
46
- startCenterX: number;
47
- startCenterY: number;
48
- startTransform: ImageTransform;
49
- };
50
 
51
- const minScale = 1;
52
- const maxScale = 4;
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- function clamp(value: number, min: number, max: number) {
55
- return Math.min(max, Math.max(min, value));
 
 
56
  }
57
 
58
- function getTouchDistance(touches: React.TouchList) {
59
- const first = touches[0];
60
- const second = touches[1];
61
- return Math.hypot(first.clientX - second.clientX, first.clientY - second.clientY);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  }
63
 
64
- function getTouchCenter(touches: React.TouchList) {
65
- const first = touches[0];
66
- const second = touches[1];
67
- return {
68
- x: (first.clientX + second.clientX) / 2,
69
- y: (first.clientY + second.clientY) / 2,
70
- };
71
  }
72
 
73
- function normalizeTransform(transform: ImageTransform) {
74
- if (transform.scale <= minScale) {
75
- return { scale: minScale, x: 0, y: 0 };
76
  }
77
 
78
- const maxX = window.innerWidth * (transform.scale - 1) * 0.5;
79
- const maxY = window.innerHeight * (transform.scale - 1) * 0.5;
80
- return {
81
- scale: transform.scale,
82
- x: clamp(transform.x, -maxX, maxX),
83
- y: clamp(transform.y, -maxY, maxY),
84
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  }
86
 
87
  export function ImageLightbox({
@@ -91,338 +146,83 @@ export function ImageLightbox({
91
  onOpenChange,
92
  onIndexChange,
93
  }: ImageLightboxProps) {
94
- const gestureRef = useRef<TouchGesture | null>(null);
95
- const lastTapRef = useRef(0);
96
- const pendingTransformRef = useRef<ImageTransform | null>(null);
97
- const rafRef = useRef<number | null>(null);
98
- const [transform, setTransform] = useState<ImageTransform>({ scale: 1, x: 0, y: 0 });
99
- const [isGesturing, setIsGesturing] = useState(false);
100
- const current = images[currentIndex];
101
- const hasPrev = currentIndex > 0;
102
- const hasNext = currentIndex < images.length - 1;
103
-
104
- const cancelScheduledTransform = useCallback(() => {
105
- if (rafRef.current != null) {
106
- cancelAnimationFrame(rafRef.current);
107
- rafRef.current = null;
108
- }
109
- pendingTransformRef.current = null;
110
- }, []);
111
 
112
- const scheduleTransform = useCallback((next: ImageTransform) => {
113
- pendingTransformRef.current = next;
114
- if (rafRef.current != null) return;
115
- rafRef.current = requestAnimationFrame(() => {
116
- rafRef.current = null;
117
- const pending = pendingTransformRef.current;
118
- pendingTransformRef.current = null;
119
- if (pending) {
120
- setTransform(pending);
121
  }
122
- });
123
- }, []);
124
-
125
- const flushScheduledTransform = useCallback(() => {
126
- if (rafRef.current != null) {
127
- cancelAnimationFrame(rafRef.current);
128
- rafRef.current = null;
129
- }
130
- const pending = pendingTransformRef.current;
131
- pendingTransformRef.current = null;
132
- if (pending) {
133
- setTransform(pending);
134
  }
135
- }, []);
136
-
137
- const resetTransform = useCallback(() => {
138
- cancelScheduledTransform();
139
- setTransform({ scale: 1, x: 0, y: 0 });
140
- setIsGesturing(false);
141
- gestureRef.current = null;
142
- }, [cancelScheduledTransform]);
143
 
144
- const goPrev = useCallback(() => {
145
- if (hasPrev) onIndexChange(currentIndex - 1);
146
- }, [hasPrev, currentIndex, onIndexChange]);
 
147
 
148
- const goNext = useCallback(() => {
149
- if (hasNext) onIndexChange(currentIndex + 1);
150
- }, [hasNext, currentIndex, onIndexChange]);
151
 
152
- useEffect(() => {
153
- resetTransform();
154
- }, [current?.id, open, resetTransform]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
- useEffect(() => {
157
  return () => {
158
- if (rafRef.current != null) {
159
- cancelAnimationFrame(rafRef.current);
160
- rafRef.current = null;
161
- }
162
  };
163
- }, []);
164
 
165
  useEffect(() => {
166
- if (!open) return;
167
-
168
- const handleKeyDown = (e: KeyboardEvent) => {
169
- if (e.key === "ArrowLeft") {
170
- e.preventDefault();
171
- goPrev();
172
- } else if (e.key === "ArrowRight") {
173
- e.preventDefault();
174
- goNext();
175
  }
176
  };
177
-
178
- window.addEventListener("keydown", handleKeyDown);
179
- return () => window.removeEventListener("keydown", handleKeyDown);
180
- }, [open, goPrev, goNext]);
181
-
182
- const handleDownload = useCallback(() => {
183
- if (!current) return;
184
- void downloadImageSrc(current.src, `image-${current.id}.png`);
185
- }, [current]);
186
-
187
- const toggleZoom = useCallback(() => {
188
- setTransform((currentTransform) =>
189
- currentTransform.scale > minScale ? { scale: 1, x: 0, y: 0 } : { scale: 2.5, x: 0, y: 0 },
190
- );
191
  }, []);
192
 
193
- const handleTouchStart = useCallback(
194
- (event: React.TouchEvent<HTMLDivElement>) => {
195
- if (event.touches.length === 2) {
196
- event.preventDefault();
197
- const startDistance = getTouchDistance(event.touches);
198
- if (startDistance < 1) {
199
- gestureRef.current = null;
200
- return;
201
- }
202
- const center = getTouchCenter(event.touches);
203
- cancelScheduledTransform();
204
- setIsGesturing(true);
205
- gestureRef.current = {
206
- type: "pinch",
207
- startDistance,
208
- startCenterX: center.x,
209
- startCenterY: center.y,
210
- startTransform: transform,
211
- };
212
- return;
213
- }
214
-
215
- if (event.touches.length !== 1) {
216
- gestureRef.current = null;
217
- return;
218
- }
219
-
220
- const touch = event.touches[0];
221
- if (transform.scale > minScale) {
222
- cancelScheduledTransform();
223
- setIsGesturing(true);
224
- gestureRef.current = {
225
- type: "pan",
226
- startX: touch.clientX,
227
- startY: touch.clientY,
228
- startTransform: transform,
229
- };
230
- } else {
231
- gestureRef.current = {
232
- type: "swipe",
233
- startX: touch.clientX,
234
- startY: touch.clientY,
235
- };
236
- }
237
- },
238
- [transform, cancelScheduledTransform],
239
- );
240
-
241
- const handleTouchMove = useCallback(
242
- (event: React.TouchEvent<HTMLDivElement>) => {
243
- const gesture = gestureRef.current;
244
- if (!gesture) return;
245
-
246
- if (gesture.type === "pinch" && event.touches.length === 2) {
247
- event.preventDefault();
248
- const targetScale = clamp(
249
- (getTouchDistance(event.touches) / gesture.startDistance) * gesture.startTransform.scale,
250
- minScale,
251
- maxScale,
252
- );
253
- const effectiveRatio = targetScale / gesture.startTransform.scale;
254
- const center = getTouchCenter(event.touches);
255
- const viewportCenterX = window.innerWidth / 2;
256
- const viewportCenterY = window.innerHeight / 2;
257
- const nextX =
258
- center.x -
259
- viewportCenterX -
260
- (gesture.startCenterX - viewportCenterX - gesture.startTransform.x) * effectiveRatio;
261
- const nextY =
262
- center.y -
263
- viewportCenterY -
264
- (gesture.startCenterY - viewportCenterY - gesture.startTransform.y) * effectiveRatio;
265
- scheduleTransform(
266
- normalizeTransform({ scale: targetScale, x: nextX, y: nextY }),
267
- );
268
- return;
269
- }
270
-
271
- if (gesture.type === "pan" && event.touches.length === 1) {
272
- event.preventDefault();
273
- const touch = event.touches[0];
274
- scheduleTransform(
275
- normalizeTransform({
276
- scale: gesture.startTransform.scale,
277
- x: gesture.startTransform.x + touch.clientX - gesture.startX,
278
- y: gesture.startTransform.y + touch.clientY - gesture.startY,
279
- }),
280
- );
281
- return;
282
- }
283
-
284
- if (event.touches.length !== 1) {
285
- gestureRef.current = null;
286
- }
287
- },
288
- [scheduleTransform],
289
- );
290
-
291
- const handleTouchEnd = useCallback(
292
- (event: React.TouchEvent<HTMLDivElement>) => {
293
- flushScheduledTransform();
294
- setIsGesturing(false);
295
-
296
- const gesture = gestureRef.current;
297
- gestureRef.current = null;
298
- if (!gesture) return;
299
-
300
- if (gesture.type !== "swipe" || event.changedTouches.length !== 1) {
301
- return;
302
- }
303
-
304
- const touch = event.changedTouches[0];
305
- const deltaX = touch.clientX - gesture.startX;
306
- const deltaY = touch.clientY - gesture.startY;
307
- const now = Date.now();
308
-
309
- if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && now - lastTapRef.current < 280) {
310
- event.preventDefault();
311
- lastTapRef.current = 0;
312
- toggleZoom();
313
- return;
314
- }
315
- lastTapRef.current = now;
316
-
317
- if (Math.abs(deltaX) < 48 || Math.abs(deltaX) < Math.abs(deltaY) * 1.4) {
318
- return;
319
- }
320
-
321
- if (deltaX > 0) {
322
- goPrev();
323
- } else {
324
- goNext();
325
- }
326
- },
327
- [goPrev, goNext, toggleZoom, flushScheduledTransform],
328
- );
329
-
330
- const handleTouchCancel = useCallback(() => {
331
- cancelScheduledTransform();
332
- setIsGesturing(false);
333
- gestureRef.current = null;
334
- }, [cancelScheduledTransform]);
335
-
336
- if (!current) return null;
337
-
338
- return (
339
- <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
340
- <DialogPrimitive.Portal>
341
- <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" />
342
- <DialogPrimitive.Content
343
- className="fixed inset-0 z-50 flex items-center justify-center outline-none"
344
- onPointerDownOutside={(e) => e.preventDefault()}
345
- >
346
- <DialogPrimitive.Title className="sr-only">
347
- 图片预览
348
- </DialogPrimitive.Title>
349
-
350
- <div className="absolute top-[calc(env(safe-area-inset-top)+1rem)] right-4 z-10 flex items-center gap-2">
351
- {current.sizeLabel || current.dimensions ? (
352
- <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
353
- {[current.sizeLabel, current.dimensions].filter(Boolean).join(" · ")}
354
- </span>
355
- ) : null}
356
- {images.length > 1 && (
357
- <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
358
- {currentIndex + 1} / {images.length}
359
- </span>
360
- )}
361
- <button
362
- type="button"
363
- onClick={handleDownload}
364
- className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70"
365
- aria-label="下载图片"
366
- >
367
- <Download className="size-4" />
368
- </button>
369
- <DialogPrimitive.Close className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70">
370
- <X className="size-4" />
371
- <span className="sr-only">关闭</span>
372
- </DialogPrimitive.Close>
373
- </div>
374
-
375
- {hasPrev && transform.scale <= minScale && (
376
- <button
377
- type="button"
378
- onClick={goPrev}
379
- 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"
380
- aria-label="上一张"
381
- >
382
- <ChevronLeft className="size-5" />
383
- </button>
384
- )}
385
-
386
- <div
387
- className="flex h-full w-full touch-none items-center justify-center overflow-hidden"
388
- onClick={() => onOpenChange(false)}
389
- onTouchStart={handleTouchStart}
390
- onTouchMove={handleTouchMove}
391
- onTouchEnd={handleTouchEnd}
392
- onTouchCancel={handleTouchCancel}
393
- >
394
- <img
395
- src={current.src}
396
- alt=""
397
- className={cn(
398
- "max-h-[90vh] max-w-[90vw] rounded-lg object-contain will-change-transform",
399
- isGesturing ? "" : "transition-transform duration-150 ease-out",
400
- transform.scale > minScale ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in",
401
- )}
402
- style={{
403
- transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
404
- }}
405
- onClick={(e) => e.stopPropagation()}
406
- onDoubleClick={(e) => {
407
- e.stopPropagation();
408
- toggleZoom();
409
- }}
410
- draggable={false}
411
- />
412
- </div>
413
-
414
- {hasNext && transform.scale <= minScale && (
415
- <button
416
- type="button"
417
- onClick={goNext}
418
- 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"
419
- aria-label="下一张"
420
- >
421
- <ChevronRight className="size-5" />
422
- </button>
423
- )}
424
- </DialogPrimitive.Content>
425
- </DialogPrimitive.Portal>
426
- </DialogPrimitive.Root>
427
- );
428
  }
 
1
  "use client";
2
 
3
+ import { useEffect, useRef } from "react";
 
 
 
 
 
4
 
5
  type LightboxImage = {
6
  id: string;
 
17
  onIndexChange: (index: number) => void;
18
  };
19
 
20
+ type FancyboxItem = {
21
+ src: string;
22
+ type: "image";
23
+ caption?: string;
24
+ thumb?: string;
25
  };
26
 
27
+ type FancyboxInstance = {
28
+ close: () => void;
29
+ getSlide?: () => { index?: number } | undefined;
30
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
+ type FancyboxGlobal = {
33
+ show: (
34
+ items: FancyboxItem[],
35
+ options?: {
36
+ startIndex?: number;
37
+ Hash?: false;
38
+ closeExisting?: boolean;
39
+ Carousel?: { infinite?: boolean };
40
+ on?: {
41
+ close?: () => void;
42
+ "Carousel.change"?: (fancyboxRef: FancyboxInstance) => void;
43
+ };
44
+ },
45
+ ) => FancyboxInstance;
46
+ };
47
 
48
+ declare global {
49
+ interface Window {
50
+ Fancybox?: FancyboxGlobal;
51
+ }
52
  }
53
 
54
+ const FANCYBOX_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1.14/dist/fancybox/fancybox.umd.js";
55
+ const FANCYBOX_STYLE_URL = "https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1.14/dist/fancybox/fancybox.css";
56
+
57
+ let fancyboxLoader: Promise<FancyboxGlobal> | null = null;
58
+
59
+ function escapeHtml(value: string) {
60
+ return value.replace(/[&<>"']/g, (char) => {
61
+ switch (char) {
62
+ case "&":
63
+ return "&amp;";
64
+ case "<":
65
+ return "&lt;";
66
+ case ">":
67
+ return "&gt;";
68
+ case "\"":
69
+ return "&quot;";
70
+ case "'":
71
+ return "&#39;";
72
+ default:
73
+ return char;
74
+ }
75
+ });
76
  }
77
 
78
+ function getCaption(image: LightboxImage) {
79
+ const parts = [image.sizeLabel, image.dimensions].filter(Boolean);
80
+ return parts.length ? escapeHtml(parts.join(" · ")) : undefined;
 
 
 
 
81
  }
82
 
83
+ function ensureFancyboxStyle() {
84
+ if (document.querySelector(`link[href="${FANCYBOX_STYLE_URL}"]`)) {
85
+ return;
86
  }
87
 
88
+ const link = document.createElement("link");
89
+ link.rel = "stylesheet";
90
+ link.href = FANCYBOX_STYLE_URL;
91
+ document.head.appendChild(link);
92
+ }
93
+
94
+ function loadFancybox() {
95
+ if (typeof window === "undefined") {
96
+ return Promise.reject(new Error("Fancybox can only be loaded in the browser"));
97
+ }
98
+
99
+ ensureFancyboxStyle();
100
+
101
+ if (window.Fancybox) {
102
+ return Promise.resolve(window.Fancybox);
103
+ }
104
+
105
+ if (fancyboxLoader) {
106
+ return fancyboxLoader;
107
+ }
108
+
109
+ fancyboxLoader = new Promise<FancyboxGlobal>((resolve, reject) => {
110
+ const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${FANCYBOX_SCRIPT_URL}"]`);
111
+
112
+ const resolveLoaded = () => {
113
+ if (window.Fancybox) {
114
+ resolve(window.Fancybox);
115
+ } else {
116
+ reject(new Error("Fancybox loaded but did not expose window.Fancybox"));
117
+ }
118
+ };
119
+
120
+ if (existingScript) {
121
+ existingScript.addEventListener("load", resolveLoaded, { once: true });
122
+ existingScript.addEventListener("error", () => reject(new Error("Failed to load Fancybox")), { once: true });
123
+ return;
124
+ }
125
+
126
+ const script = document.createElement("script");
127
+ script.src = FANCYBOX_SCRIPT_URL;
128
+ script.async = true;
129
+ script.onload = resolveLoaded;
130
+ script.onerror = () => reject(new Error("Failed to load Fancybox"));
131
+ document.body.appendChild(script);
132
+ });
133
+
134
+ return fancyboxLoader;
135
+ }
136
+
137
+ function clampIndex(index: number, length: number) {
138
+ if (length <= 0) return 0;
139
+ return Math.max(0, Math.min(index, length - 1));
140
  }
141
 
142
  export function ImageLightbox({
 
146
  onOpenChange,
147
  onIndexChange,
148
  }: ImageLightboxProps) {
149
+ const instanceRef = useRef<FancyboxInstance | null>(null);
150
+ const isClosingRef = useRef(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
+ useEffect(() => {
153
+ if (!open) {
154
+ if (instanceRef.current && !isClosingRef.current) {
155
+ isClosingRef.current = true;
156
+ instanceRef.current.close();
157
+ instanceRef.current = null;
158
+ isClosingRef.current = false;
 
 
159
  }
160
+ return;
 
 
 
 
 
 
 
 
 
 
 
161
  }
 
 
 
 
 
 
 
 
162
 
163
+ if (images.length === 0) {
164
+ onOpenChange(false);
165
+ return;
166
+ }
167
 
168
+ if (instanceRef.current) {
169
+ return;
170
+ }
171
 
172
+ let cancelled = false;
173
+
174
+ void loadFancybox()
175
+ .then((Fancybox) => {
176
+ if (cancelled) return;
177
+
178
+ const items = images.map((image) => ({
179
+ src: image.src,
180
+ type: "image" as const,
181
+ thumb: image.src,
182
+ caption: getCaption(image),
183
+ }));
184
+
185
+ instanceRef.current = Fancybox.show(items, {
186
+ startIndex: clampIndex(currentIndex, images.length),
187
+ Hash: false,
188
+ closeExisting: true,
189
+ Carousel: {
190
+ infinite: images.length > 1,
191
+ },
192
+ on: {
193
+ close: () => {
194
+ instanceRef.current = null;
195
+ isClosingRef.current = false;
196
+ onOpenChange(false);
197
+ },
198
+ "Carousel.change": (fancyboxRef) => {
199
+ const nextIndex = fancyboxRef.getSlide?.()?.index;
200
+ if (typeof nextIndex === "number") {
201
+ onIndexChange(clampIndex(nextIndex, images.length));
202
+ }
203
+ },
204
+ },
205
+ });
206
+ })
207
+ .catch((error) => {
208
+ console.error(error);
209
+ onOpenChange(false);
210
+ });
211
 
 
212
  return () => {
213
+ cancelled = true;
 
 
 
214
  };
215
+ }, [open, images, currentIndex, onOpenChange, onIndexChange]);
216
 
217
  useEffect(() => {
218
+ return () => {
219
+ if (instanceRef.current) {
220
+ isClosingRef.current = true;
221
+ instanceRef.current.close();
222
+ instanceRef.current = null;
 
 
 
 
223
  }
224
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  }, []);
226
 
227
+ return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  }