From de9878f5fd15518d634299e4eb742a0e2767bd8b Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:48:37 +1100 Subject: [PATCH] Clamp live zoom scale and align wheel coordinates to transform viewport --- web/src/views/live/LiveCameraView.tsx | 59 +++++++++++++++- web/src/views/live/liveZoom.ts | 99 +++++++++++++++++++-------- 2 files changed, 125 insertions(+), 33 deletions(-) diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 0e5278b34..65be4bb6c 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -88,7 +88,11 @@ import { MdPhotoCamera, } from "react-icons/md"; import { Link, useNavigate } from "react-router-dom"; -import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; +import { + TransformWrapper, + TransformComponent, + ReactZoomPanPinchRef, +} from "react-zoom-pan-pinch"; import useSWR from "swr"; import { cn } from "@/lib/utils"; import { useSessionPersistence } from "@/hooks/use-session-persistence"; @@ -124,7 +128,11 @@ import { import ActivityIndicator from "@/components/indicators/activity-indicator"; import { createLiveZoomWrapperProps, + getCursorRelativeZoomTransform, getLiveZoomTransformStyles, + LIVE_ZOOM_MAX_SCALE, + LIVE_ZOOM_MIN_SCALE, + LIVE_ZOOM_SHIFT_WHEEL_STEP, } from "@/views/live/liveZoom"; type LiveCameraViewProps = { @@ -146,6 +154,7 @@ export default function LiveCameraView({ const { isPortrait } = useMobileOrientation(); const mainRef = useRef(null); const containerRef = useRef(null); + const zoomRef = useRef(null); const [{ width: windowWidth, height: windowHeight }] = useResizeObserver(window); @@ -440,8 +449,47 @@ export default function LiveCameraView({ [config, webRTC], ); + const handlePlayerWheel = useCallback( + (e: React.WheelEvent) => { + if (!e.shiftKey || !zoomRef.current) { + return; + } + + const transformState = zoomRef.current.instance.transformState; + const zoomStep = + e.deltaY < 0 ? LIVE_ZOOM_SHIFT_WHEEL_STEP : -LIVE_ZOOM_SHIFT_WHEEL_STEP; + const nextScale = Math.min( + LIVE_ZOOM_MAX_SCALE, + Math.max(LIVE_ZOOM_MIN_SCALE, transformState.scale + zoomStep), + ); + + if (nextScale === transformState.scale) { + return; + } + + e.preventDefault(); + + const rect = e.currentTarget.getBoundingClientRect(); + const nextTransform = getCursorRelativeZoomTransform( + transformState, + { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }, + nextScale, + ); + + zoomRef.current.setTransform( + nextTransform.positionX, + nextTransform.positionY, + nextTransform.scale, + ); + }, + [], + ); + return ( - +
{!debug ? (
- +
; export type LiveZoomTransformStyles = { @@ -19,37 +19,76 @@ export const LIVE_ZOOM_WHEEL_CONFIG: NonNullable< smoothStep: 0.005, }; -const LIVE_ZOOM_TRANSFORM_STYLES: Record = - { - player: { - wrapperStyle: { - width: "100%", - height: "100%", - }, - contentStyle: { - position: "relative", - width: "100%", - height: "100%", - padding: "8px", - }, - }, - debug: { - wrapperStyle: { - width: "100%", - height: "100%", - }, - contentStyle: { - position: "relative", - width: "100%", - height: "100%", - }, - }, - }; +export const LIVE_ZOOM_SHIFT_WHEEL_STEP = 0.1; +export const LIVE_ZOOM_MIN_SCALE = 1; +export const LIVE_ZOOM_MAX_SCALE = 5; + +type LiveZoomTransformState = { + positionX: number; + positionY: number; + scale: number; +}; + +type LiveZoomPoint = { + x: number; + y: number; +}; + +export function getCursorRelativeZoomTransform( + transformState: LiveZoomTransformState, + cursorPoint: LiveZoomPoint, + nextScale: number, +): LiveZoomTransformState { + const scaleRatio = nextScale / transformState.scale; -export function createLiveZoomWrapperProps(disabled: boolean): LiveZoomWrapperProps { return { - minScale: 1, - wheel: LIVE_ZOOM_WHEEL_CONFIG, + scale: nextScale, + positionX: + cursorPoint.x - (cursorPoint.x - transformState.positionX) * scaleRatio, + positionY: + cursorPoint.y - (cursorPoint.y - transformState.positionY) * scaleRatio, + }; +} + +const LIVE_ZOOM_TRANSFORM_STYLES: Record< + LiveZoomMode, + LiveZoomTransformStyles +> = { + player: { + wrapperStyle: { + width: "100%", + height: "100%", + }, + contentStyle: { + position: "relative", + width: "100%", + height: "100%", + padding: "8px", + }, + }, + debug: { + wrapperStyle: { + width: "100%", + height: "100%", + }, + contentStyle: { + position: "relative", + width: "100%", + height: "100%", + }, + }, +}; + +export function createLiveZoomWrapperProps( + disabled: boolean, +): LiveZoomWrapperProps { + return { + minScale: LIVE_ZOOM_MIN_SCALE, + maxScale: LIVE_ZOOM_MAX_SCALE, + wheel: { + ...LIVE_ZOOM_WHEEL_CONFIG, + disabled: true, + }, disabled, }; }