Files
twotalesanimation 428814b8b9
Deploy / deploy (push) Successful in 3m16s
feat(review): zoom and pan in the review player
Wheel / trackpad pinch zooms toward the cursor (up to 8x), drag pans when
zoomed, and two-finger pinch works on phones. A drag never toggles
playback. A badge shows the zoom level with -/+/Fit while zoomed, and
+ / - / 0 work from the keyboard. Zoom resets when the video changes.

Video, blanking and the annotation canvas zoom as one layer, so marks
drawn while zoomed land where the pointer is and stay pinned to the image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:25:46 +02:00

246 lines
7.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
export const MIN_ZOOM = 1;
export const MAX_ZOOM = 8;
interface Transform {
scale: number;
x: number;
y: number;
}
interface Pointer {
x: number;
y: number;
}
type Gesture =
| { kind: "pan"; startX: number; startY: number; originX: number; originY: number }
| {
kind: "pinch";
startDist: number;
// Content-space point that sat under the fingers' midpoint at the start;
// it is kept under the (moving) midpoint for the whole pinch.
contentX: number;
contentY: number;
startScale: number;
}
| null;
/** Movement (px) past which a press counts as a drag rather than a click. */
const DRAG_THRESHOLD = 4;
/**
* Zoom and pan for the review player's picture.
*
* `stageRef` is the clipping box that receives gestures; `layerRef` is the
* element that gets transformed. The transform is written straight to the
* layer's style so panning never re-renders the player; only `scale` is state
* (for the zoom badge).
*
* - Wheel / trackpad pinch: zoom toward the cursor (always, even while drawing)
* - Drag (mouse, or one finger): pan, when zoomed and `gesturesEnabled`
* - Two-finger pinch: zoom + pan, when `gesturesEnabled`
*/
export function useZoomPan(
stageRef: RefObject<HTMLElement | null>,
layerRef: RefObject<HTMLElement | null>,
{ gesturesEnabled }: { gesturesEnabled: boolean }
) {
const [scale, setScale] = useState(1);
const t = useRef<Transform>({ scale: 1, x: 0, y: 0 });
const pointers = useRef(new Map<number, Pointer>());
const gesture = useRef<Gesture>(null);
const dragged = useRef(false);
const pressStart = useRef<Pointer | null>(null);
const apply = useCallback(
(next: Transform) => {
const stage = stageRef.current;
const w = stage?.clientWidth ?? 0;
const h = stage?.clientHeight ?? 0;
const s = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next.scale));
// Keep the layer covering the stage: no empty gutter past the picture edge.
const x = Math.min(0, Math.max(w - w * s, next.x));
const y = Math.min(0, Math.max(h - h * s, next.y));
t.current = { scale: s, x, y };
const layer = layerRef.current;
if (layer) {
layer.style.transform =
s === 1 ? "" : `translate(${x}px, ${y}px) scale(${s})`;
}
setScale(s);
},
[stageRef, layerRef]
);
/** Zoom to `nextScale`, keeping the stage point (px, py) fixed on screen. */
const zoomAt = useCallback(
(nextScale: number, px: number, py: number) => {
const { scale: s, x, y } = t.current;
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextScale));
const cx = (px - x) / s;
const cy = (py - y) / s;
apply({ scale: clamped, x: px - cx * clamped, y: py - cy * clamped });
},
[apply]
);
const zoomBy = useCallback(
(factor: number) => {
const stage = stageRef.current;
if (!stage) return;
zoomAt(t.current.scale * factor, stage.clientWidth / 2, stage.clientHeight / 2);
},
[stageRef, zoomAt]
);
const reset = useCallback(() => apply({ scale: 1, x: 0, y: 0 }), [apply]);
// Wheel needs a non-passive native listener to be able to preventDefault.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? stage.clientHeight : 1;
// Trackpad pinch arrives as ctrl+wheel with small deltas — make it snappier.
const k = e.ctrlKey ? 0.01 : 0.0015;
const rect = stage.getBoundingClientRect();
zoomAt(
t.current.scale * Math.exp(-e.deltaY * unit * k),
e.clientX - rect.left,
e.clientY - rect.top
);
};
stage.addEventListener("wheel", onWheel, { passive: false });
return () => stage.removeEventListener("wheel", onWheel);
}, [stageRef, zoomAt]);
// Re-clamp when the player changes size (panel resize, fullscreen, rotate).
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const observer = new ResizeObserver(() => apply(t.current));
observer.observe(stage);
return () => observer.disconnect();
}, [stageRef, apply]);
const local = (e: React.PointerEvent): Pointer => {
const rect = stageRef.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const beginGesture = () => {
const pts = [...pointers.current.values()];
const { scale: s, x, y } = t.current;
if (pts.length >= 2) {
const [a, b] = pts;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
gesture.current = {
kind: "pinch",
startDist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
contentX: (mx - x) / s,
contentY: (my - y) / s,
startScale: s,
};
} else if (pts.length === 1 && s > 1) {
gesture.current = {
kind: "pan",
startX: pts[0].x,
startY: pts[0].y,
originX: x,
originY: y,
};
} else {
gesture.current = null;
}
};
const onPointerDown = (e: React.PointerEvent) => {
if (!gesturesEnabled || !stageRef.current) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const p = local(e);
if (pointers.current.size === 0) {
dragged.current = false;
pressStart.current = p;
}
pointers.current.set(e.pointerId, p);
if (pointers.current.size >= 2) dragged.current = true; // a pinch is never a tap
beginGesture();
if (gesture.current) stageRef.current.setPointerCapture?.(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!pointers.current.has(e.pointerId)) return;
const p = local(e);
pointers.current.set(e.pointerId, p);
const start = pressStart.current;
if (start && Math.hypot(p.x - start.x, p.y - start.y) > DRAG_THRESHOLD) {
dragged.current = true;
}
const g = gesture.current;
if (!g) return;
if (g.kind === "pan") {
apply({
scale: t.current.scale,
x: g.originX + (p.x - g.startX),
y: g.originY + (p.y - g.startY),
});
} else {
const [a, b] = [...pointers.current.values()];
if (!a || !b) return;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const s = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, (g.startScale * Math.hypot(a.x - b.x, a.y - b.y)) / g.startDist)
);
apply({ scale: s, x: mx - g.contentX * s, y: my - g.contentY * s });
}
};
const onPointerEnd = (e: React.PointerEvent) => {
if (!pointers.current.delete(e.pointerId)) return;
// Lifting one finger of a pinch hands over to a pan from where it is now.
beginGesture();
};
/**
* Call from the stage's click handler: true when the click is really the
* tail end of a drag/pinch and should not toggle playback.
*/
const consumeDrag = useCallback(() => {
const was = dragged.current;
dragged.current = false;
return was;
}, []);
const zoomed = scale > 1;
return {
scale,
zoomed,
zoomBy,
reset,
consumeDrag,
stageHandlers: {
onPointerDown,
onPointerMove,
onPointerUp: onPointerEnd,
onPointerCancel: onPointerEnd,
},
stageStyle: {
// At 1x leave vertical page scrolling to the browser on phones, but keep
// pinch for us; once zoomed every touch belongs to the player.
touchAction: zoomed ? "none" : "pan-y",
cursor: gesturesEnabled && zoomed ? "grab" : undefined,
} as React.CSSProperties,
};
}