Clamp live zoom scale and align wheel coordinates to transform viewport

This commit is contained in:
ibs0d 2026-03-08 23:48:37 +11:00
parent f914a0c81c
commit de9878f5fd
2 changed files with 125 additions and 33 deletions

View File

@ -88,7 +88,11 @@ import {
MdPhotoCamera, MdPhotoCamera,
} from "react-icons/md"; } from "react-icons/md";
import { Link, useNavigate } from "react-router-dom"; 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 useSWR from "swr";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence"; import { useSessionPersistence } from "@/hooks/use-session-persistence";
@ -124,7 +128,11 @@ import {
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { import {
createLiveZoomWrapperProps, createLiveZoomWrapperProps,
getCursorRelativeZoomTransform,
getLiveZoomTransformStyles, getLiveZoomTransformStyles,
LIVE_ZOOM_MAX_SCALE,
LIVE_ZOOM_MIN_SCALE,
LIVE_ZOOM_SHIFT_WHEEL_STEP,
} from "@/views/live/liveZoom"; } from "@/views/live/liveZoom";
type LiveCameraViewProps = { type LiveCameraViewProps = {
@ -146,6 +154,7 @@ export default function LiveCameraView({
const { isPortrait } = useMobileOrientation(); const { isPortrait } = useMobileOrientation();
const mainRef = useRef<HTMLDivElement | null>(null); const mainRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const zoomRef = useRef<ReactZoomPanPinchRef | null>(null);
const [{ width: windowWidth, height: windowHeight }] = const [{ width: windowWidth, height: windowHeight }] =
useResizeObserver(window); useResizeObserver(window);
@ -440,8 +449,47 @@ export default function LiveCameraView({
[config, webRTC], [config, webRTC],
); );
const handlePlayerWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => {
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 ( return (
<TransformWrapper {...createLiveZoomWrapperProps(debug)}> <TransformWrapper ref={zoomRef} {...createLiveZoomWrapperProps(debug)}>
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
<div <div
ref={mainRef} ref={mainRef}
@ -621,7 +669,12 @@ export default function LiveCameraView({
</div> </div>
{!debug ? ( {!debug ? (
<div id="player-container" className="size-full" ref={containerRef}> <div id="player-container" className="size-full" ref={containerRef}>
<TransformComponent {...getLiveZoomTransformStyles("player")}> <TransformComponent
{...getLiveZoomTransformStyles("player")}
wrapperProps={{
onWheel: handlePlayerWheel,
}}
>
<div <div
className={`flex flex-col items-center justify-center ${growClassName}`} className={`flex flex-col items-center justify-center ${growClassName}`}
ref={clickOverlayRef} ref={clickOverlayRef}

View File

@ -5,7 +5,7 @@ export type LiveZoomMode = "player" | "debug";
export type LiveZoomWrapperProps = Pick< export type LiveZoomWrapperProps = Pick<
ReactZoomPanPinchProps, ReactZoomPanPinchProps,
"minScale" | "wheel" | "disabled" "minScale" | "maxScale" | "wheel" | "disabled"
>; >;
export type LiveZoomTransformStyles = { export type LiveZoomTransformStyles = {
@ -19,37 +19,76 @@ export const LIVE_ZOOM_WHEEL_CONFIG: NonNullable<
smoothStep: 0.005, smoothStep: 0.005,
}; };
const LIVE_ZOOM_TRANSFORM_STYLES: Record<LiveZoomMode, LiveZoomTransformStyles> = export const LIVE_ZOOM_SHIFT_WHEEL_STEP = 0.1;
{ export const LIVE_ZOOM_MIN_SCALE = 1;
player: { export const LIVE_ZOOM_MAX_SCALE = 5;
wrapperStyle: {
width: "100%", type LiveZoomTransformState = {
height: "100%", positionX: number;
}, positionY: number;
contentStyle: { scale: number;
position: "relative", };
width: "100%",
height: "100%", type LiveZoomPoint = {
padding: "8px", x: number;
}, y: number;
}, };
debug: {
wrapperStyle: { export function getCursorRelativeZoomTransform(
width: "100%", transformState: LiveZoomTransformState,
height: "100%", cursorPoint: LiveZoomPoint,
}, nextScale: number,
contentStyle: { ): LiveZoomTransformState {
position: "relative", const scaleRatio = nextScale / transformState.scale;
width: "100%",
height: "100%",
},
},
};
export function createLiveZoomWrapperProps(disabled: boolean): LiveZoomWrapperProps {
return { return {
minScale: 1, scale: nextScale,
wheel: LIVE_ZOOM_WHEEL_CONFIG, 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, disabled,
}; };
} }