Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 110 additions & 7 deletions packages/studio/src/components/nle/NLEPreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ vi.mock("../../player", async () => {
};
});

vi.mock("../../utils/studioUiPreferences", () => ({
readStudioUiPreferences: () => ({}),
writeStudioUiPreferences: () => {},
}));

let resizeCallbacks: Array<() => void> = [];

class MockResizeObserver {
Expand Down Expand Up @@ -93,11 +88,11 @@ function renderPreview(
document.body.append(host);
const root = createRoot(host);
const iframeRef = createRef<HTMLIFrameElement>();
const render = (directUrl?: string) =>
const render = (directUrl?: string, projectId = "timeline-edit-playground") =>
act(() => {
root.render(
React.createElement(NLEPreview, {
projectId: "timeline-edit-playground",
projectId,
directUrl,
iframeRef,
onIframeLoad: () => {},
Expand Down Expand Up @@ -129,6 +124,9 @@ function renderPreview(
render,
viewport,
stage,
openProject(projectId: string) {
render(undefined, projectId);
},
cleanup() {
act(() => {
root.unmount();
Expand Down Expand Up @@ -262,6 +260,111 @@ describe("NLEPreview", () => {
view.cleanup();
});

describe("zoom", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
});

/** A pinch (ctrl + wheel) over the preview, then the settle that follows it. */
function pinchIn(view: ReturnType<typeof renderPreview>, steps: number) {
act(() => {
for (let step = 0; step < steps; step += 1) {
const pinch = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
clientX: 400,
clientY: 300,
deltaY: -10,
});
// happy-dom drops ctrlKey from the WheelEvent init; a trackpad pinch sets it.
Object.defineProperty(pinch, "ctrlKey", { value: true });
view.stage.dispatchEvent(pinch);
}
});
act(() => vi.advanceTimersByTime(300));
}
const chip = (view: ReturnType<typeof renderPreview>) =>
view.host.querySelector('[data-testid="preview-zoom-chip"]');
const navigator = (view: ReturnType<typeof renderPreview>) =>
view.host.querySelector('[data-testid="preview-zoom-navigator"]');

it("labels a pan away from Fit without a zoom as panned", () => {
const view = renderPreview();
act(() => {
view.stage.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX: -30, deltaY: 0 }),
);
});
act(() => vi.advanceTimersByTime(300));
expect(chip(view)?.textContent).toBe("Panned·Fit");
view.cleanup();
});

it("keeps a click on Fit from reaching the pane behind it", () => {
const view = renderPreview();
// Above React's root, as the preview pane's handler is: React stops the event before either.
const pane = vi.fn();
document.body.addEventListener("pointerdown", pane);
pinchIn(view, 10);
act(() => {
view.host
.querySelector('[data-testid="preview-zoom-fit"]')!
.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
});
document.body.removeEventListener("pointerdown", pane);
expect(pane).not.toHaveBeenCalled();
view.cleanup();
});

it("opens at Fit even when an older Studio saved a zoom", () => {
localStorage.setItem(
"hf-studio-ui-preferences",
JSON.stringify({ previewZoom: { zoomPercent: 245, panX: 0, panY: 0 } }),
);
const view = renderPreview();
expect(view.stage.style.transform).toContain("scale(1)");
expect(chip(view)).toBeNull();
view.cleanup();
});

it("says how far it is zoomed, shows where in the frame, and Fit puts it back", () => {
const view = renderPreview();
expect([chip(view), navigator(view)]).toEqual([null, null]);

pinchIn(view, 10);
expect(chip(view)?.textContent).toMatch(/^Zoomed 2\d\d%·Fit$/);
const region = view.host.querySelector<HTMLElement>(
'[data-testid="preview-zoom-navigator-region"]',
);
expect(Number.parseFloat(region!.style.width)).toBeLessThan(100);

act(() => {
view.host.querySelector<HTMLButtonElement>('[data-testid="preview-zoom-fit"]')!.click();
});
act(() => vi.advanceTimersByTime(300));
expect(view.stage.style.transform).toContain("scale(1)");
expect([chip(view), navigator(view)]).toEqual([null, null]);
view.cleanup();
});

it("keeps a zoom only while the project is open: nothing is saved, and another project opens at Fit", () => {
const view = renderPreview();
pinchIn(view, 10);
expect(chip(view)).not.toBeNull();
expect(localStorage.getItem("hf-studio-ui-preferences") ?? "").not.toContain("previewZoom");

view.openProject("another-project");
expect(view.stage.style.transform).toContain("scale(1)");
expect(chip(view)).toBeNull();
view.cleanup();
});
});

it("insets the picture by default and fills a same-shape box when fillBox is on", () => {
const box = { width: 640, height: 360 };
const inset = renderPreview(undefined, { box });
Expand Down
97 changes: 51 additions & 46 deletions packages/studio/src/components/nle/NLEPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,31 @@ import {
readPreviewCompositionSize,
type PreviewCompositionSize,
} from "../../utils/previewCompositionSize";
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type RefObject,
} from "react";
import { Player } from "../../player";
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
import {
DEFAULT_PREVIEW_ZOOM,
canStartPreviewPan,
clampPreviewPan,
clampPreviewZoomPercent,
isPreviewAtFit,
ownsPreviewPanTarget,
resolvePreviewWheelPan,
resolvePreviewWheelZoom,
toDomPrecision,
type PreviewZoomState,
} from "./previewZoom";
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay";
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
interface NLEPreviewProps {
Expand Down Expand Up @@ -65,25 +74,6 @@ const SHADOW_IFRAME_STYLE: React.CSSProperties = {
pointerEvents: "none",
};

function isPreviewAtFit(state: PreviewZoomState): boolean {
return (
Math.abs(state.zoomPercent - 100) < 0.5 &&
Math.abs(state.panX) < 0.1 &&
Math.abs(state.panY) < 0.1
);
}

function loadInitialZoom(): PreviewZoomState {
const stored = readStudioUiPreferences().previewZoom;
return stored
? {
zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
panX: stored.panX,
panY: stored.panY,
}
: DEFAULT_PREVIEW_ZOOM;
}

export function resolvePreviewStageSize(
viewportWidth: number,
viewportHeight: number,
Expand Down Expand Up @@ -160,8 +150,8 @@ export const NLEPreview = memo(function NLEPreview({
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));

const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
const hudRef = useRef<HTMLDivElement>(null);
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -232,14 +222,33 @@ export const NLEPreview = memo(function NLEPreview({
const stageSizeRef = useRef(stageSize);
stageSizeRef.current = stageSize;

const writeTransform = useCallback((state: PreviewZoomState) => {
const stage = stageRef.current;
if (!stage) return;
const s = toDomPrecision(state.zoomPercent / 100);
const px = toDomPrecision(state.panX);
const py = toDomPrecision(state.panY);
stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
}, []);
const { draw: drawNavigator, setRegion: setNavigatorRegion } = usePreviewNavigator(
viewportRef,
stageSize,
zoomRef,
);
const writeTransform = useCallback(
(state: PreviewZoomState) => {
const stage = stageRef.current;
if (!stage) return;
const s = toDomPrecision(state.zoomPercent / 100);
const px = toDomPrecision(state.panX);
const py = toDomPrecision(state.panY);
stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
drawNavigator(state);
},
[drawNavigator],
);

const zoomProjectRef = useRef(projectId);
// Before paint, so the next project never shows a frame at the previous one's zoom.
useLayoutEffect(() => {
if (zoomProjectRef.current === projectId) return;
zoomProjectRef.current = projectId;
zoomRef.current = DEFAULT_PREVIEW_ZOOM;
writeTransform(DEFAULT_PREVIEW_ZOOM);
setSettledZoom(DEFAULT_PREVIEW_ZOOM);
}, [projectId, writeTransform]);

const applyTransform = useCallback(
(next: PreviewZoomState, showHud: boolean) => {
Expand Down Expand Up @@ -269,7 +278,6 @@ export const NLEPreview = memo(function NLEPreview({
settleTimerRef.current = setTimeout(() => {
zoomingRef.current = false;
const final = zoomRef.current;
writeStudioUiPreferences({ previewZoom: final });
setSettledZoom((prev) =>
prev.zoomPercent === final.zoomPercent &&
prev.panX === final.panX &&
Expand Down Expand Up @@ -304,9 +312,8 @@ export const NLEPreview = memo(function NLEPreview({

const applyInitialZoom = useCallback(() => {
const z = zoomRef.current;
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
// A pan persisted on a large window can restore the composition mostly
// off-screen in a smaller one; clamp against the current viewport first.
if (!isPreviewAtFit(z)) {
// A composition reload can bring a different frame size than the pan was made on; clamp first.
const viewport = viewportRef.current;
const rect = viewport?.getBoundingClientRect();
const sz = stageSizeRef.current;
Expand Down Expand Up @@ -569,17 +576,15 @@ export const NLEPreview = memo(function NLEPreview({
style={{ opacity: 0, transition: "opacity 200ms ease-in" }}
aria-live="polite"
/>
{!isPreviewAtFit(settledZoom) && (
<button
type="button"
className="absolute bottom-3 right-3 z-50 rounded-md px-2.5 py-1 text-xs font-medium text-white/80 bg-black/50 backdrop-blur-xs hover:bg-black/70 hover:text-white transition-colors"
onClick={() => applyZoom(DEFAULT_PREVIEW_ZOOM)}
aria-label="Reset zoom to fit"
data-testid="preview-reset-zoom"
>
{Math.round(settledZoom.zoomPercent)}% — Reset
</button>
)}
<PreviewZoomOverlay
zoom={settledZoom}
stageSize={stageSize}
onFit={() => {
applyZoom(DEFAULT_PREVIEW_ZOOM);
viewportRef.current?.focus();
}}
navigatorRegionRef={setNavigatorRegion}
/>
</div>
</div>
);
Expand Down
Loading
Loading