El hover-zoom escalaba (transform:scale) la misma <Image> ya recortada por object-cover -- para fotos verticales (ej. figura de acción de pie) eso dejaba partes del producto (la cabeza) fuera del recorte desde el propio render, así que ningún nivel de zoom las mostraba. Se reemplaza por un overlay con background-image usando la foto completa (background-size en un solo valor preserva el aspect ratio real) y background-position siguiendo el cursor, que sí permite recorrer toda la imagen. De paso: las miniaturas ahora cambian la imagen principal también con hover (onMouseEnter), no solo con click. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
123 lines
7.5 KiB
TypeScript
123 lines
7.5 KiB
TypeScript
"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 HoverZoomOrigin { x: number; y: number }
|
|
|
|
// Zoom automatico al pasar el cursor (estilo lupa clasico de e-commerce)
|
|
// sobre la imagen principal -- solo mouse (mousemove no dispara de forma
|
|
// continua en touch, asi que en mobile simplemente no aparece el efecto,
|
|
// sin romper nada). Independiente del zoom interactivo del modal
|
|
// (click -> Dialog con scale/drag), que sigue igual.
|
|
//
|
|
// El overlay de hover usa background-image (no transform:scale sobre el
|
|
// <Image object-cover>) a proposito: escalar la imagen ya recortada por
|
|
// object-cover nunca iba a revelar las partes recortadas (ej. la cabeza
|
|
// de una figura de accion en una foto vertical) -- el recorte pasa antes
|
|
// del zoom, en el propio render. Con background-size en un solo valor
|
|
// (ancho%, alto en "auto") se preserva el aspect ratio real de la foto
|
|
// completa, y background-position 0%-100% permite recorrer TODA la
|
|
// imagen con el cursor, no solo la porcion que object-cover dejaba
|
|
// visible en reposo.
|
|
const HOVER_ZOOM_SCALE = 2.2;
|
|
|
|
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<Offset>({ x: 0, y: 0 });
|
|
const [hoverZoom, setHoverZoom] = useState<HoverZoomOrigin | null>(null);
|
|
const drag = useRef<DragState | null>(null);
|
|
|
|
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
setHoverZoom({
|
|
x: ((event.clientX - rect.left) / rect.width) * 100,
|
|
y: ((event.clientY - rect.top) / rect.height) * 100,
|
|
});
|
|
};
|
|
|
|
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<HTMLDivElement>) => { event.preventDefault(); applyScale(scale + (event.deltaY < 0 ? .2 : -.2)); };
|
|
const pointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
if (drag.current?.pointerId === event.pointerId) drag.current = null;
|
|
};
|
|
|
|
return (
|
|
<div className="grid gap-4 lg:grid-cols-[86px_1fr]">
|
|
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
|
|
{images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} onMouseEnter={() => { setSelected(image); reset(); }} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}
|
|
</div>
|
|
<Dialog open={open} onOpenChange={changeOpen}>
|
|
<DialogTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="group relative order-1 aspect-square cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2"
|
|
onMouseMove={trackHoverZoom}
|
|
onMouseLeave={() => setHoverZoom(null)}
|
|
>
|
|
<Image
|
|
src={selected}
|
|
alt={name}
|
|
fill
|
|
priority
|
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
className="object-cover"
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
|
|
style={{
|
|
opacity: hoverZoom ? 1 : 0,
|
|
backgroundImage: `url(${selected})`,
|
|
backgroundRepeat: "no-repeat",
|
|
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
|
|
backgroundPosition: hoverZoom ? `${hoverZoom.x}% ${hoverZoom.y}%` : "center",
|
|
}}
|
|
/>
|
|
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
|
|
</button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
|
|
<div className="grid min-h-[70vh] grid-rows-[1fr_auto]">
|
|
<div className="relative overflow-hidden bg-slate-950" onWheel={wheel} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerEnd} onPointerCancel={pointerEnd} onDoubleClick={() => applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
|
|
<div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
|
|
<Image src={selected} alt={name} fill sizes="95vw" className="select-none object-contain" draggable={false} />
|
|
</div>
|
|
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-black/65 px-3 py-1.5 text-xs font-black text-white backdrop-blur">Rueda o botones para acercar · Arrastra para mover</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 bg-slate-900 p-4 text-white">
|
|
<button type="button" aria-label="Alejar" onClick={() => applyScale(scale - .25)} disabled={scale <= 1} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Minus /></button>
|
|
<input aria-label="Nivel de zoom" type="range" min="1" max="3" step="0.1" value={scale} onChange={(event) => applyScale(Number(event.target.value))} className="w-44 accent-fuchsia-500 sm:w-64" />
|
|
<button type="button" aria-label="Acercar" onClick={() => applyScale(scale + .25)} disabled={scale >= 3} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Plus /></button>
|
|
<button type="button" onClick={reset} className="inline-flex h-11 items-center gap-2 rounded-full bg-white/10 px-4 text-sm font-black hover:bg-white/20"><RotateCcw className="size-4" />Restablecer</button>
|
|
<span className="inline-flex h-11 items-center gap-2 rounded-full bg-fuchsia-500 px-4 text-sm font-black"><Maximize2 className="size-4" />{Math.round(scale * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|