Skip to content

Commit 383ff45

Browse files
authored
fix(viewport): preserve zoom mode on wheel pan (#53)
1 parent f0e34a4 commit 383ff45

3 files changed

Lines changed: 156 additions & 14 deletions

File tree

client/src/app/AppShell.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import type {
7878
} from "../features/viewport/types";
7979
import { useViewportLayout } from "../features/viewport/useViewportLayout";
8080
import { NewSimulatorModal } from "../features/simulators/NewSimulatorModal";
81+
import { nextViewportWheelPanState } from "../features/viewport/viewportWheel";
8182
import {
8283
buildShellRotationTransform,
8384
clampPan,
@@ -2097,20 +2098,22 @@ export function AppShell({
20972098
return;
20982099
}
20992100

2100-
setViewMode("manual");
2101-
setPan((currentPan) =>
2102-
clampPan(
2103-
{
2104-
x: currentPan.x - deltaX,
2105-
y: currentPan.y + autoViewportOffsetY - deltaY,
2106-
},
2107-
effectiveZoom,
2108-
canvasSize,
2109-
effectiveDeviceNaturalSize,
2110-
viewportChromeProfile,
2111-
rotationQuarterTurns,
2112-
zoomDockReservedHeight,
2113-
),
2101+
setPan(
2102+
(currentPan) =>
2103+
nextViewportWheelPanState({
2104+
canvasSize,
2105+
chromeProfile: viewportChromeProfile,
2106+
deltaX,
2107+
deltaY,
2108+
deviceNaturalSize: effectiveDeviceNaturalSize,
2109+
effectiveZoom,
2110+
fitScale,
2111+
pan: currentPan,
2112+
reservedBottomInset: zoomDockReservedHeight,
2113+
rotationQuarterTurns,
2114+
viewMode,
2115+
zoom,
2116+
}).pan,
21142117
);
21152118
}
21162119

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { nextViewportWheelPanState } from "./viewportWheel";
4+
5+
describe("nextViewportWheelPanState", () => {
6+
it("preserves fit mode and zoom when a fitted viewport cannot pan", () => {
7+
const pan = { x: 0, y: 0 };
8+
9+
expect(
10+
nextViewportWheelPanState({
11+
canvasSize: { width: 400, height: 800 },
12+
chromeProfile: null,
13+
deltaX: 0,
14+
deltaY: 120,
15+
deviceNaturalSize: { width: 300, height: 600 },
16+
effectiveZoom: 1,
17+
fitScale: 1,
18+
pan,
19+
reservedBottomInset: 96,
20+
rotationQuarterTurns: 0,
21+
viewMode: "fit",
22+
zoom: null,
23+
}),
24+
).toEqual({
25+
pan,
26+
viewMode: "fit",
27+
zoom: null,
28+
});
29+
});
30+
31+
it("preserves center mode and zoom while panning", () => {
32+
expect(
33+
nextViewportWheelPanState({
34+
canvasSize: { width: 300, height: 300 },
35+
chromeProfile: null,
36+
deltaX: 10,
37+
deltaY: 20,
38+
deviceNaturalSize: { width: 600, height: 600 },
39+
effectiveZoom: 1,
40+
fitScale: 0.5,
41+
pan: { x: 0, y: 0 },
42+
reservedBottomInset: 96,
43+
rotationQuarterTurns: 0,
44+
viewMode: "center",
45+
zoom: null,
46+
}),
47+
).toEqual({
48+
pan: { x: -10, y: -20 },
49+
viewMode: "center",
50+
zoom: null,
51+
});
52+
});
53+
54+
it("keeps manual zoom during plain wheel panning", () => {
55+
expect(
56+
nextViewportWheelPanState({
57+
canvasSize: { width: 300, height: 300 },
58+
chromeProfile: null,
59+
deltaX: 10,
60+
deltaY: 20,
61+
deviceNaturalSize: { width: 600, height: 600 },
62+
effectiveZoom: 1.35,
63+
fitScale: 0.5,
64+
pan: { x: 0, y: 0 },
65+
reservedBottomInset: 96,
66+
rotationQuarterTurns: 0,
67+
viewMode: "manual",
68+
zoom: 1.35,
69+
}),
70+
).toEqual({
71+
pan: { x: -10, y: -20 },
72+
viewMode: "manual",
73+
zoom: 1.35,
74+
});
75+
});
76+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { ChromeProfile } from "../../api/types";
2+
import type { Point, Size, ViewMode } from "./types";
3+
import { clampPan } from "./viewportMath";
4+
5+
const PAN_EPSILON = 0.001;
6+
7+
interface ViewportWheelState {
8+
viewMode: ViewMode;
9+
zoom: number | null;
10+
}
11+
12+
interface ViewportWheelPanOptions {
13+
canvasSize: Size | null;
14+
chromeProfile: ChromeProfile | null;
15+
deltaX: number;
16+
deltaY: number;
17+
deviceNaturalSize: Size | null;
18+
effectiveZoom: number;
19+
fitScale: number;
20+
pan: Point;
21+
reservedBottomInset: number;
22+
rotationQuarterTurns: number;
23+
viewMode: ViewMode;
24+
zoom: number | null;
25+
}
26+
27+
export function nextViewportWheelPanState({
28+
canvasSize,
29+
chromeProfile,
30+
deltaX,
31+
deltaY,
32+
deviceNaturalSize,
33+
effectiveZoom,
34+
fitScale,
35+
pan,
36+
reservedBottomInset,
37+
rotationQuarterTurns,
38+
viewMode,
39+
zoom,
40+
}: ViewportWheelPanOptions): ViewportWheelState & { pan: Point } {
41+
if (effectiveZoom <= fitScale + PAN_EPSILON) {
42+
return { pan, viewMode, zoom };
43+
}
44+
45+
const nextPan = clampPan(
46+
{
47+
x: pan.x - deltaX,
48+
y: pan.y - deltaY,
49+
},
50+
effectiveZoom,
51+
canvasSize,
52+
deviceNaturalSize,
53+
chromeProfile,
54+
rotationQuarterTurns,
55+
viewMode === "manual" ? reservedBottomInset : 0,
56+
);
57+
58+
return {
59+
pan: nextPan,
60+
viewMode,
61+
zoom,
62+
};
63+
}

0 commit comments

Comments
 (0)