feat: add image viewer, carousel, and pan/zoom features
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { IconChevronLeft, IconChevronRight, IconZoomInFilled } from "@tabler/icons-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import ImageViewer from "@/components/ui/ImageViewer";
|
||||||
|
import type { Screenshot } from "@/constants/projects";
|
||||||
|
|
||||||
|
interface ProjectCarouselProps {
|
||||||
|
screenshots: Screenshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Milliseconds each slide stays up before auto-advancing. */
|
||||||
|
const AUTOPLAY_MS = 5000;
|
||||||
|
|
||||||
|
export default function ProjectCarousel({ screenshots }: ProjectCarouselProps) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [viewerOpen, setViewerOpen] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const len = screenshots.length;
|
||||||
|
const go = (i: number) => setIndex(((i % len) + len) % len);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (len < 2 || paused || viewerOpen) return;
|
||||||
|
const t = setInterval(() => setIndex((i) => (i + 1) % len), AUTOPLAY_MS);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [len, paused, viewerOpen]);
|
||||||
|
|
||||||
|
if (len === 0) return null;
|
||||||
|
|
||||||
|
const active = screenshots[index];
|
||||||
|
|
||||||
|
const handleButtonClick = (e: React.MouseEvent, i: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
go(i);
|
||||||
|
setPaused(true);
|
||||||
|
setTimeout(() => setPaused(false), AUTOPLAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={() => { setPaused(true); setIsHovered(true); }}
|
||||||
|
onMouseLeave={() => { setPaused(false); setIsHovered(false); }}
|
||||||
|
onFocusCapture={() => setPaused(true)}
|
||||||
|
onBlurCapture={() => setPaused(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative aspect-[8/5] w-full cursor-pointer overflow-hidden rounded-lg bg-black/50"
|
||||||
|
onClick={() => setViewerOpen(true)}
|
||||||
|
>
|
||||||
|
{screenshots.map((shot, i) => (
|
||||||
|
<Image
|
||||||
|
key={shot.src}
|
||||||
|
src={shot.src}
|
||||||
|
alt={shot.alt}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 768px) 720px, 100vw"
|
||||||
|
priority={i === 0}
|
||||||
|
className={`object-contain transition-opacity duration-500 ${
|
||||||
|
i === index ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 hidden items-center justify-center md:flex">
|
||||||
|
{isHovered && <IconZoomInFilled size={32} className="text-neutral-300" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<p key={index} className="animate-fade-in text-sm text-neutral-500">
|
||||||
|
{active.caption ?? active.alt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{len > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Previous image"
|
||||||
|
onClick={(e) => { handleButtonClick(e, index - 1); }}
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
className={`p-1 rounded-full hover:bg-neutral-800 justify-center items-center flex `}
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
{screenshots.map((shot, i) => (
|
||||||
|
<button
|
||||||
|
key={shot.src}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Go to image ${i + 1}`}
|
||||||
|
aria-current={i === index}
|
||||||
|
onClick={() => go(i)}
|
||||||
|
className={`h-3 rounded-full transition-all cursor-pointer ${
|
||||||
|
i === index ? "w-6 bg-neutral-300" : "w-3 bg-neutral-700 hover:bg-neutral-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Next image"
|
||||||
|
onClick={(e) => { handleButtonClick(e, index + 1); }}
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
className={`p-1 rounded-full hover:bg-neutral-800 justify-center items-center flex `}
|
||||||
|
>
|
||||||
|
<IconChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewerOpen && (
|
||||||
|
<ImageViewer
|
||||||
|
items={screenshots}
|
||||||
|
index={index}
|
||||||
|
onIndexChange={go}
|
||||||
|
onClose={() => setViewerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { IconZoomInFilled } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface ProjectImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
caption?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectImage({ src, alt, caption, onClick }: ProjectImageProps) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsHovered(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsHovered(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={1200}
|
||||||
|
height={750}
|
||||||
|
className={`h-auto w-full rounded-lg border border-neutral-800`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center pt-2">
|
||||||
|
<p className="text-sm text-neutral-500">{caption ?? alt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`pointer-events-none absolute inset-0 z-100 hidden md:flex transition-opacity duration-300 justify-center items-center w-full h-full`}>
|
||||||
|
{isHovered && <IconZoomInFilled size={32} className="text-neutral-300" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { IconX, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||||
|
import { usePanZoom } from '@/hooks/usePanZoom';
|
||||||
|
import type { Screenshot } from '@/constants/projects';
|
||||||
|
|
||||||
|
type ImageViewerProps = {
|
||||||
|
items: Screenshot[];
|
||||||
|
index: number;
|
||||||
|
onIndexChange: (i: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Caption to display — falls back to the required `alt` text. */
|
||||||
|
const captionOf = (s: Screenshot) => s.caption ?? s.alt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-bleed fullscreen image viewer with pan / pinch / wheel zoom, keyboard
|
||||||
|
* navigation, and a thumbnail rail (vertical on desktop, horizontal at the
|
||||||
|
* bottom on mobile). Borrowed from imagelab-gpu's FullscreenImage, trimmed to
|
||||||
|
* the parts this portfolio needs — no slideshow.
|
||||||
|
*/
|
||||||
|
export default function ImageViewer({ items, index, onIndexChange, onClose }: ImageViewerProps) {
|
||||||
|
// Natural size is tagged with the index it was measured for, so it reads as
|
||||||
|
// null until the *current* image's onLoad fires — no reset effect needed.
|
||||||
|
const [sized, setSized] = useState<{ i: number; w: number; h: number } | null>(null);
|
||||||
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeThumbRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const len = items.length;
|
||||||
|
const item = items[index];
|
||||||
|
const prev = () => onIndexChange((index - 1 + len) % len);
|
||||||
|
const next = () => onIndexChange((index + 1) % len);
|
||||||
|
|
||||||
|
const { zoom, offset, reframing, handlers } = usePanZoom(stageRef, {
|
||||||
|
resetKey: index,
|
||||||
|
onSwipe: (dir) => onIndexChange((index + dir + len) % len),
|
||||||
|
onTap: onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lock body scroll while the viewer is open.
|
||||||
|
useEffect(() => {
|
||||||
|
const original = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = original; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard navigation: arrows to move, Esc to close.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
else if (e.key === 'ArrowLeft') onIndexChange((index - 1 + len) % len);
|
||||||
|
else if (e.key === 'ArrowRight') onIndexChange((index + 1) % len);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [index, len, onClose, onIndexChange]);
|
||||||
|
|
||||||
|
// Keep the active thumbnail scrolled into view.
|
||||||
|
useEffect(() => {
|
||||||
|
activeThumbRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
const url = item.src;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[80] flex flex-col overflow-hidden bg-black md:flex-row">
|
||||||
|
{/* Ambient backdrop sampled from the current image via heavy blur. */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="h-full w-full scale-125 object-cover opacity-40"
|
||||||
|
style={{ filter: 'blur(60px)' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stage — owns the pan/zoom gestures. */}
|
||||||
|
<div
|
||||||
|
ref={stageRef}
|
||||||
|
className="relative z-10 min-h-0 flex-1 overflow-hidden"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
onWheel={handlers.onWheel}
|
||||||
|
onPointerDown={handlers.onPointerDown}
|
||||||
|
onPointerMove={handlers.onPointerMove}
|
||||||
|
onPointerUp={handlers.onPointerUp}
|
||||||
|
onPointerCancel={handlers.onPointerUp}
|
||||||
|
onClick={handlers.onClick}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={item.alt}
|
||||||
|
draggable={false}
|
||||||
|
onLoad={(e) => setSized({ i: index, w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
|
||||||
|
className="max-h-full max-w-full select-none rounded-md shadow-[0_20px_70px_rgba(0,0,0,0.7)]"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
||||||
|
transition: reframing ? 'transform 250ms cubic-bezier(0.22, 1, 0.36, 1)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prev / next */}
|
||||||
|
{len > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Previous image"
|
||||||
|
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||||||
|
className="absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Next image"
|
||||||
|
onClick={(e) => { e.stopPropagation(); next(); }}
|
||||||
|
className="absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
|
||||||
|
>
|
||||||
|
<IconChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close viewer"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||||
|
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page counter + dimensions */}
|
||||||
|
<div className="pointer-events-none absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/40 px-3 py-1.5 font-mono text-[12px] text-white/70 backdrop-blur-md">
|
||||||
|
<span>{index + 1} / {len}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Caption */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-10 flex justify-center px-16">
|
||||||
|
<p className="max-w-2xl rounded-full bg-black/40 px-4 py-1.5 text-center text-sm text-white/90 backdrop-blur-md">
|
||||||
|
{captionOf(item)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail rail — horizontal at the bottom on mobile, vertical on the right on desktop. */}
|
||||||
|
{len > 1 && (
|
||||||
|
<div className="z-10 flex shrink-0 gap-2 overflow-x-auto border-t border-white/10 bg-black/40 p-3 backdrop-blur-md md:w-32 md:flex-col md:overflow-x-visible md:overflow-y-auto md:border-l md:border-t-0">
|
||||||
|
{items.map((shot, i) => {
|
||||||
|
const isActive = i === index;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={shot.src}
|
||||||
|
ref={isActive ? activeThumbRef : null}
|
||||||
|
type="button"
|
||||||
|
aria-label={`View ${captionOf(shot)}`}
|
||||||
|
aria-current={isActive}
|
||||||
|
onClick={() => onIndexChange(i)}
|
||||||
|
className={`relative shrink-0 overflow-hidden rounded-md border transition-all md:w-full ${
|
||||||
|
isActive
|
||||||
|
? 'border-white/80 opacity-100 ring-2 ring-white/60'
|
||||||
|
: 'border-white/10 opacity-60 hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={shot.src}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="h-16 w-24 object-cover md:h-20 md:w-full"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { IconXFilled } from "@tabler/icons-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
headline?: string;
|
||||||
|
open?: boolean;
|
||||||
|
setOpen?: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Modal({ children, headline, open, setOpen }: ModalProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const original = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = original;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && handleClose();
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (setOpen) setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted || !open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
onClick={handleClose}
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="fixed inset-0 m-2 sm:m-24 sm:mx-64 xs:mx-64 flex flex-col rounded-md border border-neutral-700 bg-neutral-900/50 p-2"
|
||||||
|
>
|
||||||
|
<nav className="flex justify-between items-center p-2">
|
||||||
|
<div className="text-lg font-medium text-center w-full">
|
||||||
|
{headline}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="text-neutral-400 hover:text-neutral-200 rounded-md p-1 transition-colors duration-200 cursor-pointer"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<IconXFilled />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||||
|
import { THROW_MIN_DISTANCE, createVelocityTracker, runInertia } from '@/lib/momentum';
|
||||||
|
|
||||||
|
const ZOOM_MIN = 1;
|
||||||
|
const ZOOM_MAX = 8;
|
||||||
|
const SWIPE_THRESHOLD = 80; // px of horizontal flick (at zoom ~1) to navigate
|
||||||
|
const CLICK_BUFFER = 6; // px of travel under which a press counts as a tap
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
resetKey: unknown;
|
||||||
|
onSwipe?: (dir: -1 | 1) => void;
|
||||||
|
onTap?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PanZoom = {
|
||||||
|
zoom: number;
|
||||||
|
offset: { x: number; y: number };
|
||||||
|
reframing: boolean;
|
||||||
|
handlers: {
|
||||||
|
onWheel: (e: React.WheelEvent) => void;
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
|
onPointerMove: (e: React.PointerEvent) => void;
|
||||||
|
onPointerUp: (e: React.PointerEvent) => void;
|
||||||
|
onClick: (e: React.MouseEvent) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pan + pinch-zoom + wheel-zoom + release momentum for a CSS-transformed
|
||||||
|
* element. Pure gesture mechanics built on `lib/momentum` — the consumer
|
||||||
|
* supplies `onSwipe` for un-zoomed horizontal flicks and `onTap` for plain
|
||||||
|
* presses, and applies the returned `zoom`/`offset` as a transform.
|
||||||
|
*/
|
||||||
|
export function usePanZoom(
|
||||||
|
containerRef: RefObject<HTMLElement | null>,
|
||||||
|
{ resetKey, onSwipe, onTap }: Options,
|
||||||
|
): PanZoom {
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [reframing, setReframing] = useState(false);
|
||||||
|
const reframeTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const reframe = () => {
|
||||||
|
setZoom(ZOOM_MIN);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
setReframing(true);
|
||||||
|
if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
|
||||||
|
reframeTimerRef.current = window.setTimeout(() => setReframing(false), 260);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointersRef = useRef(new Map<number, { x: number; y: number; sx: number; sy: number }>());
|
||||||
|
const downPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const panStartRef = useRef<{ ox: number; oy: number } | null>(null);
|
||||||
|
const pinchStartRef = useRef<{ d: number; z: number; ox: number; oy: number; cx: number; cy: number } | null>(null);
|
||||||
|
const velocity = useRef(createVelocityTracker());
|
||||||
|
const cancelInertiaRef = useRef<(() => void) | null>(null);
|
||||||
|
const zoomRef = useRef(zoom);
|
||||||
|
const offsetRef = useRef(offset);
|
||||||
|
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||||
|
useEffect(() => { offsetRef.current = offset; }, [offset]);
|
||||||
|
|
||||||
|
const stopInertia = () => {
|
||||||
|
cancelInertiaRef.current?.();
|
||||||
|
cancelInertiaRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [prevKey, setPrevKey] = useState(resetKey);
|
||||||
|
if (prevKey !== resetKey) {
|
||||||
|
setPrevKey(resetKey);
|
||||||
|
setZoom(ZOOM_MIN);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
setReframing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopInertia();
|
||||||
|
if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [resetKey]);
|
||||||
|
useEffect(() => () => {
|
||||||
|
stopInertia();
|
||||||
|
if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
||||||
|
Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
const onWheel = (e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
stopInertia();
|
||||||
|
const r = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
const mx = e.clientX - r.left - r.width / 2;
|
||||||
|
const my = e.clientY - r.top - r.height / 2;
|
||||||
|
const factor = Math.exp(-e.deltaY * 0.001);
|
||||||
|
const z = zoomRef.current;
|
||||||
|
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z * factor));
|
||||||
|
// Hit the floor → reframe to the original framing.
|
||||||
|
if (newZoom <= ZOOM_MIN + 0.001 && z > ZOOM_MIN + 0.001) {
|
||||||
|
reframe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const k = newZoom / z - 1;
|
||||||
|
setOffset(o => ({ x: o.x - mx * k, y: o.y - my * k }));
|
||||||
|
setZoom(newZoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) return;
|
||||||
|
stopInertia();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
const map = pointersRef.current;
|
||||||
|
map.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY });
|
||||||
|
|
||||||
|
if (map.size === 1) {
|
||||||
|
panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y };
|
||||||
|
pinchStartRef.current = null;
|
||||||
|
downPosRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
velocity.current.reset(e.clientX, e.clientY);
|
||||||
|
} else if (map.size === 2) {
|
||||||
|
downPosRef.current = null;
|
||||||
|
const [a, b] = [...map.values()];
|
||||||
|
const r = containerRef.current!.getBoundingClientRect();
|
||||||
|
pinchStartRef.current = {
|
||||||
|
d: dist(a, b),
|
||||||
|
z: zoomRef.current,
|
||||||
|
ox: offsetRef.current.x,
|
||||||
|
oy: offsetRef.current.y,
|
||||||
|
cx: (a.x + b.x) / 2 - r.left - r.width / 2,
|
||||||
|
cy: (a.y + b.y) / 2 - r.top - r.height / 2,
|
||||||
|
};
|
||||||
|
panStartRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
const map = pointersRef.current;
|
||||||
|
const pt = map.get(e.pointerId);
|
||||||
|
if (!pt) return;
|
||||||
|
pt.x = e.clientX;
|
||||||
|
pt.y = e.clientY;
|
||||||
|
|
||||||
|
if (map.size === 1 && panStartRef.current) {
|
||||||
|
const only = [...map.values()][0];
|
||||||
|
setOffset({
|
||||||
|
x: panStartRef.current.ox + (only.x - only.sx),
|
||||||
|
y: panStartRef.current.oy + (only.y - only.sy),
|
||||||
|
});
|
||||||
|
velocity.current.sample(e.clientX, e.clientY);
|
||||||
|
} else if (map.size === 2 && pinchStartRef.current) {
|
||||||
|
const [a, b] = [...map.values()];
|
||||||
|
const r = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
const start = pinchStartRef.current;
|
||||||
|
const newD = dist(a, b);
|
||||||
|
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, start.z * newD / start.d));
|
||||||
|
const ncx = (a.x + b.x) / 2 - r.left - r.width / 2;
|
||||||
|
const ncy = (a.y + b.y) / 2 - r.top - r.height / 2;
|
||||||
|
const imgPx = (start.cx - start.ox) / start.z;
|
||||||
|
const imgPy = (start.cy - start.oy) / start.z;
|
||||||
|
setZoom(newZoom);
|
||||||
|
setOffset({ x: ncx - newZoom * imgPx, y: ncy - newZoom * imgPy });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (e: React.PointerEvent) => {
|
||||||
|
const map = pointersRef.current;
|
||||||
|
const pt = map.get(e.pointerId);
|
||||||
|
if (!pt) return;
|
||||||
|
const dx = pt.x - pt.sx;
|
||||||
|
const dy = pt.y - pt.sy;
|
||||||
|
map.delete(e.pointerId);
|
||||||
|
|
||||||
|
if (map.size === 0) {
|
||||||
|
if (zoomRef.current <= ZOOM_MIN + 0.001 && (offsetRef.current.x !== 0 || offsetRef.current.y !== 0)) {
|
||||||
|
reframe();
|
||||||
|
} else if (panStartRef.current) {
|
||||||
|
const moved = Math.hypot(dx, dy);
|
||||||
|
if (zoomRef.current <= 1.05) {
|
||||||
|
if (moved > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
onSwipe?.(dx > 0 ? -1 : 1);
|
||||||
|
} else {
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
} else if (moved > THROW_MIN_DISTANCE) {
|
||||||
|
stopInertia();
|
||||||
|
cancelInertiaRef.current = runInertia(velocity.current.release(), (sdx, sdy) =>
|
||||||
|
setOffset(o => ({ x: o.x + sdx, y: o.y + sdy })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panStartRef.current = null;
|
||||||
|
pinchStartRef.current = null;
|
||||||
|
} else if (map.size === 1) {
|
||||||
|
const remaining = [...map.values()][0];
|
||||||
|
remaining.sx = remaining.x;
|
||||||
|
remaining.sy = remaining.y;
|
||||||
|
panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y };
|
||||||
|
pinchStartRef.current = null;
|
||||||
|
velocity.current.reset(remaining.x, remaining.y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = (e: React.MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) return;
|
||||||
|
const down = downPosRef.current;
|
||||||
|
downPosRef.current = null;
|
||||||
|
if (!down) return;
|
||||||
|
if (Math.hypot(e.clientX - down.x, e.clientY - down.y) <= CLICK_BUFFER) onTap?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { zoom, offset, reframing, handlers: { onWheel, onPointerDown, onPointerMove, onPointerUp, onClick } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export const THROW_MIN_DISTANCE = 14;
|
||||||
|
export const THROW_GRACE_MS = 24;
|
||||||
|
export const THROW_STALE_MS = 90;
|
||||||
|
|
||||||
|
export function freshness(idleMs: number): number {
|
||||||
|
if (idleMs <= THROW_GRACE_MS) return 1;
|
||||||
|
return Math.max(0, 1 - (idleMs - THROW_GRACE_MS) / (THROW_STALE_MS - THROW_GRACE_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Vec = { x: number; y: number };
|
||||||
|
|
||||||
|
export function createVelocityTracker() {
|
||||||
|
let last: { x: number; y: number; t: number } | null = null;
|
||||||
|
let vx = 0;
|
||||||
|
let vy = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset(x: number, y: number) {
|
||||||
|
last = { x, y, t: performance.now() };
|
||||||
|
vx = 0;
|
||||||
|
vy = 0;
|
||||||
|
},
|
||||||
|
sample(x: number, y: number) {
|
||||||
|
const now = performance.now();
|
||||||
|
if (last) {
|
||||||
|
const dt = now - last.t;
|
||||||
|
if (dt > 0) {
|
||||||
|
vx = vx * 0.7 + ((x - last.x) / dt) * 0.3;
|
||||||
|
vy = vy * 0.7 + ((y - last.y) / dt) * 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last = { x, y, t: now };
|
||||||
|
},
|
||||||
|
release(): Vec {
|
||||||
|
const idle = last ? performance.now() - last.t : Infinity;
|
||||||
|
const f = freshness(idle);
|
||||||
|
return { x: vx * f, y: vy * f };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runInertia(
|
||||||
|
velocity: Vec,
|
||||||
|
onStep: (dx: number, dy: number) => void,
|
||||||
|
opts: { minSpeed?: number; stopSpeed?: number; decayPerFrame?: number } = {},
|
||||||
|
): () => void {
|
||||||
|
const minSpeed = opts.minSpeed ?? 0.04;
|
||||||
|
const stopSpeed = opts.stopSpeed ?? 0.012;
|
||||||
|
const decayPerFrame = opts.decayPerFrame ?? 0.94;
|
||||||
|
let { x: vx, y: vy } = velocity;
|
||||||
|
if (Math.hypot(vx, vy) < minSpeed) return () => { /* nothing started */ };
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const dt = Math.min(40, now - last);
|
||||||
|
last = now;
|
||||||
|
onStep(vx * dt, vy * dt);
|
||||||
|
const decay = Math.pow(decayPerFrame, dt / 16);
|
||||||
|
vx *= decay;
|
||||||
|
vy *= decay;
|
||||||
|
if (Math.hypot(vx, vy) > stopSpeed) {
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
raf = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => { if (raf) cancelAnimationFrame(raf); };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user