Skip to content

Commit 19e50b8

Browse files
committed
feat(studio): support trackpad panning in preview
1 parent 5ddd137 commit 19e50b8

5 files changed

Lines changed: 251 additions & 9 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
2+
export const INNER_ROOT_MARKER_ATTR = "data-hf-inner-root";
3+
4+
export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
5+
"data-composition-id",
6+
"data-composition-file",
7+
"data-start",
8+
"data-duration",
9+
"data-end",
10+
"data-track-index",
11+
"data-track",
12+
"data-composition-src",
13+
"data-hf-authored-duration",
14+
"data-hf-authored-end",
15+
] as const;

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+
});

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
clampPreviewPan,
77
clampPreviewZoomPercent,
88
ownsPreviewPanTarget,
9+
resolvePreviewWheelPan,
910
resolvePreviewWheelZoom,
1011
toDomPrecision,
1112
type PreviewZoomState,
@@ -210,8 +211,6 @@ export const NLEPreview = memo(function NLEPreview({
210211
const viewport = viewportRef.current;
211212
if (!viewport) return;
212213

213-
let lastZoomTime = 0;
214-
215214
const handleWheel = (event: WheelEvent) => {
216215
const rect = viewport.getBoundingClientRect();
217216
if (
@@ -226,7 +225,6 @@ export const NLEPreview = memo(function NLEPreview({
226225
const isZoomGesture = event.ctrlKey || event.metaKey;
227226

228227
if (isZoomGesture) {
229-
lastZoomTime = Date.now();
230228
event.preventDefault();
231229
event.stopPropagation();
232230

@@ -242,10 +240,21 @@ export const NLEPreview = memo(function NLEPreview({
242240
return;
243241
}
244242

245-
if (Date.now() - lastZoomTime < 400) {
246-
event.preventDefault();
247-
event.stopPropagation();
248-
}
243+
if (!ownsPreviewPanTarget(event.target, stageRef.current)) return;
244+
245+
event.preventDefault();
246+
event.stopPropagation();
247+
248+
const next = resolvePreviewWheelPan({
249+
state: zoomRef.current,
250+
deltaX: event.deltaX,
251+
deltaY: event.deltaY,
252+
viewportWidth: rect.width,
253+
viewportHeight: rect.height,
254+
contentWidth: stageSize.width,
255+
contentHeight: stageSize.height,
256+
});
257+
applyZoom(next);
249258
};
250259

251260
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ownsPreviewPanTarget,
1414
PREVIEW_PAN_OVERSCROLL_PX,
1515
PREVIEW_PAN_SURFACE_SELECTOR,
16+
resolvePreviewWheelPan,
1617
resolvePreviewWheelZoom,
1718
toDomPrecision,
1819
} from "./previewZoom";
@@ -187,3 +188,32 @@ describe("resolvePreviewWheelZoom", () => {
187188
expect(next.panY).toBe(20);
188189
});
189190
});
191+
192+
describe("resolvePreviewWheelPan", () => {
193+
it("moves preview pan from wheel deltas", () => {
194+
const next = resolvePreviewWheelPan({
195+
state: DEFAULT_PREVIEW_ZOOM,
196+
deltaX: 18,
197+
deltaY: -12,
198+
viewportWidth: 800,
199+
viewportHeight: 600,
200+
});
201+
202+
expect(next.zoomPercent).toBe(100);
203+
expect(next.panX).toBe(-18);
204+
expect(next.panY).toBe(12);
205+
});
206+
207+
it("keeps wheel pan inside overscroll bounds", () => {
208+
const next = resolvePreviewWheelPan({
209+
state: DEFAULT_PREVIEW_ZOOM,
210+
deltaX: -900,
211+
deltaY: 900,
212+
viewportWidth: 800,
213+
viewportHeight: 600,
214+
});
215+
216+
expect(next.panX).toBe(PREVIEW_PAN_OVERSCROLL_PX);
217+
expect(next.panY).toBe(-PREVIEW_PAN_OVERSCROLL_PX);
218+
});
219+
});

packages/studio/src/components/nle/previewZoom.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,28 @@ export function resolvePreviewWheelZoom(input: {
105105
...pan,
106106
};
107107
}
108+
109+
export function resolvePreviewWheelPan(input: {
110+
state: PreviewZoomState;
111+
deltaX: number;
112+
deltaY: number;
113+
viewportWidth: number;
114+
viewportHeight: number;
115+
contentWidth?: number;
116+
contentHeight?: number;
117+
}): PreviewZoomState {
118+
const pan = clampPreviewPan({
119+
panX: input.state.panX - input.deltaX,
120+
panY: input.state.panY - input.deltaY,
121+
zoomPercent: input.state.zoomPercent,
122+
viewportWidth: input.viewportWidth,
123+
viewportHeight: input.viewportHeight,
124+
contentWidth: input.contentWidth,
125+
contentHeight: input.contentHeight,
126+
});
127+
128+
return {
129+
zoomPercent: clampPreviewZoomPercent(input.state.zoomPercent),
130+
...pan,
131+
};
132+
}

0 commit comments

Comments
 (0)