chatgpt2api / web /src /components /image-lightbox.tsx
tx1538's picture
Upload 179 files
9d7ddb9 verified
Raw
History Blame
13.3 kB
"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>
);
}