Skip to content

Commit 0d726ae

Browse files
authored
feat(studio): support preview panning with mouse and trackpad (#888)
* feat(studio): support middle-mouse panning in preview * feat(studio): support trackpad panning in preview * chore(core): remove stray compositionRoot helper
1 parent 5f8391b commit 0d726ae

5 files changed

Lines changed: 532 additions & 85 deletions

File tree

packages/studio/src/components/nle/NLELayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export const NLELayout = memo(function NLELayout({
310310
>
311311
{/* Preview + player controls */}
312312
<div className="flex-1 min-h-0 flex flex-col">
313-
<div className="flex-1 min-h-0 relative">
313+
<div className="flex-1 min-h-0 relative" data-preview-pan-surface="true">
314314
<NLEPreview
315315
projectId={projectId}
316316
iframeRef={iframeRef}

packages/studio/src/components/nle/NLEPreview.test.ts

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,101 @@
1-
import { describe, expect, it } from "vitest";
2-
import { getPreviewPlayerKey } from "./NLEPreview";
1+
// @vitest-environment happy-dom
2+
3+
import React, { act, createRef } from "react";
4+
import { createRoot } from "react-dom/client";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview";
7+
8+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9+
10+
vi.mock("../../player", async () => {
11+
const React = await import("react");
12+
13+
return {
14+
Player: React.forwardRef(function MockPlayer(
15+
props: {
16+
onLoad?: () => void;
17+
style?: React.CSSProperties;
18+
},
19+
ref: React.ForwardedRef<HTMLIFrameElement>,
20+
) {
21+
React.useEffect(() => {
22+
props.onLoad?.();
23+
}, [props]);
24+
25+
return React.createElement("div", {
26+
ref: ref as React.ForwardedRef<HTMLDivElement>,
27+
"data-testid": "mock-player",
28+
style: props.style,
29+
});
30+
}),
31+
};
32+
});
33+
34+
vi.mock("../../utils/studioUiPreferences", () => ({
35+
readStudioUiPreferences: () => ({}),
36+
writeStudioUiPreferences: () => {},
37+
}));
38+
39+
class MockResizeObserver {
40+
observe() {}
41+
disconnect() {}
42+
}
43+
44+
const originalResizeObserver = globalThis.ResizeObserver;
45+
46+
function setRect(node: Element, rect: { width: number; height: number }) {
47+
Object.defineProperty(node, "getBoundingClientRect", {
48+
configurable: true,
49+
value: () => ({
50+
x: 0,
51+
y: 0,
52+
left: 0,
53+
top: 0,
54+
right: rect.width,
55+
bottom: rect.height,
56+
width: rect.width,
57+
height: rect.height,
58+
toJSON: () => ({}),
59+
}),
60+
});
61+
}
62+
63+
function renderPreview() {
64+
const host = document.createElement("div");
65+
document.body.append(host);
66+
const root = createRoot(host);
67+
const iframeRef = createRef<HTMLIFrameElement>();
68+
69+
act(() => {
70+
root.render(
71+
React.createElement(NLEPreview, {
72+
projectId: "timeline-edit-playground",
73+
iframeRef,
74+
onIframeLoad: () => {},
75+
}),
76+
);
77+
});
78+
79+
const viewport = host.querySelector('[aria-label="Composition preview"]') as HTMLDivElement;
80+
const stage = host.querySelector('[data-testid="preview-zoom-stage"]') as HTMLDivElement;
81+
expect(viewport).toBeTruthy();
82+
expect(stage).toBeTruthy();
83+
84+
setRect(viewport, { width: 800, height: 600 });
85+
86+
return {
87+
host,
88+
root,
89+
viewport,
90+
stage,
91+
cleanup() {
92+
act(() => {
93+
root.unmount();
94+
});
95+
host.remove();
96+
},
97+
};
98+
}
399

4100
describe("getPreviewPlayerKey", () => {
5101
it("keeps the same player identity when only refreshKey changes", () => {
@@ -30,3 +126,70 @@ describe("getPreviewPlayerKey", () => {
30126
);
31127
});
32128
});
129+
130+
describe("NLEPreview", () => {
131+
beforeEach(() => {
132+
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
133+
});
134+
135+
afterEach(() => {
136+
globalThis.ResizeObserver = originalResizeObserver;
137+
});
138+
139+
it("pans the preview with middle mouse drag", () => {
140+
const view = renderPreview();
141+
const target = document.createElement("div");
142+
view.stage.appendChild(target);
143+
144+
act(() => {
145+
target.dispatchEvent(
146+
new PointerEvent("pointerdown", {
147+
bubbles: true,
148+
pointerId: 1,
149+
button: 1,
150+
clientX: 240,
151+
clientY: 180,
152+
}),
153+
);
154+
document.dispatchEvent(
155+
new PointerEvent("pointermove", {
156+
bubbles: true,
157+
pointerId: 1,
158+
clientX: 300,
159+
clientY: 220,
160+
}),
161+
);
162+
document.dispatchEvent(
163+
new PointerEvent("pointerup", {
164+
bubbles: true,
165+
pointerId: 1,
166+
}),
167+
);
168+
});
169+
170+
expect(view.stage.style.transform).toContain("translate(48px, 40px)");
171+
view.cleanup();
172+
});
173+
174+
it("pans the preview with a two-finger wheel gesture", () => {
175+
const view = renderPreview();
176+
const target = document.createElement("div");
177+
view.stage.appendChild(target);
178+
179+
act(() => {
180+
target.dispatchEvent(
181+
new WheelEvent("wheel", {
182+
bubbles: true,
183+
cancelable: true,
184+
clientX: 240,
185+
clientY: 180,
186+
deltaX: -30,
187+
deltaY: 24,
188+
}),
189+
);
190+
});
191+
192+
expect(view.stage.style.transform).toContain("translate(30px, -24px)");
193+
view.cleanup();
194+
});
195+
});

0 commit comments

Comments
 (0)