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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user