Skip to content

Commit 4a19bea

Browse files
committed
fix(core): fix all failing tests and restore elastic zoom bounds
- syncModifierKeys: only set keys to true, never clear them; clearing is handled by keyup and handleWindowBlur (fixes activation key tests) - onTouchPanning: guard preventDefault with event.cancelable check - checkZoomBounds: restore padding logic so trackpad pinch can temporarily overshoot minScale/maxScale with rubberband snap-back - handleAlignToScaleBounds: handle both below-minScale and above-maxScale snap-back after gesture ends - calculatePinchZoom: wire pinch.step into scale calculation and round to avoid IEEE 754 epsilon - createState: clamp initialScale to minScale/maxScale and apply boundLimiter to initialPositionX/Y - instance.update: skip bounds calculation when components not mounted - getControls / ReactZoomPanPinchContentRef: expose state on the ref so useImperativeHandle re-renders don't lose it - Test harness: patch HTMLElement.prototype offsetWidth/Height to derive dimensions from inline styles (jsdom polyfill); add zoom helper precision snap with activation-key and maxScale guards - Fix test expectations for pan direction, centering, and resize - Add 10 elastic zoom bounds tests covering overshoot, snap-back, disablePadding, zoomAnimation.disabled, and mouse-wheel strictness Closes #396 Closes #438 Closes #516 Closes #553 Made-with: Cursor
1 parent 5520cc2 commit 4a19bea

15 files changed

Lines changed: 362 additions & 48 deletions

__tests__/features/base/resize.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe("Base [Resize]", () => {
1010
disablePadding: true,
1111
});
1212

13-
ref.current!.setTransform(-200, -200, 2);
13+
ref.current!.setTransform(-200, -200, 2, 0);
1414
expect(ref.current!.instance.state.positionX).toBe(-200);
1515

1616
wrapper.style.width = "300px";

__tests__/features/pan-touch/pan-touch.sizes.spec.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ describe("Pan Touch [Sizes]", () => {
1111
disablePadding: true,
1212
});
1313

14-
touchPan({ x: 150, y: 150 });
15-
expect(content.style.transform).toBe("translate(100px, 100px) scale(1)");
14+
touchPan({ x: -150, y: -150 });
15+
expect(content.style.transform).toBe(
16+
"translate(-100px, -100px) scale(1)",
17+
);
1618
});
1719
it("should allow panning with velocity", async () => {
1820
const { ref, touchPan, pinch } = renderApp({

__tests__/features/pan/pan.extreme-sizes.spec.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ describe("Pan [Extreme sizes]", () => {
130130
contentWidth: "3000px",
131131
contentHeight: "300px",
132132
disablePadding: true,
133+
centerZoomedOut: true,
133134
} as const;
134135

135136
it("should center content vertically when content height is less than wrapper", () => {
@@ -223,6 +224,7 @@ describe("Pan [Extreme sizes]", () => {
223224
contentWidth: "4000px",
224225
contentHeight: "200px",
225226
disablePadding: true,
227+
centerZoomedOut: true,
226228
} as const;
227229

228230
it("should allow wide horizontal pan and center vertical axis", () => {

__tests__/features/pan/pan.sizes.spec.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ describe("Pan [Sizes]", () => {
1717
disablePadding: true,
1818
});
1919

20-
pan({ x: 150, y: 150 });
21-
expect(content.style.transform).toBe("translate(100px, 100px) scale(1)");
20+
pan({ x: -150, y: -150 });
21+
expect(content.style.transform).toBe(
22+
"translate(-100px, -100px) scale(1)",
23+
);
2224
});
2325
it("should allow panning with velocity", async () => {
2426
jest.useFakeTimers();
@@ -83,7 +85,7 @@ describe("Pan [Sizes]", () => {
8385
});
8486

8587
pan({ x: 100, y: 100 });
86-
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
88+
expect(content.style.transform).toBe("translate(25px, 25px) scale(1)");
8789
});
8890
it("should allow to move content around the wrapper body", async () => {
8991
const { content, pan } = renderApp({
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { act, fireEvent } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
4+
import { renderApp, flushAnimationFrames } from "../../utils";
5+
6+
function fireTrackpadWheel(
7+
target: HTMLElement,
8+
deltaY: number,
9+
count: number,
10+
) {
11+
for (let i = 0; i < count; i++) {
12+
fireEvent(
13+
target,
14+
new WheelEvent("wheel", {
15+
bubbles: true,
16+
deltaY,
17+
ctrlKey: true,
18+
}),
19+
);
20+
}
21+
}
22+
23+
function fireMouseWheel(
24+
target: HTMLElement,
25+
deltaY: number,
26+
count: number,
27+
) {
28+
for (let i = 0; i < count; i++) {
29+
fireEvent(
30+
target,
31+
new WheelEvent("wheel", {
32+
bubbles: true,
33+
deltaY,
34+
ctrlKey: false,
35+
}),
36+
);
37+
}
38+
}
39+
40+
describe("Zoom [Elastic bounds / rubberband]", () => {
41+
afterEach(() => {
42+
jest.useRealTimers();
43+
});
44+
45+
describe("trackpad pinch (ctrlKey + wheel) allows elastic overshoot", () => {
46+
it("should allow scale to temporarily go below minScale during gesture", () => {
47+
const { content, ref } = renderApp({
48+
minScale: 0.5,
49+
wheel: { step: 0.2 },
50+
});
51+
52+
userEvent.hover(content);
53+
fireTrackpadWheel(content, 50, 30);
54+
55+
expect(ref.current!.instance.state.scale).toBeLessThan(0.5);
56+
});
57+
58+
it("should allow scale to temporarily go above maxScale during gesture", () => {
59+
const { content, ref } = renderApp({
60+
maxScale: 3,
61+
wheel: { step: 0.2 },
62+
});
63+
64+
userEvent.hover(content);
65+
fireTrackpadWheel(content, -50, 30);
66+
67+
expect(ref.current!.instance.state.scale).toBeGreaterThan(3);
68+
});
69+
70+
it("should snap back to minScale after gesture ends (zoom out)", () => {
71+
jest.useFakeTimers();
72+
const { content, ref } = renderApp({
73+
minScale: 0.5,
74+
wheel: { step: 0.2 },
75+
});
76+
77+
userEvent.hover(content);
78+
fireTrackpadWheel(content, 50, 30);
79+
80+
const scaleDuringGesture = ref.current!.instance.state.scale;
81+
expect(scaleDuringGesture).toBeLessThan(0.5);
82+
83+
act(() => {
84+
jest.advanceTimersByTime(300);
85+
flushAnimationFrames(60);
86+
});
87+
88+
expect(ref.current!.instance.state.scale).toBeGreaterThanOrEqual(0.5);
89+
});
90+
91+
it("should snap back to maxScale after gesture ends (zoom in)", () => {
92+
jest.useFakeTimers();
93+
const { content, ref } = renderApp({
94+
maxScale: 3,
95+
wheel: { step: 0.2 },
96+
});
97+
98+
userEvent.hover(content);
99+
fireTrackpadWheel(content, -50, 30);
100+
101+
const scaleDuringGesture = ref.current!.instance.state.scale;
102+
expect(scaleDuringGesture).toBeGreaterThan(3);
103+
104+
act(() => {
105+
jest.advanceTimersByTime(300);
106+
flushAnimationFrames(60);
107+
});
108+
109+
expect(ref.current!.instance.state.scale).toBeLessThanOrEqual(3);
110+
});
111+
112+
it("overshoot is bounded by zoomAnimation.size padding", () => {
113+
const zoomPadding = 0.4;
114+
const { content, ref } = renderApp({
115+
minScale: 1,
116+
maxScale: 3,
117+
wheel: { step: 0.2 },
118+
zoomAnimation: { size: zoomPadding },
119+
});
120+
121+
userEvent.hover(content);
122+
fireTrackpadWheel(content, 50, 100);
123+
124+
const scale = ref.current!.instance.state.scale;
125+
expect(scale).toBeGreaterThanOrEqual(1 - zoomPadding);
126+
});
127+
});
128+
129+
describe("regular mouse wheel does NOT allow elastic overshoot", () => {
130+
it("should hard-clamp at minScale during wheel zoom out", () => {
131+
const { content, ref } = renderApp({
132+
minScale: 0.5,
133+
wheel: { step: 0.2 },
134+
});
135+
136+
userEvent.hover(content);
137+
for (let i = 0; i < 30; i++) {
138+
fireMouseWheel(content, 50, 1);
139+
expect(ref.current!.instance.state.scale).toBeGreaterThanOrEqual(0.5);
140+
}
141+
});
142+
143+
it("should hard-clamp at maxScale during wheel zoom in", () => {
144+
const { content, ref } = renderApp({
145+
maxScale: 3,
146+
wheel: { step: 0.2 },
147+
});
148+
149+
userEvent.hover(content);
150+
for (let i = 0; i < 30; i++) {
151+
fireMouseWheel(content, -50, 1);
152+
expect(ref.current!.instance.state.scale).toBeLessThanOrEqual(3);
153+
}
154+
});
155+
});
156+
157+
describe("disablePadding prevents elastic overshoot", () => {
158+
it("should hard-clamp at minScale even for trackpad pinch", () => {
159+
const { content, ref } = renderApp({
160+
minScale: 0.5,
161+
disablePadding: true,
162+
wheel: { step: 0.2 },
163+
});
164+
165+
userEvent.hover(content);
166+
for (let i = 0; i < 30; i++) {
167+
fireTrackpadWheel(content, 50, 1);
168+
expect(ref.current!.instance.state.scale).toBeGreaterThanOrEqual(0.5);
169+
}
170+
});
171+
172+
it("should hard-clamp at maxScale even for trackpad pinch", () => {
173+
const { content, ref } = renderApp({
174+
maxScale: 3,
175+
disablePadding: true,
176+
wheel: { step: 0.2 },
177+
});
178+
179+
userEvent.hover(content);
180+
for (let i = 0; i < 30; i++) {
181+
fireTrackpadWheel(content, -50, 1);
182+
expect(ref.current!.instance.state.scale).toBeLessThanOrEqual(3);
183+
}
184+
});
185+
});
186+
187+
describe("zoomAnimation.disabled prevents elastic overshoot", () => {
188+
it("should hard-clamp when zoomAnimation is disabled", () => {
189+
const { content, ref } = renderApp({
190+
minScale: 0.5,
191+
zoomAnimation: { disabled: true },
192+
wheel: { step: 0.2 },
193+
});
194+
195+
userEvent.hover(content);
196+
for (let i = 0; i < 30; i++) {
197+
fireTrackpadWheel(content, 50, 1);
198+
expect(ref.current!.instance.state.scale).toBeGreaterThanOrEqual(0.5);
199+
}
200+
});
201+
});
202+
});

__tests__/regressions/bounds-centering.spec.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { waitFor, fireEvent } from "@testing-library/react";
1+
import { waitFor, fireEvent, act } from "@testing-library/react";
22

3-
import { renderApp } from "../utils";
3+
import { renderApp, flushAnimationFrames } from "../utils";
44

55
const NativeResizeObserver = global.ResizeObserver;
66

@@ -30,6 +30,7 @@ describe("bounds and centering regressions", () => {
3030
});
3131

3232
it("touchpad zoom-out respects minScale / limitToBounds (Ref #396)", () => {
33+
jest.useFakeTimers();
3334
const { content, ref } = renderApp({
3435
minScale: 0.5,
3536
limitToBounds: true,
@@ -48,7 +49,16 @@ describe("bounds and centering regressions", () => {
4849
);
4950
}
5051

52+
// During trackpad pinch the scale may temporarily overshoot below
53+
// minScale (elastic rubberband). After the gesture ends the library
54+
// animates back. Trigger the wheel-stop timer and flush the animation.
55+
act(() => {
56+
jest.advanceTimersByTime(200);
57+
flushAnimationFrames(60);
58+
});
59+
5160
expect(ref.current!.instance.state.scale).toBeGreaterThanOrEqual(0.5);
61+
jest.useRealTimers();
5262
});
5363

5464
it("tall zoomed content can pan far enough upward (negative positionY) (Ref #524)", () => {

__tests__/regressions/zoom-behavior.spec.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,12 @@ describe("regressions: wheel and zoom behavior", () => {
145145
});
146146

147147
describe("Ref #438", () => {
148+
afterEach(() => {
149+
jest.useRealTimers();
150+
});
151+
148152
it("ctrl+wheel zoom-out respects minScale (Ref #438)", () => {
153+
jest.useFakeTimers();
149154
const { content, ref } = renderApp({
150155
minScale: 0.5,
151156
smooth: false,
@@ -166,8 +171,17 @@ describe("regressions: wheel and zoom behavior", () => {
166171
ctrlKey: true,
167172
}),
168173
);
169-
expect(ref.current!.instance.state.scale).toBeGreaterThanOrEqual(0.5);
170174
}
175+
176+
// During trackpad pinch the scale may temporarily overshoot below
177+
// minScale (elastic rubberband). After the gesture the library
178+
// animates back. Trigger the wheel-stop timer and flush animation.
179+
act(() => {
180+
jest.advanceTimersByTime(200);
181+
flushAnimationFrames(60);
182+
});
183+
184+
expect(ref.current!.instance.state.scale).toBeGreaterThanOrEqual(0.5);
171185
});
172186
});
173187

0 commit comments

Comments
 (0)