canina/frontend/application/components/ProductImageZoomModal.tsx

355 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}