Skip to content

Commit ad1f433

Browse files
committed
fix(zoom): remove setState rounding that caused trackpad pinch-to-zoom drift
setState was rounding scale to 2 decimal places and position to 3 decimal places via parseFloat(toFixed(N)). During rapid trackpad pinch-to-zoom (many small sequential wheel events), this quantization compounded frame-over-frame, causing the zoom center to visibly tremble and drift. Also removes stale hasExplicitPositionBounds reference from wheel.logic (already deleted from bounds.utils). Made-with: Cursor
1 parent 41f2f54 commit ad1f433

15 files changed

Lines changed: 180 additions & 121 deletions

File tree

.cursor/rules/testing.mdc

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
description: Testing conventions — always use yarn test
3+
alwaysApply: true
4+
---
5+
6+
# Testing
7+
8+
Always use `yarn test` to run the test suite. Never use `npx jest` or `npm test`.
9+
10+
```bash
11+
# ✅ GOOD
12+
yarn test
13+
14+
# ❌ BAD
15+
npx jest
16+
npm test
17+
npx jest --testPathPattern="..."
18+
```
19+
20+
When running a subset of tests, pass args through yarn:
21+
22+
```bash
23+
yarn test --testPathPattern="some-pattern"
24+
```

__tests__/regressions/bounds-centering.spec.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,34 @@ describe("bounds and centering regressions", () => {
115115
expect(ref.current!.instance.state.positionX).toBe(100);
116116
});
117117
});
118+
119+
it("centerZoomedOut locks content to center after panning when zoomed out", async () => {
120+
const { pan, ref } = renderApp({
121+
wrapperWidth: "500px",
122+
wrapperHeight: "500px",
123+
contentWidth: "300px",
124+
contentHeight: "300px",
125+
centerOnInit: true,
126+
centerZoomedOut: true,
127+
limitToBounds: true,
128+
disablePadding: true,
129+
});
130+
131+
await waitFor(() => {
132+
expect(ref.current!.instance.state.positionX).toBe(100);
133+
expect(ref.current!.instance.state.positionY).toBe(100);
134+
});
135+
136+
pan({ x: 200, y: 0 });
137+
expect(ref.current!.instance.state.positionX).toBe(100);
138+
expect(ref.current!.instance.state.positionY).toBe(100);
139+
140+
pan({ x: -200, y: 0, from: { clientX: 250, clientY: 250 } });
141+
expect(ref.current!.instance.state.positionX).toBe(100);
142+
expect(ref.current!.instance.state.positionY).toBe(100);
143+
144+
pan({ x: 150, y: -150, from: { clientX: 250, clientY: 250 } });
145+
expect(ref.current!.instance.state.positionX).toBe(100);
146+
expect(ref.current!.instance.state.positionY).toBe(100);
147+
});
118148
});

__tests__/regressions/zoom-behavior.spec.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,25 @@ describe("regressions: wheel and zoom behavior", () => {
204204
});
205205
});
206206

207+
describe("setState precision (trackpad zoom drift regression)", () => {
208+
it("setState preserves full floating-point precision for scale and position", () => {
209+
const { ref } = renderApp();
210+
211+
const preciseScale = 1.23456789;
212+
const preciseX = 42.123456789;
213+
const preciseY = -17.987654321;
214+
215+
ref.current!.instance.setState(preciseScale, preciseX, preciseY);
216+
217+
// Values must be stored at full precision. Rounding (e.g. toFixed(2)
218+
// on scale or toFixed(3) on position) causes compounding drift
219+
// during rapid trackpad pinch-to-zoom.
220+
expect(ref.current!.instance.state.scale).toBe(preciseScale);
221+
expect(ref.current!.instance.state.positionX).toBe(preciseX);
222+
expect(ref.current!.instance.state.positionY).toBe(preciseY);
223+
});
224+
});
225+
207226
describe("Ref #495", () => {
208227
it("wheel.step changes zoom sensitivity (Ref #495)", () => {
209228
const run = (step: number) => {

src/core/bounds/bounds.utils.ts

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { roundNumber } from "../../utils";
33
import {
44
BoundsType,
5-
LibrarySetup,
65
PositionType,
76
ReactZoomPanPinchContext,
87
} from "../../models";
@@ -45,11 +44,11 @@ export const getBounds = (
4544
): BoundsType => {
4645
const scaleWidthFactor =
4746
wrapperWidth > newContentWidth
48-
? diffWidth * (centerZoomedOut ? 1 : 0.5)
47+
? diffWidth * (centerZoomedOut ? 0.5 : 1)
4948
: 0;
5049
const scaleHeightFactor =
5150
wrapperHeight > newContentHeight
52-
? diffHeight * (centerZoomedOut ? 1 : 0.5)
51+
? diffHeight * (centerZoomedOut ? 0.5 : 1)
5352
: 0;
5453

5554
const minPositionX = wrapperWidth - newContentWidth - scaleWidthFactor;
@@ -82,8 +81,8 @@ export const calculateBounds = (
8281
wrapperWidth,
8382
wrapperHeight,
8483
newContentWidth,
85-
newDiffWidth,
8684
newContentHeight,
85+
newDiffWidth,
8786
newDiffHeight,
8887
} = getComponentsSizes(wrapperComponent, contentComponent, newScale);
8988

@@ -99,7 +98,7 @@ export const calculateBounds = (
9998

10099
const contentFitsCompletely =
101100
wrapperWidth >= newContentWidth && wrapperHeight >= newContentHeight;
102-
if (disablePadding && contentFitsCompletely) {
101+
if (disablePadding && contentFitsCompletely && !centerZoomedOut) {
103102
bounds.minPositionX = 0;
104103
bounds.maxPositionX = 0;
105104
bounds.minPositionY = 0;
@@ -121,20 +120,6 @@ export const calculateBounds = (
121120
return bounds;
122121
};
123122

124-
export function hasExplicitPositionBounds(
125-
setup: Pick<
126-
LibrarySetup,
127-
"minPositionX" | "maxPositionX" | "minPositionY" | "maxPositionY"
128-
>,
129-
): boolean {
130-
return (
131-
setup.minPositionX != null ||
132-
setup.maxPositionX != null ||
133-
setup.minPositionY != null ||
134-
setup.maxPositionY != null
135-
);
136-
}
137-
138123
export function clamp(v: number, min: number, max: number) {
139124
return Math.max(min, Math.min(v, max));
140125
}

src/core/handlers/handlers.utils.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,12 @@ export function handleZoomToViewCenter(
4747
): void {
4848
const { wrapperComponent } = contextInstance;
4949
const { scale, positionX, positionY } = contextInstance.state;
50+
const { zoomAnimation } = contextInstance.setup;
5051

5152
if (!wrapperComponent) return console.error("No WrapperComponent found");
5253

54+
const effectiveAnimationTime = zoomAnimation.disabled ? 0 : animationTime;
55+
5356
const wrapperWidth = wrapperComponent.offsetWidth;
5457
const wrapperHeight = wrapperComponent.offsetHeight;
5558
const mouseX = (wrapperWidth / 2 - positionX) / scale;
@@ -75,15 +78,15 @@ export function handleZoomToViewCenter(
7578
const ctx = getContext(contextInstance);
7679
handleCallback(ctx, event, onZoomStart);
7780
handleCallback(ctx, event, onZoom);
78-
animate(contextInstance, targetState, animationTime, animationType);
81+
animate(contextInstance, targetState, effectiveAnimationTime, animationType);
7982
const win =
8083
wrapperComponent.ownerDocument?.defaultView ??
8184
(typeof window !== "undefined" ? window : null);
8285
if (win) {
8386
win.setTimeout(() => {
8487
if (!contextInstance.mounted) return;
8588
handleCallback(getContext(contextInstance), event, onZoomStop);
86-
}, animationTime);
89+
}, effectiveAnimationTime);
8790
}
8891
}
8992

src/core/instance.core.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -562,11 +562,11 @@ export class ZoomPanPinch {
562562
) {
563563
if (scale !== this.state.scale) {
564564
this.state.previousScale = this.state.scale;
565-
this.state.scale = parseFloat(scale.toFixed(2));
565+
this.state.scale = scale;
566566
}
567567

568-
this.state.positionX = parseFloat(positionX.toFixed(3));
569-
this.state.positionY = parseFloat(positionY.toFixed(3));
568+
this.state.positionX = positionX;
569+
this.state.positionY = positionY;
570570

571571
this.applyTransformation();
572572
const ctx = getContext(this);

src/core/pan/panning.utils.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ export const isPanningStartAllowed = (
1919
const targetIsShadowDom = "shadowRoot" in target && "composedPath" in event;
2020
const isWrapperChild = targetIsShadowDom
2121
? event.composedPath().some((el) => {
22-
if (!(el instanceof Element)) {
23-
return false;
24-
}
22+
if (!(el instanceof Element)) {
23+
return false;
24+
}
2525

26-
return wrapperComponent?.contains(el);
27-
})
26+
return wrapperComponent?.contains(el);
27+
})
2828
: wrapperComponent?.contains(target);
2929

3030
const isAllowed = isInitialized && target && isWrapperChild;
@@ -35,6 +35,14 @@ export const isPanningStartAllowed = (
3535

3636
if (isExcluded) return false;
3737

38+
if (
39+
target.getAttribute("draggable") === "true" ||
40+
target.getAttribute("contenteditable") === "true" ||
41+
target.isContentEditable
42+
) {
43+
return false;
44+
}
45+
3846
return true;
3947
};
4048

@@ -196,11 +204,11 @@ export const getPaddingValue = (
196204
explicitScale?: number,
197205
): number => {
198206
const { setup, state } = contextInstance;
199-
const { minScale, disablePadding } = setup;
207+
const { minScale, disablePadding, centerZoomedOut } = setup;
200208

201209
const scale = explicitScale ?? state.scale;
202210

203-
if (size > 0 && scale >= minScale && !disablePadding) {
211+
if (size > 0 && scale >= minScale && !disablePadding && !centerZoomedOut) {
204212
return size;
205213
}
206214

src/core/pan/velocity.logic.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable no-param-reassign */
22
import { DeviceType, PositionType } from "../../models";
33
import { ReactZoomPanPinchContext } from "../../models/context.model";
4+
import { getContext, handleCallback } from "../../utils";
45
import { animations } from "../animations/animations.constants";
56
import { handleSetupAnimation } from "../animations/animations.utils";
67
import { getPaddingValue } from "./panning.utils";
@@ -176,6 +177,10 @@ export function handleVelocityPanning(
176177

177178
if (positionX !== newPositionX || positionY !== newPositionY) {
178179
contextInstance.setState(scale, currentPositionX, currentPositionY);
180+
const { onPanning } = contextInstance.props;
181+
if (onPanning) {
182+
onPanning(getContext(contextInstance), {} as MouseEvent);
183+
}
179184
}
180185
},
181186
);

src/core/pinch/pinch.logic.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import {
1111
getMouseBoundedPosition,
1212
handleCalculateBounds,
13-
hasExplicitPositionBounds,
1413
} from "../bounds/bounds.utils";
1514
import { handleCalculateZoomPositions } from "../zoom/zoom.utils";
1615
import { getPaddingValue } from "core/pan/panning.utils";
@@ -92,9 +91,7 @@ export const handlePinchZoom = (
9291
const bounds = handleCalculateBounds(contextInstance, newScale);
9392

9493
const isPaddingDisabled = disabled || size === 0 || centerZoomedOut;
95-
const isLimitedToBounds =
96-
(limitToBounds && isPaddingDisabled) ||
97-
hasExplicitPositionBounds(contextInstance.setup);
94+
const isLimitedToBounds = limitToBounds && isPaddingDisabled;
9895
const { x, y } = handleCalculateZoomPositions(
9996
contextInstance,
10097
midPoint.x,

src/core/wheel/wheel.logic.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@ import { handleCallback } from "../../utils/callback.utils";
44
import { getContext } from "../../utils/context.utils";
55
import { cancelTimeout } from "../../utils/helpers.utils";
66
import { handleCancelAnimation } from "../animations/animations.utils";
7-
import {
8-
handleCalculateBounds,
9-
hasExplicitPositionBounds,
10-
} from "../bounds/bounds.utils";
7+
import { handleCalculateBounds } from "../bounds/bounds.utils";
118
import {
129
getDelta,
1310
handleCalculateWheelZoom,
@@ -77,9 +74,7 @@ export const handleWheelZoom = (
7774

7875
const isPaddingDisabled =
7976
disabled || size === 0 || centerZoomedOut || disablePadding;
80-
const isLimitedToBounds =
81-
(limitToBounds && isPaddingDisabled) ||
82-
hasExplicitPositionBounds(setup);
77+
const isLimitedToBounds = limitToBounds && isPaddingDisabled;
8378

8479
const { x, y } = handleCalculateZoomPositions(
8580
contextInstance,

0 commit comments

Comments
 (0)