Skip to content

Commit 34126e2

Browse files
committed
refactor(studio): the zoom chip and navigator live beside the preview, under the file size cap
1 parent e91b20b commit 34126e2

3 files changed

Lines changed: 132 additions & 87 deletions

File tree

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

Lines changed: 15 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ import {
1818
canStartPreviewPan,
1919
clampPreviewPan,
2020
clampPreviewZoomPercent,
21+
isPreviewAtFit,
2122
ownsPreviewPanTarget,
22-
resolvePreviewVisibleRegion,
2323
resolvePreviewWheelPan,
2424
resolvePreviewWheelZoom,
2525
toDomPrecision,
2626
type PreviewZoomState,
2727
} from "./previewZoom";
2828
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
29+
import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay";
2930
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
3031
interface NLEPreviewProps {
3132
projectId: string;
@@ -61,7 +62,6 @@ export function getPreviewPlayerKey({
6162
const ZOOM_HUD_TIMEOUT_MS = 1200;
6263
const ZOOM_SETTLE_MS = 200;
6364
const PREVIEW_STAGE_INSET_PX = 8;
64-
const NAVIGATOR_PX = 112;
6565

6666
// clip-path as well as visibility: the player's loading overlay sets its own
6767
// visibility:visible and would otherwise paint over the live frame.
@@ -73,23 +73,6 @@ const SHADOW_IFRAME_STYLE: React.CSSProperties = {
7373
pointerEvents: "none",
7474
};
7575

76-
const isFitZoom = (zoomPercent: number) => Math.abs(zoomPercent - 100) < 0.5;
77-
78-
function isPreviewAtFit(state: PreviewZoomState): boolean {
79-
return isFitZoom(state.zoomPercent) && Math.abs(state.panX) < 0.1 && Math.abs(state.panY) < 0.1;
80-
}
81-
82-
function zoomChipLabel(zoomPercent: number): string {
83-
return isFitZoom(zoomPercent) ? "Panned" : `Zoomed ${Math.round(zoomPercent)}%`;
84-
}
85-
86-
function navigatorFrameSize(stage: { width: number; height: number }) {
87-
const ratio = stage.width > 0 && stage.height > 0 ? stage.width / stage.height : 16 / 9;
88-
return ratio >= 1
89-
? { width: NAVIGATOR_PX, height: toDomPrecision(NAVIGATOR_PX / ratio) }
90-
: { width: toDomPrecision(NAVIGATOR_PX * ratio), height: NAVIGATOR_PX };
91-
}
92-
9376
export function resolvePreviewStageSize(
9477
viewportWidth: number,
9578
viewportHeight: number,
@@ -237,32 +220,11 @@ export const NLEPreview = memo(function NLEPreview({
237220
const stageSizeRef = useRef(stageSize);
238221
stageSizeRef.current = stageSize;
239222

240-
const navigatorRegionRef = useRef<HTMLDivElement | null>(null);
241-
const drawNavigator = useCallback((state: PreviewZoomState) => {
242-
const region = navigatorRegionRef.current;
243-
const rect = viewportRef.current?.getBoundingClientRect();
244-
if (!region || !rect) return;
245-
const visible = resolvePreviewVisibleRegion({
246-
state,
247-
viewportWidth: rect.width,
248-
viewportHeight: rect.height,
249-
contentWidth: stageSizeRef.current.width,
250-
contentHeight: stageSizeRef.current.height,
251-
});
252-
region.style.left = `${visible.left * 100}%`;
253-
region.style.top = `${visible.top * 100}%`;
254-
region.style.width = `${visible.width * 100}%`;
255-
region.style.height = `${visible.height * 100}%`;
256-
}, []);
257-
const setNavigatorRegion = useCallback(
258-
(node: HTMLDivElement | null) => {
259-
navigatorRegionRef.current = node;
260-
drawNavigator(zoomRef.current);
261-
},
262-
[drawNavigator],
223+
const { draw: drawNavigator, setRegion: setNavigatorRegion } = usePreviewNavigator(
224+
viewportRef,
225+
stageSize,
226+
zoomRef,
263227
);
264-
useEffect(() => drawNavigator(zoomRef.current), [stageSize, drawNavigator]);
265-
266228
const writeTransform = useCallback(
267229
(state: PreviewZoomState) => {
268230
const stage = stageRef.current;
@@ -600,49 +562,15 @@ export const NLEPreview = memo(function NLEPreview({
600562
style={{ opacity: 0, transition: "opacity 200ms ease-in" }}
601563
aria-live="polite"
602564
/>
603-
{!isPreviewAtFit(settledZoom) && (
604-
<>
605-
<div
606-
className="absolute top-3 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1.5 rounded-md py-1 pl-2.5 pr-1 text-xs text-white/80 bg-black/60 backdrop-blur-xs"
607-
data-testid="preview-zoom-chip"
608-
// The pane clears the timeline selection on a pointerdown outside the frame.
609-
onPointerDown={(event) => event.stopPropagation()}
610-
>
611-
<span className="tabular-nums">{zoomChipLabel(settledZoom.zoomPercent)}</span>
612-
<span aria-hidden="true" className="text-white/30">
613-
·
614-
</span>
615-
<button
616-
type="button"
617-
className="rounded px-1.5 py-0.5 font-medium text-studio-accent hover:bg-white/10 transition-colors"
618-
onClick={() => {
619-
applyZoom(DEFAULT_PREVIEW_ZOOM);
620-
viewportRef.current?.focus();
621-
}}
622-
aria-label="Fit the whole frame in view"
623-
data-testid="preview-zoom-fit"
624-
>
625-
Fit
626-
</button>
627-
</div>
628-
<div
629-
aria-hidden="true"
630-
className="pointer-events-none absolute bottom-3 right-3 z-50 rounded-md p-1.5 bg-black/60 backdrop-blur-xs"
631-
data-testid="preview-zoom-navigator"
632-
>
633-
<div
634-
className="relative overflow-hidden bg-white/10"
635-
style={navigatorFrameSize(stageSize)}
636-
>
637-
<div
638-
ref={setNavigatorRegion}
639-
className="absolute rounded-[1px] border border-studio-accent bg-studio-accent/15"
640-
data-testid="preview-zoom-navigator-region"
641-
/>
642-
</div>
643-
</div>
644-
</>
645-
)}
565+
<PreviewZoomOverlay
566+
zoom={settledZoom}
567+
stageSize={stageSize}
568+
onFit={() => {
569+
applyZoom(DEFAULT_PREVIEW_ZOOM);
570+
viewportRef.current?.focus();
571+
}}
572+
navigatorRegionRef={setNavigatorRegion}
573+
/>
646574
</div>
647575
</div>
648576
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useCallback, useEffect, useRef, type RefObject } from "react";
2+
import {
3+
isFitZoom,
4+
isPreviewAtFit,
5+
resolvePreviewVisibleRegion,
6+
toDomPrecision,
7+
type PreviewZoomState,
8+
} from "./previewZoom";
9+
10+
const NAVIGATOR_PX = 112;
11+
12+
function zoomChipLabel(zoomPercent: number): string {
13+
return isFitZoom(zoomPercent) ? "Panned" : `Zoomed ${Math.round(zoomPercent)}%`;
14+
}
15+
16+
function navigatorFrameSize(stage: { width: number; height: number }) {
17+
const ratio = stage.width > 0 && stage.height > 0 ? stage.width / stage.height : 16 / 9;
18+
return ratio >= 1
19+
? { width: NAVIGATOR_PX, height: toDomPrecision(NAVIGATOR_PX / ratio) }
20+
: { width: toDomPrecision(NAVIGATOR_PX * ratio), height: NAVIGATOR_PX };
21+
}
22+
23+
/** `draw` moves the navigator's highlight without a render, so it can follow every transform write. */
24+
export function usePreviewNavigator(
25+
viewportRef: RefObject<HTMLDivElement | null>,
26+
stageSize: { width: number; height: number },
27+
zoomRef: RefObject<PreviewZoomState>,
28+
) {
29+
const regionRef = useRef<HTMLDivElement | null>(null);
30+
const stageSizeRef = useRef(stageSize);
31+
stageSizeRef.current = stageSize;
32+
const draw = useCallback(
33+
(state: PreviewZoomState) => {
34+
const region = regionRef.current;
35+
const rect = viewportRef.current?.getBoundingClientRect();
36+
if (!region || !rect) return;
37+
const visible = resolvePreviewVisibleRegion({
38+
state,
39+
viewportWidth: rect.width,
40+
viewportHeight: rect.height,
41+
contentWidth: stageSizeRef.current.width,
42+
contentHeight: stageSizeRef.current.height,
43+
});
44+
region.style.left = `${visible.left * 100}%`;
45+
region.style.top = `${visible.top * 100}%`;
46+
region.style.width = `${visible.width * 100}%`;
47+
region.style.height = `${visible.height * 100}%`;
48+
},
49+
[viewportRef],
50+
);
51+
const setRegion = useCallback(
52+
(node: HTMLDivElement | null) => {
53+
regionRef.current = node;
54+
draw(zoomRef.current);
55+
},
56+
[draw, zoomRef],
57+
);
58+
useEffect(() => draw(zoomRef.current), [stageSize, draw, zoomRef]);
59+
return { draw, setRegion };
60+
}
61+
62+
export function PreviewZoomOverlay({
63+
zoom,
64+
stageSize,
65+
onFit,
66+
navigatorRegionRef,
67+
}: {
68+
zoom: PreviewZoomState;
69+
stageSize: { width: number; height: number };
70+
onFit: () => void;
71+
navigatorRegionRef: (node: HTMLDivElement | null) => void;
72+
}) {
73+
if (isPreviewAtFit(zoom)) return null;
74+
return (
75+
<>
76+
<div
77+
className="absolute top-3 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1.5 rounded-md py-1 pl-2.5 pr-1 text-xs text-white/80 bg-black/60 backdrop-blur-xs"
78+
data-testid="preview-zoom-chip"
79+
// The pane clears the timeline selection on a pointerdown outside the frame.
80+
onPointerDown={(event) => event.stopPropagation()}
81+
>
82+
<span className="tabular-nums">{zoomChipLabel(zoom.zoomPercent)}</span>
83+
<span aria-hidden="true" className="text-white/30">
84+
·
85+
</span>
86+
<button
87+
type="button"
88+
className="rounded px-1.5 py-0.5 font-medium text-studio-accent hover:bg-white/10 transition-colors"
89+
onClick={onFit}
90+
aria-label="Fit the whole frame in view"
91+
data-testid="preview-zoom-fit"
92+
>
93+
Fit
94+
</button>
95+
</div>
96+
<div
97+
aria-hidden="true"
98+
className="pointer-events-none absolute bottom-3 right-3 z-50 rounded-md p-1.5 bg-black/60 backdrop-blur-xs"
99+
data-testid="preview-zoom-navigator"
100+
>
101+
<div className="relative overflow-hidden bg-white/10" style={navigatorFrameSize(stageSize)}>
102+
<div
103+
ref={navigatorRegionRef}
104+
className="absolute rounded-[1px] border border-studio-accent bg-studio-accent/15"
105+
data-testid="preview-zoom-navigator-region"
106+
/>
107+
</div>
108+
</div>
109+
</>
110+
);
111+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = {
1717
const ZOOM_SENSITIVITY = 0.007;
1818
const MAX_DELTA = 10;
1919

20+
export const isFitZoom = (zoomPercent: number) => Math.abs(zoomPercent - 100) < 0.5;
21+
22+
export function isPreviewAtFit(state: PreviewZoomState): boolean {
23+
return isFitZoom(state.zoomPercent) && Math.abs(state.panX) < 0.1 && Math.abs(state.panY) < 0.1;
24+
}
25+
2026
export function toDomPrecision(value: number): number {
2127
return Math.round(value * 10000) / 10000;
2228
}

0 commit comments

Comments
 (0)