fix(ecommerce): zoom de producto estilo lupa de Amazon (lupa + panel lateral)
El zoom in-place anterior (overlay de fondo dentro de la misma caja)
seguía sintiéndose confuso: el usuario esperaba el patrón clásico de
e-commerce (Amazon) -- un recuadro semitransparente ("lupa") que marca
qué región de la foto se está mirando, y un panel aparte, más grande,
que muestra esa región ampliada. Sin scroll ni superposición dentro de
la misma caja.
- Lupa: recuadro que sigue al cursor sobre la imagen principal,
clampeado para no salirse de los bordes (visible en lg+).
- bgX/bgY ahora se calculan por fracción de recorrido de la lupa
(posición del borde de la lupa entre 0 y el ancho disponible), no por
posición cruda del cursor -- mapeo correcto incluso cuando la lupa
queda pegada a un borde.
- Panel: a la derecha de la imagen (position absolute, se superpone al
contenido de al lado igual que en Amazon), mismo mecanismo de
background-image con la foto completa que ya evitaba el problema de
object-cover recortando partes de la imagen.
- Por debajo de lg (sin espacio para un panel aparte) se conserva el
overlay in-place como fallback.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
This commit is contained in:
1 parent
f0b1d7b745
commit
8d16f2b854
1 file changed
+89
-51
@@ -7,38 +7,49 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
|||||||
|
|
||||||
interface Offset { x: number; y: number }
|
interface Offset { x: number; y: number }
|
||||||
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
|
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
|
||||||
interface HoverZoomOrigin { x: number; y: number }
|
interface HoverZoomState { lensLeft: number; lensTop: number; lensSize: number; bgX: number; bgY: number }
|
||||||
|
|
||||||
// Zoom automatico al pasar el cursor (estilo lupa clasico de e-commerce)
|
// Zoom estilo "lupa" de Amazon: un recuadro semitransparente sobre la
|
||||||
// sobre la imagen principal -- solo mouse (mousemove no dispara de forma
|
// imagen (la lupa) marca la region que se esta mirando, y un panel
|
||||||
// continua en touch, asi que en mobile simplemente no aparece el efecto,
|
// aparte a la derecha muestra esa misma region ampliada -- sin recortar
|
||||||
// sin romper nada). Independiente del zoom interactivo del modal
|
// nada de la pantalla ni pedirle al usuario que haga scroll para verlo
|
||||||
// (click -> Dialog con scale/drag), que sigue igual.
|
// 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 overlay de hover usa background-image (no transform:scale sobre el
|
// El panel usa background-image con la foto completa (no la version ya
|
||||||
// <Image object-cover>) a proposito: escalar la imagen ya recortada por
|
// recortada por object-cover) a proposito -- escalar la imagen recortada
|
||||||
// object-cover nunca iba a revelar las partes recortadas (ej. la cabeza
|
// nunca iba a revelar las partes recortadas (ej. la cabeza de una figura
|
||||||
// de una figura de accion en una foto vertical) -- el recorte pasa antes
|
// de accion en una foto vertical). background-size en un solo valor
|
||||||
// del zoom, en el propio render. Con background-size en un solo valor
|
// (ancho%, alto en "auto") preserva el aspect ratio real de la foto
|
||||||
// (ancho%, alto en "auto") se preserva el aspect ratio real de la foto
|
// completa.
|
||||||
// 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;
|
const HOVER_ZOOM_SCALE = 2.2;
|
||||||
|
const LENS_RATIO = 1 / HOVER_ZOOM_SCALE;
|
||||||
|
|
||||||
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
||||||
const [selected, setSelected] = useState(images[0] ?? "");
|
const [selected, setSelected] = useState(images[0] ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
|
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
|
||||||
const [hoverZoom, setHoverZoom] = useState<HoverZoomOrigin | null>(null);
|
const [hoverZoom, setHoverZoom] = useState<HoverZoomState | null>(null);
|
||||||
const drag = useRef<DragState | null>(null);
|
const drag = useRef<DragState | null>(null);
|
||||||
|
|
||||||
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
|
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
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);
|
||||||
setHoverZoom({
|
setHoverZoom({
|
||||||
x: ((event.clientX - rect.left) / rect.width) * 100,
|
lensLeft,
|
||||||
y: ((event.clientY - rect.top) / rect.height) * 100,
|
lensTop,
|
||||||
|
lensSize,
|
||||||
|
bgX: (lensLeft / (rect.width - lensSize)) * 100,
|
||||||
|
bgY: (lensTop / (rect.height - lensSize)) * 100,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,37 +80,50 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
|||||||
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
|
<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>)}
|
{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>
|
</div>
|
||||||
<Dialog open={open} onOpenChange={changeOpen}>
|
<div className="relative order-1 lg:order-2">
|
||||||
<DialogTrigger asChild>
|
<Dialog open={open} onOpenChange={changeOpen}>
|
||||||
<button
|
<DialogTrigger asChild>
|
||||||
type="button"
|
<button
|
||||||
className="group relative order-1 aspect-square cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2"
|
type="button"
|
||||||
onMouseMove={trackHoverZoom}
|
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50"
|
||||||
onMouseLeave={() => setHoverZoom(null)}
|
onMouseMove={trackHoverZoom}
|
||||||
>
|
onMouseLeave={() => setHoverZoom(null)}
|
||||||
<Image
|
>
|
||||||
src={selected}
|
<Image
|
||||||
alt={name}
|
src={selected}
|
||||||
fill
|
alt={name}
|
||||||
priority
|
fill
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
priority
|
||||||
className="object-cover"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
className="object-cover"
|
||||||
<div
|
/>
|
||||||
aria-hidden
|
{/* Lupa: recuadro que marca la region ampliada, visible desde
|
||||||
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
|
lg (donde también hay lugar para el panel de al lado). Por
|
||||||
style={{
|
debajo de lg se usa el overlay in-place (misma imagen
|
||||||
opacity: hoverZoom ? 1 : 0,
|
completa como fondo, sin lupa ni panel aparte) porque no
|
||||||
backgroundImage: `url(${selected})`,
|
hay espacio horizontal para un panel flotante. */}
|
||||||
backgroundRepeat: "no-repeat",
|
{hoverZoom && (
|
||||||
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
|
<div
|
||||||
backgroundPosition: hoverZoom ? `${hoverZoom.x}% ${hoverZoom.y}%` : "center",
|
aria-hidden
|
||||||
}}
|
className="pointer-events-none absolute hidden border-2 border-white bg-white/25 shadow-lg lg:block"
|
||||||
/>
|
style={{ left: hoverZoom.lensLeft, top: hoverZoom.lensTop, width: hoverZoom.lensSize, height: hoverZoom.lensSize }}
|
||||||
<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>
|
<div
|
||||||
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out lg:hidden"
|
||||||
|
style={{
|
||||||
|
opacity: hoverZoom ? 1 : 0,
|
||||||
|
backgroundImage: `url(${selected})`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
|
||||||
|
backgroundPosition: hoverZoom ? `${hoverZoom.bgX}% ${hoverZoom.bgY}%` : "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="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="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})` }}>
|
<div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
|
||||||
@@ -115,8 +139,22 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
|||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{hoverZoom && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute left-full top-0 z-30 ml-4 hidden aspect-square w-[26rem] max-w-[40vw] overflow-hidden rounded-[2rem] border border-slate-200 bg-white shadow-2xl lg:block"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${selected})`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
|
||||||
|
backgroundPosition: `${hoverZoom.bgX}% ${hoverZoom.bgY}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user