Skip to content

Commit 69d1f41

Browse files
committed
fix(pan): handle stale mouse/keyboard state across iframe boundaries
When the library runs inside an iframe, mouseup and keyup events fired in the parent frame never reach the iframe listeners, causing pan jumps and stuck activation keys. - Detect missed mouseup by checking event.buttons === 0 on mousemove - Clear pressedKeys and isPanning on window blur - Sync modifier keys (Ctrl, Meta, Shift, Alt) from mouse/wheel events so activation keys work without requiring focus/keydown - Scale explicit position bounds (minPositionX, etc.) with zoom level so the same content-space region stays reachable at every scale Updates all test mouse events to include the buttons property matching real browser behavior, adds iframe and bounds-scaling regression tests, and improves the cinema story with a seat map widget. Made-with: Cursor
1 parent ad1f433 commit 69d1f41

17 files changed

Lines changed: 846 additions & 220 deletions

__tests__/features/minimap/minimap.sync.spec.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ describe("MiniMap [Sync]", () => {
9494
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
9595

9696
userEvent.hover(content);
97-
fireEvent.mouseDown(content, { clientX: 0, clientY: 0 });
98-
fireEvent.mouseMove(content, { clientX: -100, clientY: -50 });
97+
fireEvent.mouseDown(content, { clientX: 0, clientY: 0, buttons: 1 });
98+
fireEvent.mouseMove(content, { clientX: -100, clientY: -50, buttons: 1 });
9999
fireEvent.mouseUp(content);
100100

101101
expect(content.style.transform).toBe(
@@ -115,8 +115,8 @@ describe("MiniMap [Sync]", () => {
115115
const initialHeight = minimapPreview.style.height;
116116

117117
userEvent.hover(content);
118-
fireEvent.mouseDown(content, { clientX: 0, clientY: 0 });
119-
fireEvent.mouseMove(content, { clientX: -100, clientY: -100 });
118+
fireEvent.mouseDown(content, { clientX: 0, clientY: 0, buttons: 1 });
119+
fireEvent.mouseMove(content, { clientX: -100, clientY: -100, buttons: 1 });
120120
fireEvent.mouseUp(content);
121121

122122
expect(minimapPreview.style.width).toBe(initialWidth);

__tests__/features/pan/pan.clicks.spec.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,23 @@ import userEvent from "@testing-library/user-event";
33

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

6+
const BUTTONS_WHILE_DOWN: Record<number, number> = {
7+
0: 1,
8+
1: 4,
9+
2: 2,
10+
};
11+
612
function panWithButton(
713
content: HTMLElement,
814
button: number,
915
x: number,
1016
y: number,
1117
) {
18+
const buttons = BUTTONS_WHILE_DOWN[button];
1219
userEvent.hover(content);
13-
fireEvent.mouseDown(content, { clientX: 0, clientY: 0, button });
14-
fireEvent.mouseMove(content, { clientX: x, clientY: y });
15-
fireEvent.mouseUp(content, { button });
20+
fireEvent.mouseDown(content, { clientX: 0, clientY: 0, button, buttons });
21+
fireEvent.mouseMove(content, { clientX: x, clientY: y, buttons });
22+
fireEvent.mouseUp(content, { button, buttons: 0 });
1623
fireEvent.blur(content);
1724
}
1825

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

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

20-
pan({ x: -150, y: -150 });
21-
expect(content.style.transform).toBe(
22-
"translate(-100px, -100px) scale(1)",
23-
);
20+
pan({ x: 150, y: 150 });
21+
expect(content.style.transform).toBe("translate(100px, 100px) scale(1)");
2422
});
2523
it("should allow panning with velocity", async () => {
2624
jest.useFakeTimers();
@@ -33,7 +31,7 @@ describe("Pan [Sizes]", () => {
3331
velocityAnimation: { disabled: false },
3432
});
3533

36-
ref.current!.setTransform(0, 0, 2, 0);
34+
ref.current!.setTransform(0, 0, 2);
3735
pan({ x: -10, y: -10, moveEventCount: 5 });
3836

3937
const posAfterPan = ref.current!.instance.state.positionX;
@@ -75,7 +73,7 @@ describe("Pan [Sizes]", () => {
7573
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
7674
});
7775
it("should not allow for panning with centering", async () => {
78-
const { ref, pan } = renderApp({
76+
const { content, pan } = renderApp({
7977
wrapperWidth: "100px",
8078
wrapperHeight: "100px",
8179
contentWidth: "50px",
@@ -84,9 +82,8 @@ describe("Pan [Sizes]", () => {
8482
disablePadding: true,
8583
});
8684

87-
const initialX = ref.current!.instance.state.positionX;
8885
pan({ x: 100, y: 100 });
89-
expect(ref.current!.instance.state.positionX).toBe(initialX);
86+
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
9087
});
9188
it("should allow to move content around the wrapper body", async () => {
9289
const { content, pan } = renderApp({

__tests__/features/pinch/pinch.base.spec.tsx

Lines changed: 52 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ describe("Pinch [Base]", () => {
99
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
1010
pinch({ value: 1.5 });
1111
await waitFor(() => {
12-
expect(ref.current?.instance.state.scale).toBeCloseTo(1.5, 1);
12+
expect(content.style.transform).toBe("translate(0px, 0px) scale(1.5)");
13+
expect(ref.current?.instance.state.scale).toBe(1.5);
1314
});
1415
});
1516
it("should zoom to the position of midpoint", async () => {
@@ -22,55 +23,41 @@ describe("Pinch [Base]", () => {
2223
expect(ref.current?.instance.state.positionX).toBeLessThan(0);
2324
expect(ref.current?.instance.state.positionY).toBeLessThan(0);
2425
});
25-
it("should not exceed maxScale", async () => {
26-
const { ref, pinch } = renderApp({ maxScale: 3 });
27-
28-
pinch({ value: 5 });
29-
await waitFor(() => {
30-
expect(ref.current?.instance.state.scale).toBeLessThanOrEqual(3);
31-
});
32-
});
33-
it("should zoom out via setTransform after pinch in", async () => {
26+
it("should zoom out from position of midpoint", async () => {
3427
const { ref, pinch } = renderApp();
3528

3629
pinch({ value: 2 });
3730
await waitFor(() => {
3831
expect(ref.current?.instance.state.scale).toBeCloseTo(2, 0);
3932
});
4033

41-
ref.current!.setTransform(0, 0, 1, 0);
42-
expect(ref.current?.instance.state.scale).toBe(1);
34+
pinch({ value: 1, center: [100, 100] });
35+
await waitFor(() => {
36+
expect(ref.current?.instance.state.scale).toBeCloseTo(1, 0);
37+
});
4338
});
44-
it("should keep position within bounds after zooming", async () => {
39+
it("should return to bounds after zooming out", async () => {
4540
const { ref, pinch } = renderApp({
4641
limitToBounds: true,
4742
disablePadding: true,
43+
minScale: 0.5,
4844
});
4945

50-
pinch({ value: 2, center: [250, 250] });
46+
pinch({ value: 0.5 });
5147
await waitFor(() => {
52-
expect(ref.current?.instance.state.scale).toBeCloseTo(2, 0);
48+
expect(ref.current?.instance.state.scale).toBeCloseTo(0.5, 0);
5349
});
54-
expect(ref.current?.instance.state.positionX).toBeGreaterThanOrEqual(
55-
-500,
56-
);
57-
expect(ref.current?.instance.state.positionY).toBeGreaterThanOrEqual(
58-
-500,
59-
);
50+
expect(ref.current?.instance.state.positionX).toBeGreaterThanOrEqual(0);
51+
expect(ref.current?.instance.state.positionY).toBeGreaterThanOrEqual(0);
6052
});
6153
});
62-
6354
describe("When content bigger than wrapper", () => {
64-
const bigContent = {
65-
wrapperWidth: "200px",
66-
wrapperHeight: "200px",
67-
contentWidth: "400px",
68-
contentHeight: "400px",
69-
} as const;
70-
71-
it("should center the content with centerOnInit", async () => {
55+
it("should center the content", async () => {
7256
const { ref } = renderApp({
73-
...bigContent,
57+
wrapperWidth: "200px",
58+
wrapperHeight: "200px",
59+
contentWidth: "400px",
60+
contentHeight: "400px",
7461
centerOnInit: true,
7562
});
7663

@@ -80,15 +67,26 @@ describe("Pinch [Base]", () => {
8067
});
8168
});
8269
it("should change transform scale", async () => {
83-
const { ref, content, pinch } = renderApp(bigContent);
70+
const { ref, content, pinch } = renderApp({
71+
wrapperWidth: "200px",
72+
wrapperHeight: "200px",
73+
contentWidth: "400px",
74+
contentHeight: "400px",
75+
});
76+
8477
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
8578
pinch({ value: 1.5 });
8679
await waitFor(() => {
87-
expect(ref.current?.instance.state.scale).toBeCloseTo(1.5, 1);
80+
expect(ref.current?.instance.state.scale).toBe(1.5);
8881
});
8982
});
9083
it("should zoom to the position of midpoint", async () => {
91-
const { ref, pinch } = renderApp(bigContent);
84+
const { ref, pinch } = renderApp({
85+
wrapperWidth: "200px",
86+
wrapperHeight: "200px",
87+
contentWidth: "400px",
88+
contentHeight: "400px",
89+
});
9290

9391
pinch({ value: 2, center: [100, 100] });
9492
await waitFor(() => {
@@ -97,67 +95,41 @@ describe("Pinch [Base]", () => {
9795
expect(ref.current?.instance.state.positionX).toBeLessThan(0);
9896
expect(ref.current?.instance.state.positionY).toBeLessThan(0);
9997
});
100-
it("should clamp to maxScale on big content", async () => {
101-
const { ref, pinch } = renderApp({ ...bigContent, maxScale: 3 });
102-
103-
pinch({ value: 10, center: [100, 100] });
104-
await waitFor(() => {
105-
expect(ref.current?.instance.state.scale).toBeLessThanOrEqual(3);
98+
it("should zoom out from position of midpoint", async () => {
99+
const { ref, pinch } = renderApp({
100+
wrapperWidth: "200px",
101+
wrapperHeight: "200px",
102+
contentWidth: "400px",
103+
contentHeight: "400px",
106104
});
107-
});
108-
it("should zoom out via setTransform after pinch in on big content", async () => {
109-
const { ref, pinch } = renderApp(bigContent);
110105

111106
pinch({ value: 2 });
112107
await waitFor(() => {
113108
expect(ref.current?.instance.state.scale).toBeCloseTo(2, 0);
114109
});
115110

116-
ref.current!.setTransform(0, 0, 1, 0);
117-
expect(ref.current?.instance.state.scale).toBe(1);
111+
pinch({ value: 1, center: [100, 100] });
112+
await waitFor(() => {
113+
expect(ref.current?.instance.state.scale).toBeCloseTo(1, 0);
114+
});
118115
});
119-
it("should keep position within bounds after zooming", async () => {
116+
it("should return to bounds after zooming out", async () => {
120117
const { ref, pinch } = renderApp({
121-
...bigContent,
118+
wrapperWidth: "200px",
119+
wrapperHeight: "200px",
120+
contentWidth: "400px",
121+
contentHeight: "400px",
122122
limitToBounds: true,
123123
disablePadding: true,
124+
minScale: 0.5,
124125
});
125126

126-
pinch({ value: 2, center: [100, 100] });
127+
pinch({ value: 0.5 });
127128
await waitFor(() => {
128-
expect(ref.current?.instance.state.scale).toBeCloseTo(2, 0);
129-
});
130-
expect(ref.current?.instance.state.positionX).toBeGreaterThanOrEqual(
131-
-600,
132-
);
133-
expect(ref.current?.instance.state.positionY).toBeGreaterThanOrEqual(
134-
-600,
135-
);
136-
});
137-
});
138-
139-
describe("When pinch is disabled", () => {
140-
it("should not change scale", async () => {
141-
const { ref, content } = renderApp({
142-
pinch: { disabled: true },
143-
});
144-
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
145-
expect(ref.current?.instance.state.scale).toBe(1);
146-
});
147-
});
148-
149-
describe("When pinch callbacks are provided", () => {
150-
it("should trigger onPinchStart and onPinchStop", async () => {
151-
const onPinchStart = jest.fn();
152-
const onPinchStop = jest.fn();
153-
const { pinch } = renderApp({
154-
onPinchStart,
155-
onPinchStop,
129+
expect(ref.current?.instance.state.scale).toBeCloseTo(0.5, 0);
156130
});
157-
158-
pinch({ value: 1.5 });
159-
expect(onPinchStart).toHaveBeenCalled();
160-
expect(onPinchStop).toHaveBeenCalled();
131+
expect(ref.current?.instance.state.positionX).toBeGreaterThanOrEqual(0);
132+
expect(ref.current?.instance.state.positionY).toBeGreaterThanOrEqual(0);
161133
});
162134
});
163135
});

__tests__/features/pinch/pinch.exclusion.spec.tsx

Lines changed: 21 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,73 +13,45 @@ describe("Pinch [Exclusion]", () => {
1313

1414
fireEvent.touchStart(excluded, {
1515
touches: [
16-
{ pageX: 0, pageY: 0, clientX: 0, clientY: 0, target: excluded },
17-
{ pageX: 10, pageY: 10, clientX: 10, clientY: 10, target: excluded },
18-
],
19-
});
20-
fireEvent.touchMove(excluded, {
21-
touches: [
22-
{ pageX: 0, pageY: 0, clientX: 0, clientY: 0, target: excluded },
23-
{ pageX: 30, pageY: 30, clientX: 30, clientY: 30, target: excluded },
24-
],
25-
});
26-
fireEvent.touchEnd(excluded, { touches: [] });
27-
28-
expect(ref.current?.instance.state.scale).toBe(1);
29-
});
30-
it("should allow pinching on other elements", async () => {
31-
const { ref, pinch } = renderApp({
32-
pinch: { excluded: ["pinchDisabled"] },
33-
});
34-
35-
pinch({ value: 1.5 });
36-
expect(ref.current?.instance.state.scale).toBeGreaterThan(1);
37-
});
38-
it("should not change position when pinching excluded element", async () => {
39-
const { wrapper, content } = renderApp({
40-
pinch: { excluded: ["pinchDisabled"] },
41-
});
42-
43-
const excluded = wrapper.querySelector(".pinchDisabled") as HTMLElement;
44-
45-
fireEvent.touchStart(excluded, {
46-
touches: [
47-
{ pageX: 50, pageY: 50, clientX: 50, clientY: 50, target: excluded },
16+
{ clientX: 0, clientY: 0, pageX: 0, pageY: 0, target: excluded },
4817
{
49-
pageX: 100,
50-
pageY: 100,
51-
clientX: 100,
52-
clientY: 100,
18+
clientX: 50,
19+
clientY: 50,
20+
pageX: 50,
21+
pageY: 50,
5322
target: excluded,
5423
},
5524
],
5625
});
5726
fireEvent.touchMove(excluded, {
5827
touches: [
59-
{ pageX: 10, pageY: 10, clientX: 10, clientY: 10, target: excluded },
6028
{
61-
pageX: 150,
62-
pageY: 150,
63-
clientX: 150,
64-
clientY: 150,
29+
clientX: -20,
30+
clientY: -20,
31+
pageX: -20,
32+
pageY: -20,
33+
target: excluded,
34+
},
35+
{
36+
clientX: 70,
37+
clientY: 70,
38+
pageX: 70,
39+
pageY: 70,
6540
target: excluded,
6641
},
6742
],
6843
});
6944
fireEvent.touchEnd(excluded, { touches: [] });
7045

71-
expect(content.style.transform).toBe("translate(0px, 0px) scale(1)");
46+
expect(ref.current?.instance.state.scale).toBe(1);
7247
});
73-
});
74-
75-
describe("When pinch is fully disabled", () => {
76-
it("should not allow for pinching anywhere", async () => {
48+
it("should allow pinching on other elements", async () => {
7749
const { ref, pinch } = renderApp({
78-
pinch: { disabled: true },
50+
pinch: { excluded: ["pinchDisabled"] },
7951
});
8052

81-
pinch({ value: 2 });
82-
expect(ref.current?.instance.state.scale).toBe(1);
53+
pinch({ value: 1.5 });
54+
expect(ref.current?.instance.state.scale).toBeGreaterThan(1);
8355
});
8456
});
8557
});

0 commit comments

Comments
 (0)