feat(review): zoom and pan in the review player
Deploy / deploy (push) Successful in 3m16s

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>
This commit is contained in:
twotalesanimation
2026-09-22 14:25:46 +02:00
parent 45459e4b12
commit 428814b8b9
2 changed files with 327 additions and 4 deletions
+82 -4
View File
@@ -15,6 +15,8 @@ import { AnnotationTools } from "@/components/annotations/AnnotationTools";
import { AspectOverlay } from "./AspectOverlay";
import { FrameTimeline } from "./FrameTimeline";
import { PlaybackControls } from "./PlaybackControls";
import { useZoomPan } from "@/hooks/use-zoom-pan";
import { Minus, Plus } from "lucide-react";
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
import { cn } from "@/lib/utils";
import type { CommentWithReplies } from "@/types";
@@ -61,6 +63,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
const stableAnnotations = useMemo(() => annotations ?? [], [annotations]);
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const zoomLayerRef = useRef<HTMLDivElement>(null);
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isReversing, setIsReversing] = useState(false);
@@ -79,6 +83,11 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
setTotalFrames,
} = useReviewStore();
const zoom = useZoomPan(stageRef, zoomLayerRef, {
gesturesEnabled: !isAnnotating,
});
const { reset: resetZoom, zoomBy } = zoom;
// Initialize player config from props
useEffect(() => {
setFps(fps);
@@ -92,7 +101,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
setCurrentTime(0);
setDuration(0);
setTotalFrames(0);
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames]);
resetZoom();
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]);
// ── Playback state sync ──────────────────────────────────────────────────
const handleTimeUpdate = useCallback(() => {
@@ -222,6 +232,20 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
e.preventDefault();
toggleFullscreen();
break;
case "=":
case "+":
e.preventDefault();
zoomBy(1.25);
break;
case "-":
case "_":
e.preventDefault();
zoomBy(0.8);
break;
case "0":
e.preventDefault();
resetZoom();
break;
}
};
@@ -230,7 +254,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
window.removeEventListener("keydown", handleKeyDown);
stopReverse();
};
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]);
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, zoomBy, resetZoom]);
// ── Fullscreen ───────────────────────────────────────────────────────────
const toggleFullscreen = () => {
@@ -269,11 +293,28 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
className
)}
>
{/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating) */}
{/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating).
Wheel / pinch zooms, drag pans when zoomed; a drag never counts as a tap. */}
<div
ref={stageRef}
className="relative flex-1 min-h-0 overflow-hidden"
onClick={!isAnnotating ? togglePlayback : undefined}
style={zoom.stageStyle}
{...zoom.stageHandlers}
onClick={
!isAnnotating
? () => {
if (!zoom.consumeDrag()) togglePlayback();
}
: undefined
}
>
{/* Zoomed as one layer so blanking and annotations stay pinned to the
picture — annotation coords are normalised from the canvas's
transformed bounding rect, so drawing while zoomed lands correctly. */}
<div
ref={zoomLayerRef}
className="absolute inset-0 origin-top-left will-change-transform"
>
<video
ref={videoRef}
src={videoUrl}
@@ -306,6 +347,43 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
saveAnnotationFn={saveAnnotationFn as any}
addCommentFn={addCommentFn}
/>
</div>
{/* Zoom badge — only while zoomed */}
{zoom.zoomed && (
<div
className="absolute top-3 right-3 flex items-center gap-0.5 rounded bg-black/70 p-0.5 text-xs text-white"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="rounded p-1.5 hover:bg-white/15"
onClick={() => zoomBy(0.8)}
aria-label="Zoom out"
>
<Minus className="h-3.5 w-3.5" />
</button>
<span className="w-10 text-center font-mono tabular-nums">
{Math.round(zoom.scale * 100)}%
</span>
<button
type="button"
className="rounded p-1.5 hover:bg-white/15"
onClick={() => zoomBy(1.25)}
aria-label="Zoom in"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded px-2 py-1 hover:bg-white/15"
onClick={resetZoom}
>
Fit
</button>
</div>
)}
{/* JKL hint overlay — shown briefly when reversing */}
{isReversing && (
+245
View File
@@ -0,0 +1,245 @@
"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,
};
}