diff --git a/client/src/app/AppShell.tsx b/client/src/app/AppShell.tsx index 46032837..7fc8ac7f 100644 --- a/client/src/app/AppShell.tsx +++ b/client/src/app/AppShell.tsx @@ -78,6 +78,7 @@ import type { } from "../features/viewport/types"; import { useViewportLayout } from "../features/viewport/useViewportLayout"; import { NewSimulatorModal } from "../features/simulators/NewSimulatorModal"; +import { nextViewportWheelPanState } from "../features/viewport/viewportWheel"; import { buildShellRotationTransform, clampPan, @@ -2097,20 +2098,22 @@ export function AppShell({ return; } - setViewMode("manual"); - setPan((currentPan) => - clampPan( - { - x: currentPan.x - deltaX, - y: currentPan.y + autoViewportOffsetY - deltaY, - }, - effectiveZoom, - canvasSize, - effectiveDeviceNaturalSize, - viewportChromeProfile, - rotationQuarterTurns, - zoomDockReservedHeight, - ), + setPan( + (currentPan) => + nextViewportWheelPanState({ + canvasSize, + chromeProfile: viewportChromeProfile, + deltaX, + deltaY, + deviceNaturalSize: effectiveDeviceNaturalSize, + effectiveZoom, + fitScale, + pan: currentPan, + reservedBottomInset: zoomDockReservedHeight, + rotationQuarterTurns, + viewMode, + zoom, + }).pan, ); } diff --git a/client/src/features/viewport/viewportWheel.test.ts b/client/src/features/viewport/viewportWheel.test.ts new file mode 100644 index 00000000..30bdf8c3 --- /dev/null +++ b/client/src/features/viewport/viewportWheel.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { nextViewportWheelPanState } from "./viewportWheel"; + +describe("nextViewportWheelPanState", () => { + it("preserves fit mode and zoom when a fitted viewport cannot pan", () => { + const pan = { x: 0, y: 0 }; + + expect( + nextViewportWheelPanState({ + canvasSize: { width: 400, height: 800 }, + chromeProfile: null, + deltaX: 0, + deltaY: 120, + deviceNaturalSize: { width: 300, height: 600 }, + effectiveZoom: 1, + fitScale: 1, + pan, + reservedBottomInset: 96, + rotationQuarterTurns: 0, + viewMode: "fit", + zoom: null, + }), + ).toEqual({ + pan, + viewMode: "fit", + zoom: null, + }); + }); + + it("preserves center mode and zoom while panning", () => { + expect( + nextViewportWheelPanState({ + canvasSize: { width: 300, height: 300 }, + chromeProfile: null, + deltaX: 10, + deltaY: 20, + deviceNaturalSize: { width: 600, height: 600 }, + effectiveZoom: 1, + fitScale: 0.5, + pan: { x: 0, y: 0 }, + reservedBottomInset: 96, + rotationQuarterTurns: 0, + viewMode: "center", + zoom: null, + }), + ).toEqual({ + pan: { x: -10, y: -20 }, + viewMode: "center", + zoom: null, + }); + }); + + it("keeps manual zoom during plain wheel panning", () => { + expect( + nextViewportWheelPanState({ + canvasSize: { width: 300, height: 300 }, + chromeProfile: null, + deltaX: 10, + deltaY: 20, + deviceNaturalSize: { width: 600, height: 600 }, + effectiveZoom: 1.35, + fitScale: 0.5, + pan: { x: 0, y: 0 }, + reservedBottomInset: 96, + rotationQuarterTurns: 0, + viewMode: "manual", + zoom: 1.35, + }), + ).toEqual({ + pan: { x: -10, y: -20 }, + viewMode: "manual", + zoom: 1.35, + }); + }); +}); diff --git a/client/src/features/viewport/viewportWheel.ts b/client/src/features/viewport/viewportWheel.ts new file mode 100644 index 00000000..6ccc763c --- /dev/null +++ b/client/src/features/viewport/viewportWheel.ts @@ -0,0 +1,63 @@ +import type { ChromeProfile } from "../../api/types"; +import type { Point, Size, ViewMode } from "./types"; +import { clampPan } from "./viewportMath"; + +const PAN_EPSILON = 0.001; + +interface ViewportWheelState { + viewMode: ViewMode; + zoom: number | null; +} + +interface ViewportWheelPanOptions { + canvasSize: Size | null; + chromeProfile: ChromeProfile | null; + deltaX: number; + deltaY: number; + deviceNaturalSize: Size | null; + effectiveZoom: number; + fitScale: number; + pan: Point; + reservedBottomInset: number; + rotationQuarterTurns: number; + viewMode: ViewMode; + zoom: number | null; +} + +export function nextViewportWheelPanState({ + canvasSize, + chromeProfile, + deltaX, + deltaY, + deviceNaturalSize, + effectiveZoom, + fitScale, + pan, + reservedBottomInset, + rotationQuarterTurns, + viewMode, + zoom, +}: ViewportWheelPanOptions): ViewportWheelState & { pan: Point } { + if (effectiveZoom <= fitScale + PAN_EPSILON) { + return { pan, viewMode, zoom }; + } + + const nextPan = clampPan( + { + x: pan.x - deltaX, + y: pan.y - deltaY, + }, + effectiveZoom, + canvasSize, + deviceNaturalSize, + chromeProfile, + rotationQuarterTurns, + viewMode === "manual" ? reservedBottomInset : 0, + ); + + return { + pan: nextPan, + viewMode, + zoom, + }; +}