Skip to content

Commit 81e7c67

Browse files
committed
fix(core): refine initialization, reset, and scale safety
Synchronously apply centerOnInit position in handleInitialize before the first applyTransformation call. resetTransformations now computes the centered position when centerOnInit is set, fixing reset-to-center round-trips. setTransformState clamps scale to a 1e-7 floor to prevent degenerate zero-scale states. Closes #363 Closes #369 Closes #259 Closes #305 Closes #250 Closes #478 Closes #498 Closes #404 Closes #406 Closes #168 Closes #323 Closes #463 Closes #495 Closes #431 Closes #241 Closes #392 Closes #483 Closes #524 Closes #286 Closes #479 Closes #427 Closes #364 Closes #432 Closes #408 Closes #547 Closes #487 Closes #513 Closes #423 Made-with: Cursor
1 parent 20e481f commit 81e7c67

13 files changed

Lines changed: 333 additions & 46 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { fireEvent, waitFor } from "@testing-library/react";
2+
3+
import { renderApp } from "../../utils/render-app";
4+
5+
describe("Controls [Reset + centerOnInit]", () => {
6+
describe("When resetTransform is called with centerOnInit enabled", () => {
7+
it("should restore the centered position at initial scale", async () => {
8+
const { ref, resetBtn, zoom } = renderApp({
9+
wrapperWidth: "200px",
10+
wrapperHeight: "200px",
11+
contentWidth: "100px",
12+
contentHeight: "100px",
13+
centerOnInit: true,
14+
});
15+
16+
await waitFor(() => {
17+
expect(ref.current?.instance.state.positionX).toBe(50);
18+
expect(ref.current?.instance.state.positionY).toBe(50);
19+
});
20+
21+
zoom({ value: 2 });
22+
expect(ref.current?.instance.state.scale).toBe(2);
23+
24+
fireEvent(resetBtn, new MouseEvent("click", { bubbles: true }));
25+
26+
await waitFor(() => {
27+
expect(ref.current?.instance.state.scale).toBe(1);
28+
expect(ref.current?.instance.state.positionX).toBe(50);
29+
expect(ref.current?.instance.state.positionY).toBe(50);
30+
});
31+
});
32+
33+
it("should restore centered position after panning", async () => {
34+
const { ref, resetBtn, pan } = renderApp({
35+
wrapperWidth: "200px",
36+
wrapperHeight: "200px",
37+
contentWidth: "400px",
38+
contentHeight: "400px",
39+
centerOnInit: true,
40+
initialScale: 0.5,
41+
minScale: 0.2,
42+
});
43+
44+
await waitFor(() => {
45+
const posX = ref.current?.instance.state.positionX ?? 0;
46+
const posY = ref.current?.instance.state.positionY ?? 0;
47+
expect(posX).toBe(0);
48+
expect(posY).toBe(0);
49+
});
50+
51+
pan({ x: -50, y: -50 });
52+
53+
fireEvent(resetBtn, new MouseEvent("click", { bubbles: true }));
54+
55+
await waitFor(() => {
56+
expect(ref.current?.instance.state.scale).toBe(0.5);
57+
expect(ref.current?.instance.state.positionX).toBe(0);
58+
expect(ref.current?.instance.state.positionY).toBe(0);
59+
});
60+
});
61+
62+
it("should NOT center when centerOnInit is false", async () => {
63+
const { ref, resetBtn, zoom } = renderApp({
64+
wrapperWidth: "200px",
65+
wrapperHeight: "200px",
66+
contentWidth: "100px",
67+
contentHeight: "100px",
68+
centerOnInit: false,
69+
});
70+
71+
expect(ref.current?.instance.state.positionX).toBe(0);
72+
expect(ref.current?.instance.state.positionY).toBe(0);
73+
74+
zoom({ value: 2 });
75+
76+
fireEvent(resetBtn, new MouseEvent("click", { bubbles: true }));
77+
78+
await waitFor(() => {
79+
expect(ref.current?.instance.state.scale).toBe(1);
80+
expect(ref.current?.instance.state.positionX).toBe(0);
81+
expect(ref.current?.instance.state.positionY).toBe(0);
82+
});
83+
});
84+
});
85+
});

__tests__/features/positions/positions.centering.spec.tsx

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

33
import { renderApp } from "../../utils";
44

55
describe("Positions [Centering]", () => {
6-
describe("When rendering initially zoomed out content", () => {
7-
it("should center the content with centerOnInit", async () => {
6+
describe("When content is smaller than wrapper", () => {
7+
it("should center with centerOnInit — positive offsets", async () => {
88
const { ref } = renderApp({
99
wrapperWidth: "200px",
1010
wrapperHeight: "200px",
@@ -13,12 +13,14 @@ describe("Positions [Centering]", () => {
1313
centerOnInit: true,
1414
});
1515

16+
// (200 - 100) / 2 = 50
1617
await waitFor(() => {
1718
expect(ref.current?.instance.state.positionX).toBe(50);
1819
expect(ref.current?.instance.state.positionY).toBe(50);
1920
});
2021
});
21-
it("should not center the content with centerOnInit disabled", async () => {
22+
23+
it("should not center when centerOnInit is disabled", () => {
2224
const { ref } = renderApp({
2325
wrapperWidth: "200px",
2426
wrapperHeight: "200px",
@@ -30,9 +32,64 @@ describe("Positions [Centering]", () => {
3032
expect(ref.current?.instance.state.positionX).toBe(0);
3133
expect(ref.current?.instance.state.positionY).toBe(0);
3234
});
35+
36+
it("should center via centerView button", async () => {
37+
const { ref, centerBtn } = renderApp({
38+
wrapperWidth: "200px",
39+
wrapperHeight: "200px",
40+
contentWidth: "100px",
41+
contentHeight: "100px",
42+
});
43+
44+
fireEvent(centerBtn, new MouseEvent("click", { bubbles: true }));
45+
46+
// (200 - 100) / 2 = 50
47+
await waitFor(() => {
48+
expect(ref.current?.instance.state.positionX).toBe(50);
49+
expect(ref.current?.instance.state.positionY).toBe(50);
50+
expect(ref.current?.instance.state.scale).toBe(1);
51+
});
52+
});
53+
54+
it("should center via centerView button after zooming", async () => {
55+
const { ref, centerBtn, zoom } = renderApp({
56+
wrapperWidth: "200px",
57+
wrapperHeight: "200px",
58+
contentWidth: "100px",
59+
contentHeight: "100px",
60+
});
61+
62+
zoom({ value: 2 });
63+
64+
fireEvent(centerBtn, new MouseEvent("click", { bubbles: true }));
65+
66+
// (200 - 100*2) / 2 = 0
67+
await waitFor(() => {
68+
expect(ref.current?.instance.state.positionX).toBe(0);
69+
expect(ref.current?.instance.state.positionY).toBe(0);
70+
expect(ref.current?.instance.state.scale).toBe(2);
71+
});
72+
});
3373
});
34-
describe("When rendering initially zoomed in content", () => {
35-
it("should not center the content with centerOnInit", async () => {
74+
75+
describe("When content is bigger than wrapper", () => {
76+
it("should center with centerOnInit — negative offsets", async () => {
77+
const { ref } = renderApp({
78+
wrapperWidth: "200px",
79+
wrapperHeight: "200px",
80+
contentWidth: "400px",
81+
contentHeight: "400px",
82+
centerOnInit: true,
83+
});
84+
85+
// (200 - 400) / 2 = -100
86+
await waitFor(() => {
87+
expect(ref.current?.instance.state.positionX).toBe(-100);
88+
expect(ref.current?.instance.state.positionY).toBe(-100);
89+
});
90+
});
91+
92+
it("should not center when centerOnInit is disabled", () => {
3693
const { ref } = renderApp({
3794
wrapperWidth: "200px",
3895
wrapperHeight: "200px",
@@ -44,5 +101,80 @@ describe("Positions [Centering]", () => {
44101
expect(ref.current?.instance.state.positionX).toBe(0);
45102
expect(ref.current?.instance.state.positionY).toBe(0);
46103
});
104+
105+
it("should center via centerView button", async () => {
106+
const { ref, centerBtn } = renderApp({
107+
wrapperWidth: "200px",
108+
wrapperHeight: "200px",
109+
contentWidth: "400px",
110+
contentHeight: "400px",
111+
});
112+
113+
fireEvent(centerBtn, new MouseEvent("click", { bubbles: true }));
114+
115+
// (200 - 400) / 2 = -100
116+
await waitFor(() => {
117+
expect(ref.current?.instance.state.positionX).toBe(-100);
118+
expect(ref.current?.instance.state.positionY).toBe(-100);
119+
expect(ref.current?.instance.state.scale).toBe(1);
120+
});
121+
});
122+
123+
it("should center with centerOnInit at reduced scale", async () => {
124+
const { ref } = renderApp({
125+
wrapperWidth: "200px",
126+
wrapperHeight: "200px",
127+
contentWidth: "400px",
128+
contentHeight: "400px",
129+
centerOnInit: true,
130+
initialScale: 0.5,
131+
minScale: 0.1,
132+
});
133+
134+
// (200 - 400*0.5) / 2 = 0
135+
await waitFor(() => {
136+
expect(ref.current?.instance.state.positionX).toBe(0);
137+
expect(ref.current?.instance.state.positionY).toBe(0);
138+
expect(ref.current?.instance.state.scale).toBe(0.5);
139+
});
140+
});
141+
});
142+
143+
describe("When content and wrapper have asymmetric sizes", () => {
144+
it("should center with centerOnInit — mixed positive/negative offsets", async () => {
145+
const { ref } = renderApp({
146+
wrapperWidth: "200px",
147+
wrapperHeight: "300px",
148+
contentWidth: "600px",
149+
contentHeight: "100px",
150+
centerOnInit: true,
151+
});
152+
153+
// X: (200 - 600) / 2 = -200
154+
// Y: (300 - 100) / 2 = 100
155+
await waitFor(() => {
156+
expect(ref.current?.instance.state.positionX).toBe(-200);
157+
expect(ref.current?.instance.state.positionY).toBe(100);
158+
});
159+
});
160+
161+
it("should center via centerView button with asymmetric sizes", async () => {
162+
const { ref, centerBtn } = renderApp({
163+
wrapperWidth: "300px",
164+
wrapperHeight: "200px",
165+
contentWidth: "100px",
166+
contentHeight: "400px",
167+
});
168+
169+
fireEvent(centerBtn, new MouseEvent("click", { bubbles: true }));
170+
171+
// X: (300 - 100) / 2 = 100
172+
// Y: (200 - 400) / 2 = -100
173+
await waitFor(() => {
174+
expect(ref.current?.instance.state.positionX).toBe(100);
175+
expect(ref.current?.instance.state.positionY).toBe(-100);
176+
expect(ref.current?.instance.state.scale).toBe(1);
177+
});
178+
});
47179
});
48180
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { renderApp } from "../../utils/render-app";
2+
3+
describe("Scale floor", () => {
4+
describe("Scale should never go below 0", () => {
5+
it("should clamp minScale of 0 to a positive value", () => {
6+
const { ref } = renderApp({ minScale: 0 });
7+
expect(ref.current?.instance.setup.minScale).toBeGreaterThan(0);
8+
});
9+
10+
it("should clamp negative minScale to a positive value", () => {
11+
const { ref } = renderApp({ minScale: -5 });
12+
expect(ref.current?.instance.setup.minScale).toBeGreaterThan(0);
13+
});
14+
15+
it("should clamp initialScale of 0 to minScale", () => {
16+
const { ref } = renderApp({ initialScale: 0, minScale: 0.1 });
17+
expect(ref.current?.instance.state.scale).toBeGreaterThanOrEqual(0.1);
18+
});
19+
20+
it("should clamp negative initialScale to minScale", () => {
21+
const { ref } = renderApp({ initialScale: -1, minScale: 0.5 });
22+
expect(ref.current?.instance.state.scale).toBeGreaterThanOrEqual(0.5);
23+
});
24+
25+
it("should prevent zooming out past 0 via programmatic setTransform", async () => {
26+
const { ref } = renderApp({ minScale: 0.01 });
27+
ref.current?.setTransform(0, 0, -5, 0);
28+
expect(ref.current?.instance.state.scale).toBeGreaterThan(0);
29+
});
30+
31+
it("should handle minScale=0 and initialScale=0 without crashing", () => {
32+
const { ref } = renderApp({ minScale: 0, initialScale: 0 });
33+
expect(ref.current?.instance.state.scale).toBeGreaterThan(0);
34+
expect(ref.current?.instance.setup.minScale).toBeGreaterThan(0);
35+
});
36+
});
37+
});

src/core/animations/animations.utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ export function handleSetupAnimation(
4444

4545
const frameTime = new Date().getTime() - startTime;
4646
const animationProgress = frameTime / animationTime;
47-
const animationType = animations[animationName];
47+
const animationType =
48+
animations[animationName as keyof typeof animations];
4849

4950
const step = animationType(animationProgress);
5051

src/core/bounds/bounds.utils.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,13 @@ export const calculateBounds = (
115115
// Explicit position props define content-space boundaries at scale=1.
116116
// Scale them so the same content region stays reachable at every zoom level.
117117
if (propMinX != null) {
118-
bounds.minPositionX =
119-
wrapperWidth * (1 - newScale) + propMinX * newScale;
118+
bounds.minPositionX = wrapperWidth * (1 - newScale) + propMinX * newScale;
120119
}
121120
if (propMaxX != null) {
122121
bounds.maxPositionX = propMaxX * newScale;
123122
}
124123
if (propMinY != null) {
125-
bounds.minPositionY =
126-
wrapperHeight * (1 - newScale) + propMinY * newScale;
124+
bounds.minPositionY = wrapperHeight * (1 - newScale) + propMinY * newScale;
127125
}
128126
if (propMaxY != null) {
129127
bounds.maxPositionY = propMaxY * newScale;

src/core/handlers/handlers.utils.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { handleZoomToPoint } from "../zoom/zoom.logic";
44
import { animate } from "../animations/animations.utils";
55
import { createState } from "../../utils/state.utils";
66
import { checkZoomBounds } from "../zoom/zoom.utils";
7-
import { getContext, handleCallback, roundNumber } from "../../utils";
7+
import {
8+
getContext,
9+
getCenterPosition,
10+
handleCallback,
11+
roundNumber,
12+
} from "../../utils";
813
import {
914
calculateBounds,
1015
getMouseBoundedPosition,
@@ -96,21 +101,34 @@ export function resetTransformations(
96101
animationType: keyof typeof animations,
97102
onResetTransformation?: () => void,
98103
): void {
99-
const { setup, wrapperComponent } = contextInstance;
100-
const { limitToBounds } = setup;
104+
const { setup, wrapperComponent, contentComponent } = contextInstance;
105+
const { limitToBounds, centerOnInit } = setup;
101106
const initialTransformation = createState(contextInstance.props);
102107
const { scale, positionX, positionY } = contextInstance.state;
103108

104109
if (!wrapperComponent) return;
105110

111+
let targetPositionX = initialTransformation.positionX;
112+
let targetPositionY = initialTransformation.positionY;
113+
114+
if (centerOnInit && contentComponent) {
115+
const centered = getCenterPosition(
116+
initialTransformation.scale,
117+
wrapperComponent,
118+
contentComponent,
119+
);
120+
targetPositionX = centered.positionX;
121+
targetPositionY = centered.positionY;
122+
}
123+
106124
const newBounds = calculateBounds(
107125
contextInstance,
108126
initialTransformation.scale,
109127
);
110128

111129
const boundedPositions = getMouseBoundedPosition(
112-
initialTransformation.positionX,
113-
initialTransformation.positionY,
130+
targetPositionX,
131+
targetPositionY,
114132
newBounds,
115133
limitToBounds,
116134
0,

0 commit comments

Comments
 (0)