355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
"use client";
|
||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||
import { motion, AnimatePresence } from "motion/react";
|
||
import {
|
||
X,
|
||
ZoomIn,
|
||
ZoomOut,
|
||
RotateCcw,
|
||
Maximize2,
|
||
Minimize2,
|
||
ChevronRight,
|
||
ChevronLeft,
|
||
Move
|
||
} from "lucide-react";
|
||
|
||
interface ProductImageZoomModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
images: string[];
|
||
activeImage: string;
|
||
onSelectImage: (image: string) => void;
|
||
productName: string;
|
||
}
|
||
|
||
export default function ProductImageZoomModal({
|
||
isOpen,
|
||
onClose,
|
||
images,
|
||
activeImage,
|
||
onSelectImage,
|
||
productName,
|
||
}: ProductImageZoomModalProps) {
|
||
const [scale, setScale] = useState(1);
|
||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Reset zoom & pan when image changes or modal opens
|
||
const resetZoom = useCallback(() => {
|
||
setScale(1);
|
||
setPosition({ x: 0, y: 0 });
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
resetZoom();
|
||
document.body.style.overflow = "hidden";
|
||
} else {
|
||
document.body.style.overflow = "";
|
||
}
|
||
return () => {
|
||
document.body.style.overflow = "";
|
||
};
|
||
}, [isOpen, activeImage, resetZoom]);
|
||
|
||
const navigateImage = useCallback((direction: number) => {
|
||
if (!images || images.length <= 1) return;
|
||
const currentIndex = images.indexOf(activeImage);
|
||
if (currentIndex === -1) return;
|
||
let nextIndex = (currentIndex + direction) % images.length;
|
||
if (nextIndex < 0) nextIndex = images.length - 1;
|
||
onSelectImage(images[nextIndex]);
|
||
resetZoom();
|
||
}, [images, activeImage, onSelectImage, resetZoom]);
|
||
|
||
// Keyboard navigation & zoom shortcuts
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
onClose();
|
||
} else if (e.key === "+" || e.key === "=") {
|
||
setScale((prev) => Math.min(prev + 0.5, 4));
|
||
} else if (e.key === "-") {
|
||
setScale((prev) => {
|
||
const next = Math.max(prev - 0.5, 1);
|
||
if (next === 1) setPosition({ x: 0, y: 0 });
|
||
return next;
|
||
});
|
||
} else if (e.key === "0") {
|
||
resetZoom();
|
||
} else if (e.key === "ArrowLeft") {
|
||
navigateImage(1);
|
||
} else if (e.key === "ArrowRight") {
|
||
navigateImage(-1);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [isOpen, onClose, navigateImage, resetZoom]);
|
||
|
||
const handleZoomIn = () => {
|
||
setScale((prev) => Math.min(prev + 0.5, 4));
|
||
};
|
||
|
||
const handleZoomOut = () => {
|
||
setScale((prev) => {
|
||
const next = Math.max(prev - 0.5, 1);
|
||
if (next === 1) setPosition({ x: 0, y: 0 });
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleWheel = (e: React.WheelEvent) => {
|
||
e.preventDefault();
|
||
if (e.deltaY < 0) {
|
||
setScale((prev) => Math.min(prev + 0.25, 4));
|
||
} else {
|
||
setScale((prev) => {
|
||
const next = Math.max(prev - 0.25, 1);
|
||
if (next === 1) setPosition({ x: 0, y: 0 });
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleMouseDown = (e: React.MouseEvent) => {
|
||
if (scale <= 1) return;
|
||
setIsDragging(true);
|
||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||
};
|
||
|
||
const handleMouseMove = (e: React.MouseEvent) => {
|
||
if (!isDragging || scale <= 1) return;
|
||
setPosition({
|
||
x: e.clientX - dragStart.x,
|
||
y: e.clientY - dragStart.y,
|
||
});
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
setIsDragging(false);
|
||
};
|
||
|
||
// Touch drag for mobile
|
||
const handleTouchStart = (e: React.TouchEvent) => {
|
||
if (scale <= 1 || e.touches.length !== 1) return;
|
||
setIsDragging(true);
|
||
setDragStart({
|
||
x: e.touches[0].clientX - position.x,
|
||
y: e.touches[0].clientY - position.y,
|
||
});
|
||
};
|
||
|
||
const handleTouchMove = (e: React.TouchEvent) => {
|
||
if (!isDragging || scale <= 1 || e.touches.length !== 1) return;
|
||
setPosition({
|
||
x: e.touches[0].clientX - dragStart.x,
|
||
y: e.touches[0].clientY - dragStart.y,
|
||
});
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
setIsDragging(false);
|
||
};
|
||
|
||
const toggleFullscreen = () => {
|
||
if (!document.fullscreenElement) {
|
||
containerRef.current?.requestFullscreen?.().catch(() => {});
|
||
setIsFullscreen(true);
|
||
} else {
|
||
document.exitFullscreen?.().catch(() => {});
|
||
setIsFullscreen(false);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const currentImg = activeImage || images[0];
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
<div
|
||
ref={containerRef}
|
||
className="fixed inset-0 z-[110] flex flex-col items-center justify-between bg-black/95 backdrop-blur-xl select-none font-vazir text-white"
|
||
dir="rtl"
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onTouchMove={handleTouchMove}
|
||
onTouchEnd={handleTouchEnd}
|
||
>
|
||
{/* Top Control Bar */}
|
||
<div className="w-full flex items-center justify-between px-4 sm:px-6 py-3.5 bg-black/40 backdrop-blur-md border-b border-white/10 z-20 shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="p-2 sm:p-2.5 rounded-2xl bg-white/10 hover:bg-white/20 text-white hover:text-rose-400 transition-all cursor-pointer flex items-center gap-1.5"
|
||
title="بستن (Esc)"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
<span className="text-xs font-bold hidden sm:inline">بستن</span>
|
||
</button>
|
||
|
||
<div className="flex flex-col text-right pr-2">
|
||
<span className="text-xs sm:text-sm font-black text-white/90 truncate max-w-[200px] sm:max-w-md">
|
||
{productName}
|
||
</span>
|
||
<span className="text-[10px] text-white/50 font-sans">
|
||
{images.length > 1 ? `${images.indexOf(currentImg) + 1} از ${images.length}` : 'تصویر با کیفیت اصلی'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Floating Zoom & Tool Controls */}
|
||
<div className="flex items-center gap-1.5 sm:gap-2 bg-white/10 backdrop-blur-md p-1 sm:p-1.5 rounded-2xl border border-white/15">
|
||
<button
|
||
type="button"
|
||
onClick={handleZoomIn}
|
||
disabled={scale >= 4}
|
||
className="p-2 rounded-xl bg-white/5 hover:bg-white/15 text-white disabled:opacity-30 disabled:cursor-not-allowed transition-all cursor-pointer"
|
||
title="بزرگنمایی (+)"
|
||
>
|
||
<ZoomIn className="w-4 h-4" />
|
||
</button>
|
||
|
||
<span className="text-[11px] font-mono font-bold px-2 py-0.5 min-w-[48px] text-center text-cyan-300">
|
||
{Math.round(scale * 100)}%
|
||
</span>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleZoomOut}
|
||
disabled={scale <= 1}
|
||
className="p-2 rounded-xl bg-white/5 hover:bg-white/15 text-white disabled:opacity-30 disabled:cursor-not-allowed transition-all cursor-pointer"
|
||
title="کوچکنمایی (-)"
|
||
>
|
||
<ZoomOut className="w-4 h-4" />
|
||
</button>
|
||
|
||
{scale > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={resetZoom}
|
||
className="p-2 rounded-xl bg-white/5 hover:bg-white/15 text-white transition-all cursor-pointer"
|
||
title="بازنشانی اندازه (0)"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={toggleFullscreen}
|
||
className="p-2 rounded-xl bg-white/5 hover:bg-white/15 text-white transition-all cursor-pointer hidden sm:flex"
|
||
title="تمامصفحه"
|
||
>
|
||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center Viewer Canvas with Pan and Zoom */}
|
||
<div
|
||
className="relative flex-1 w-full flex items-center justify-center overflow-hidden cursor-default"
|
||
onWheel={handleWheel}
|
||
onMouseDown={handleMouseDown}
|
||
onTouchStart={handleTouchStart}
|
||
>
|
||
{/* Navigation Next / Prev Buttons */}
|
||
{images.length > 1 && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigateImage(-1)}
|
||
className="absolute right-3 sm:right-6 top-1/2 -translate-y-1/2 z-20 p-3 sm:p-3.5 rounded-full bg-black/60 hover:bg-black/90 border border-white/20 text-white backdrop-blur-md transition-all hover:scale-110 cursor-pointer shadow-2xl"
|
||
title="تصویر بعدی (فلش راست)"
|
||
>
|
||
<ChevronRight className="w-5 h-5 sm:w-6 sm:h-6" />
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => navigateImage(1)}
|
||
className="absolute left-3 sm:left-6 top-1/2 -translate-y-1/2 z-20 p-3 sm:p-3.5 rounded-full bg-black/60 hover:bg-black/90 border border-white/20 text-white backdrop-blur-md transition-all hover:scale-110 cursor-pointer shadow-2xl"
|
||
title="تصویر قبلی (فلش چپ)"
|
||
>
|
||
<ChevronLeft className="w-5 h-5 sm:w-6 sm:h-6" />
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Interactive Zoomable Image Element */}
|
||
<div
|
||
className={`relative max-w-full max-h-full flex items-center justify-center transition-transform ${
|
||
isDragging ? "cursor-grabbing duration-0" : scale > 1 ? "cursor-grab duration-150" : "cursor-zoom-in duration-200"
|
||
}`}
|
||
style={{
|
||
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
|
||
transformOrigin: "center center",
|
||
}}
|
||
onClick={(e) => {
|
||
if (scale === 1) {
|
||
setScale(2);
|
||
}
|
||
}}
|
||
>
|
||
{/* Raw unoptimized img element for 100% original full-resolution & clarity */}
|
||
<img
|
||
src={currentImg}
|
||
alt={productName}
|
||
draggable={false}
|
||
className="max-h-[75vh] sm:max-h-[82vh] max-w-[90vw] object-contain drop-shadow-2xl select-none pointer-events-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* Drag instruction helper for zoomed view */}
|
||
{scale > 1 && (
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/70 backdrop-blur-md px-3.5 py-1.5 rounded-full border border-white/20 text-[11px] font-bold text-white/90 flex items-center gap-1.5 pointer-events-none z-10">
|
||
<Move className="w-3.5 h-3.5 text-cyan-300 animate-pulse" />
|
||
<span>برای جابجایی بکشید (Drag) • برای زوم اسکرول کنید</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bottom Thumbnail Strip */}
|
||
{images.length > 1 && (
|
||
<div className="w-full flex items-center justify-center gap-2.5 sm:gap-3 py-3 px-4 bg-black/40 backdrop-blur-md border-t border-white/10 z-20 shrink-0 overflow-x-auto">
|
||
{images.map((img, idx) => {
|
||
const isSelected = img === currentImg;
|
||
return (
|
||
<button
|
||
key={idx}
|
||
type="button"
|
||
onClick={() => {
|
||
onSelectImage(img);
|
||
resetZoom();
|
||
}}
|
||
className={`relative w-14 h-14 sm:w-16 sm:h-16 rounded-2xl overflow-hidden bg-white/10 border-2 transition-all p-1 shrink-0 cursor-pointer ${
|
||
isSelected
|
||
? "border-cyan-400 scale-105 ring-4 ring-cyan-400/30 shadow-lg"
|
||
: "border-white/15 opacity-60 hover:opacity-100 hover:border-white/40"
|
||
}`}
|
||
>
|
||
<img
|
||
src={img}
|
||
alt={`${productName} - ${idx + 1}`}
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</AnimatePresence>
|
||
);
|
||
}
|