214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
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 } };
|
|
}
|