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 && (