Skip to content

Commit 5ddd137

Browse files
committed
feat(studio): support middle-mouse panning in preview
1 parent 2355d50 commit 5ddd137

4 files changed

Lines changed: 296 additions & 76 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.tsx

Lines changed: 183 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { memo, useCallback, useEffect, useRef, type Ref } from "react";
1+
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
22
import { Player } from "../../player";
33
import {
44
DEFAULT_PREVIEW_ZOOM,
5+
canStartPreviewPan,
56
clampPreviewPan,
67
clampPreviewZoomPercent,
8+
ownsPreviewPanTarget,
79
resolvePreviewWheelZoom,
810
toDomPrecision,
911
type PreviewZoomState,
@@ -34,6 +36,15 @@ export function getPreviewPlayerKey({
3436

3537
const ZOOM_HUD_TIMEOUT_MS = 1200;
3638
const ZOOM_SETTLE_MS = 200;
39+
const PREVIEW_STAGE_INSET_PX = 16;
40+
41+
function isPreviewAtFit(state: PreviewZoomState): boolean {
42+
return (
43+
Math.abs(state.zoomPercent - 100) < 0.5 &&
44+
Math.abs(state.panX) < 0.1 &&
45+
Math.abs(state.panY) < 0.1
46+
);
47+
}
3748

3849
function loadInitialZoom(): PreviewZoomState {
3950
const stored = readStudioUiPreferences().previewZoom;
@@ -46,21 +57,49 @@ function loadInitialZoom(): PreviewZoomState {
4657
: DEFAULT_PREVIEW_ZOOM;
4758
}
4859

60+
function resolvePreviewStageSize(
61+
viewportWidth: number,
62+
viewportHeight: number,
63+
portrait: boolean | undefined,
64+
): { width: number; height: number } {
65+
const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
66+
const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
67+
const aspectRatio = portrait ? 9 / 16 : 16 / 9;
68+
69+
if (availableWidth === 0 || availableHeight === 0) {
70+
return { width: 0, height: 0 };
71+
}
72+
73+
let width = availableWidth;
74+
let height = width / aspectRatio;
75+
if (height > availableHeight) {
76+
height = availableHeight;
77+
width = height * aspectRatio;
78+
}
79+
80+
return {
81+
width: toDomPrecision(width),
82+
height: toDomPrecision(height),
83+
};
84+
}
85+
4986
export const NLEPreview = memo(function NLEPreview({
5087
projectId,
5188
iframeRef,
5289
onIframeLoad,
5390
onCompositionLoadingChange,
5491
portrait,
5592
directUrl,
93+
refreshKey,
5694
suppressLoadingOverlay,
5795
}: NLEPreviewProps) {
58-
// Player key only changes for structural changes (project switch, composition
59-
// drill-down), NOT for content refreshes. Content refreshes use the lighter
60-
// iframe.src reload path handled by NLELayout → refreshPlayer().
61-
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
96+
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
97+
const prevRefreshKeyRef = useRef(refreshKey);
6298
const viewportRef = useRef<HTMLDivElement>(null);
6399
const stageRef = useRef<HTMLDivElement>(null);
100+
const [retiringKey, setRetiringKey] = useState<string | null>(null);
101+
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
102+
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
64103

65104
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
66105
const hudRef = useRef<HTMLDivElement>(null);
@@ -79,17 +118,32 @@ export const NLEPreview = memo(function NLEPreview({
79118
return () => {
80119
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
81120
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
121+
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
82122
};
83123
}, []);
84124

125+
useEffect(() => {
126+
const viewport = viewportRef.current;
127+
if (!viewport) return;
128+
129+
const updateStageSize = () => {
130+
const rect = viewport.getBoundingClientRect();
131+
setStageSize(resolvePreviewStageSize(rect.width, rect.height, portrait));
132+
};
133+
134+
updateStageSize();
135+
const observer = new ResizeObserver(updateStageSize);
136+
observer.observe(viewport);
137+
return () => observer.disconnect();
138+
}, [portrait]);
139+
85140
const writeTransform = useCallback((state: PreviewZoomState) => {
86141
const stage = stageRef.current;
87142
if (!stage) return;
88143
const s = toDomPrecision(state.zoomPercent / 100);
89144
const px = toDomPrecision(state.panX);
90145
const py = toDomPrecision(state.panY);
91-
stage.style.zoom = String(s);
92-
stage.style.transform = `translate(${px}px, ${py}px)`;
146+
stage.style.transform = `translate(${px}px, ${py}px) scale(${s})`;
93147
}, []);
94148

95149
const applyZoom = useCallback(
@@ -116,8 +170,7 @@ export const NLEPreview = memo(function NLEPreview({
116170
writeStudioUiPreferences({ previewZoom: final });
117171
const hud = hudRef.current;
118172
if (hud) {
119-
const zoomed = Math.abs(final.zoomPercent - 100) > 0.5;
120-
hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit";
173+
hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`;
121174
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
122175
hudTimerRef.current = setTimeout(() => {
123176
if (hudRef.current) hudRef.current.style.opacity = "0";
@@ -128,13 +181,31 @@ export const NLEPreview = memo(function NLEPreview({
128181
[writeTransform],
129182
);
130183

184+
if (refreshKey !== prevRefreshKeyRef.current) {
185+
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
186+
prevRefreshKeyRef.current = refreshKey;
187+
setRetiringKey(oldKey);
188+
}
189+
190+
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
191+
131192
const applyInitialZoom = useCallback(() => {
132193
const z = zoomRef.current;
133194
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
134195
writeTransform(z);
135196
}
136197
}, [writeTransform]);
137198

199+
const handleNewPlayerLoad = () => {
200+
onIframeLoad();
201+
applyInitialZoom();
202+
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
203+
retiringTimerRef.current = setTimeout(() => {
204+
setRetiringKey(null);
205+
retiringTimerRef.current = null;
206+
}, 160);
207+
};
208+
138209
useEffect(() => {
139210
const viewport = viewportRef.current;
140211
if (!viewport) return;
@@ -164,6 +235,8 @@ export const NLEPreview = memo(function NLEPreview({
164235
deltaY: event.deltaY,
165236
viewportWidth: rect.width,
166237
viewportHeight: rect.height,
238+
contentWidth: stageSize.width,
239+
contentHeight: stageSize.height,
167240
});
168241
applyZoom(next);
169242
return;
@@ -177,14 +250,14 @@ export const NLEPreview = memo(function NLEPreview({
177250

178251
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
179252
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
180-
}, [applyZoom]);
253+
}, [applyZoom, stageSize.height, stageSize.width]);
181254

182255
useEffect(() => {
183256
const viewport = viewportRef.current;
184257
if (!viewport) return;
185258

186259
const handleDblClick = (event: MouseEvent) => {
187-
if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return;
260+
if (isPreviewAtFit(zoomRef.current)) return;
188261
const rect = viewport.getBoundingClientRect();
189262
if (
190263
event.clientX < rect.left ||
@@ -201,20 +274,38 @@ export const NLEPreview = memo(function NLEPreview({
201274
return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
202275
}, [applyZoom]);
203276

204-
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
205-
if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return;
206-
event.currentTarget.setPointerCapture(event.pointerId);
207-
dragRef.current = {
208-
pointerId: event.pointerId,
209-
startX: event.clientX,
210-
startY: event.clientY,
211-
originX: zoomRef.current.panX,
212-
originY: zoomRef.current.panY,
277+
useEffect(() => {
278+
const isInsideViewport = (clientX: number, clientY: number): DOMRect | null => {
279+
const viewport = viewportRef.current;
280+
if (!viewport) return null;
281+
const rect = viewport.getBoundingClientRect();
282+
if (
283+
clientX < rect.left ||
284+
clientX > rect.right ||
285+
clientY < rect.top ||
286+
clientY > rect.bottom
287+
) {
288+
return null;
289+
}
290+
return rect;
291+
};
292+
293+
const handlePointerDown = (event: PointerEvent) => {
294+
const rect = isInsideViewport(event.clientX, event.clientY);
295+
if (!rect) return;
296+
if (!ownsPreviewPanTarget(event.target, stageRef.current)) return;
297+
if (!canStartPreviewPan(event.button)) return;
298+
event.preventDefault();
299+
dragRef.current = {
300+
pointerId: event.pointerId,
301+
startX: event.clientX,
302+
startY: event.clientY,
303+
originX: zoomRef.current.panX,
304+
originY: zoomRef.current.panY,
305+
};
213306
};
214-
}, []);
215307

216-
const handlePointerMove = useCallback(
217-
(event: React.PointerEvent<HTMLDivElement>) => {
308+
const handlePointerMove = (event: PointerEvent) => {
218309
const drag = dragRef.current;
219310
const viewport = viewportRef.current;
220311
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
@@ -226,17 +317,38 @@ export const NLEPreview = memo(function NLEPreview({
226317
zoomPercent: zoomRef.current.zoomPercent,
227318
viewportWidth: rect.width,
228319
viewportHeight: rect.height,
320+
contentWidth: stageSize.width,
321+
contentHeight: stageSize.height,
229322
});
230323
applyZoom({ ...zoomRef.current, ...pan });
231-
},
232-
[applyZoom],
233-
);
324+
};
234325

235-
const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
236-
if (dragRef.current?.pointerId === event.pointerId) {
237-
dragRef.current = null;
238-
}
239-
}, []);
326+
const finishDrag = (event: PointerEvent) => {
327+
if (dragRef.current?.pointerId === event.pointerId) {
328+
dragRef.current = null;
329+
}
330+
};
331+
332+
const handleAuxClick = (event: MouseEvent) => {
333+
if (event.button !== 1) return;
334+
if (!isInsideViewport(event.clientX, event.clientY)) return;
335+
if (!ownsPreviewPanTarget(event.target, stageRef.current)) return;
336+
event.preventDefault();
337+
};
338+
339+
document.addEventListener("pointerdown", handlePointerDown, { capture: true });
340+
document.addEventListener("pointermove", handlePointerMove, { capture: true });
341+
document.addEventListener("pointerup", finishDrag, { capture: true });
342+
document.addEventListener("pointercancel", finishDrag, { capture: true });
343+
document.addEventListener("auxclick", handleAuxClick, { capture: true });
344+
return () => {
345+
document.removeEventListener("pointerdown", handlePointerDown, { capture: true });
346+
document.removeEventListener("pointermove", handlePointerMove, { capture: true });
347+
document.removeEventListener("pointerup", finishDrag, { capture: true });
348+
document.removeEventListener("pointercancel", finishDrag, { capture: true });
349+
document.removeEventListener("auxclick", handleAuxClick, { capture: true });
350+
};
351+
}, [applyZoom, stageSize.height, stageSize.width]);
240352

241353
const initial = zoomRef.current;
242354

@@ -247,34 +359,48 @@ export const NLEPreview = memo(function NLEPreview({
247359
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40 bg-neutral-700"
248360
tabIndex={0}
249361
aria-label="Composition preview"
250-
onPointerDown={handlePointerDown}
251-
onPointerMove={handlePointerMove}
252-
onPointerUp={finishDrag}
253-
onPointerCancel={finishDrag}
254362
>
255-
<div
256-
ref={stageRef}
257-
className="absolute inset-2"
258-
style={{
259-
zoom: toDomPrecision(initial.zoomPercent / 100),
260-
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
261-
transformOrigin: "0 0",
262-
}}
263-
data-testid="preview-zoom-stage"
264-
>
265-
<Player
266-
key={activeKey}
267-
ref={iframeRef}
268-
projectId={directUrl ? undefined : projectId}
269-
directUrl={directUrl}
270-
onLoad={() => {
271-
onIframeLoad();
272-
applyInitialZoom();
363+
<div className="absolute inset-2 flex items-center justify-center pointer-events-none">
364+
<div
365+
ref={stageRef}
366+
className="relative shrink-0 pointer-events-auto"
367+
style={{
368+
width: `${stageSize.width}px`,
369+
height: `${stageSize.height}px`,
370+
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
371+
transformOrigin: "center center",
273372
}}
274-
onCompositionLoadingChange={onCompositionLoadingChange}
275-
portrait={portrait}
276-
suppressLoadingOverlay={suppressLoadingOverlay}
277-
/>
373+
data-testid="preview-zoom-stage"
374+
>
375+
{retiringKey && (
376+
<Player
377+
key={retiringKey}
378+
projectId={directUrl ? undefined : projectId}
379+
directUrl={directUrl}
380+
onLoad={() => {}}
381+
portrait={portrait}
382+
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
383+
/>
384+
)}
385+
<Player
386+
key={activeKey}
387+
ref={iframeRef}
388+
projectId={directUrl ? undefined : projectId}
389+
directUrl={directUrl}
390+
onLoad={
391+
retiringKey
392+
? handleNewPlayerLoad
393+
: () => {
394+
onIframeLoad();
395+
applyInitialZoom();
396+
}
397+
}
398+
onCompositionLoadingChange={onCompositionLoadingChange}
399+
portrait={portrait}
400+
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
401+
suppressLoadingOverlay={suppressLoadingOverlay}
402+
/>
403+
</div>
278404
</div>
279405
<div
280406
ref={hudRef}

0 commit comments

Comments
 (0)