"use client"; import Image from "next/image"; import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react"; import { MouseEvent, PointerEvent, WheelEvent, useRef, useState } from "react"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; interface Offset { x: number; y: number } interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number } interface HoverZoomState { lensLeft: number; lensTop: number; lensSize: number; bgX: number; bgY: number; showPanel: boolean; panelLeft: number; panelTop: number; panelSize: number; } // Zoom estilo "lupa" de Amazon: un recuadro semitransparente sobre la // imagen (la lupa) marca la region que se esta mirando, y un panel // aparte a la derecha muestra esa misma region ampliada -- sin recortar // nada de la pantalla ni pedirle al usuario que haga scroll para verlo // completo (a diferencia de un primer intento que escalaba la imagen // in-place con transform, donde para ver el borde superior había que // mover el cursor fuera del viewport visible). Solo mouse (mousemove no // dispara de forma continua en touch); en mobile no aparece el efecto, // sin romper nada. Independiente del zoom interactivo del modal (click // -> Dialog con scale/drag), que sigue igual. // // El panel usa background-image con la foto completa (no la version ya // recortada por object-cover) a proposito -- escalar la imagen recortada // nunca iba a revelar las partes recortadas (ej. la cabeza de una figura // de accion en una foto vertical). background-size en un solo valor // (ancho%, alto en "auto") preserva el aspect ratio real de la foto // completa. // // El tamaño/posicion del panel se calculan en cada mousemove contra el // viewport real (window.innerWidth/innerHeight), no con un breakpoint // fijo tipo "lg:block" -- a resoluciones altas con la ventana angosta // (o el layout de la pagina dejando poco espacio a la derecha de la // imagen) un tamaño fijo terminaba saliéndose de la pantalla. Si no // entra un panel de al menos PANEL_MIN_SIZE, se cae automáticamente al // overlay in-place (misma imagen completa como fondo, dentro de la // propia caja) en vez de mostrar un panel cortado. const HOVER_ZOOM_SCALE = 2.2; const LENS_RATIO = 1 / HOVER_ZOOM_SCALE; const PANEL_MARGIN = 16; const PANEL_MAX_SIZE = 480; const PANEL_MIN_SIZE = 220; const VIEWPORT_EDGE_PADDING = 16; export function ProductGallery({ images, name }: { images: string[]; name: string }) { const [selected, setSelected] = useState(images[0] ?? ""); const [open, setOpen] = useState(false); const [scale, setScale] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [hoverZoom, setHoverZoom] = useState(null); const drag = useRef(null); const trackHoverZoom = (event: MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const lensSize = rect.width * LENS_RATIO; const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; const lensLeft = Math.min(Math.max(cursorX - lensSize / 2, 0), rect.width - lensSize); const lensTop = Math.min(Math.max(cursorY - lensSize / 2, 0), rect.height - lensSize); const availableWidth = window.innerWidth - rect.right - PANEL_MARGIN - VIEWPORT_EDGE_PADDING; const availableHeight = window.innerHeight - VIEWPORT_EDGE_PADDING * 2; const panelSize = Math.min(PANEL_MAX_SIZE, availableWidth, availableHeight); const panelTop = Math.min(Math.max(rect.top, VIEWPORT_EDGE_PADDING), window.innerHeight - panelSize - VIEWPORT_EDGE_PADDING); setHoverZoom({ lensLeft, lensTop, lensSize, bgX: (lensLeft / (rect.width - lensSize)) * 100, bgY: (lensTop / (rect.height - lensSize)) * 100, showPanel: panelSize >= PANEL_MIN_SIZE, panelLeft: rect.right + PANEL_MARGIN, panelTop, panelSize, }); }; const applyScale = (next: number) => { const value = Math.min(3, Math.max(1, Number(next.toFixed(2)))); setScale(value); if (value === 1) setOffset({ x: 0, y: 0 }); }; const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); }; const changeOpen = (value: boolean) => { setOpen(value); if (!value) reset(); }; const wheel = (event: WheelEvent) => { event.preventDefault(); applyScale(scale + (event.deltaY < 0 ? .2 : -.2)); }; const pointerDown = (event: PointerEvent) => { if (scale <= 1) return; event.currentTarget.setPointerCapture(event.pointerId); drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: offset.x, originY: offset.y }; }; const pointerMove = (event: PointerEvent) => { if (!drag.current || drag.current.pointerId !== event.pointerId) return; setOffset({ x: drag.current.originX + event.clientX - drag.current.startX, y: drag.current.originY + event.clientY - drag.current.startY }); }; const pointerEnd = (event: PointerEvent) => { if (drag.current?.pointerId === event.pointerId) drag.current = null; }; return (
{images.map((image) => )}
applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
{name}
Rueda o botones para acercar · Arrastra para mover
applyScale(Number(event.target.value))} className="w-44 accent-fuchsia-500 sm:w-64" /> {Math.round(scale * 100)}%
{hoverZoom?.showPanel && (
)}
); }