mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(studio): add preview zoom controls (#761)
* fix(studio): add smooth preview zoom with pinch/Ctrl+scroll - Scale iframe content from inside (contentDocument.documentElement) instead of scaling the parent div, avoiding compositor re-rasterization on every zoom frame — critical for smooth zoom on high-refresh displays (240Hz) - Document-level capture-phase wheel handler bypasses the DomEditOverlay - Center-based zoom (no pan drift from pointer-anchored formulas) - Transient HUD shows zoom % briefly, no persistent UI controls - Double-click preview area to reset zoom to fit - Drag-to-pan when zoomed past 100% - Momentum scroll suppression after pinch gesture (400ms cooldown) - Delta clamping (MAX_DELTA=10) prevents overshooting on fast gestures - toDomPrecision rounds transform values to 4 decimals (matches tldraw) - Zoom state persisted to localStorage with 200ms debounce - Exposes --preview-zoom CSS custom property for overlay coordinate mapping - Fix infinite render loop in NLELayout (onIframeRef → refreshPreviewDocumentVersion) * fix(studio): use CSS zoom instead of transform scale for preview zoom CSS transform: scale() on a div containing an iframe causes compositor cross-layer sync issues that produce visible frame tearing on high-refresh displays (240Hz ProMotion). CSS zoom property changes the actual rendered size without compositor layer synchronization, eliminating the jumping. - Replace transform: scale(Z) with zoom: Z on the stage div - Keep transform: translate() for panning (compositor-friendly, no iframe) - Overlays work correctly since getBoundingClientRect() includes zoom - Remove will-change, transition hacks, pointer-events toggles * fix(ci): use apt-get for ffmpeg in preview-regression workflow The FedericoCarboni/setup-ffmpeg action downloads from an external URL that has been persistently unreachable, causing CI failures. Switch to apt-get install which uses Ubuntu's package repos (same as ci.yml and player-perf.yml). * fix(studio): clear zoom timers on NLEPreview unmount settleTimerRef, hudTimerRef, and retiringTimerRef could fire after component unmount. Add cleanup effect to prevent stale callbacks. * feat(studio): persist sidebar, timeline, and playback speed across reloads Wire up studioUiPreferences for the three remaining UI states requested in #752: left sidebar collapsed, timeline visibility, and playback rate. All three now survive page reloads using the same localStorage key as preview zoom.
This commit is contained in:
@@ -13,6 +13,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
|
||||
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
||||
import { useDomEditSession } from "./hooks/useDomEditSession";
|
||||
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
|
||||
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
||||
import { useRenderClipContent } from "./hooks/useRenderClipContent";
|
||||
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
|
||||
@@ -98,8 +99,15 @@ export function StudioApp() {
|
||||
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300);
|
||||
}, []);
|
||||
|
||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||
const toggleTimelineVisibility = useCallback(() => setTimelineVisible((v) => !v), []);
|
||||
const [timelineVisible, setTimelineVisible] = useState(
|
||||
() => readStudioUiPreferences().timelineVisible ?? true,
|
||||
);
|
||||
const toggleTimelineVisibility = useCallback(() => {
|
||||
setTimelineVisible((v) => {
|
||||
writeStudioUiPreferences({ timelineVisible: !v });
|
||||
return !v;
|
||||
});
|
||||
}, []);
|
||||
const { appToast, showToast } = useToast();
|
||||
const panelLayout = usePanelLayout();
|
||||
const editHistory = usePersistentEditHistory({ projectId });
|
||||
|
||||
@@ -247,9 +247,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
const currentLevel = compositionStack[compositionStack.length - 1];
|
||||
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
||||
|
||||
const onIframeRefStable = useRef(onIframeRef);
|
||||
onIframeRefStable.current = onIframeRef;
|
||||
useEffect(() => {
|
||||
onIframeRef?.(iframeRef.current);
|
||||
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
|
||||
onIframeRefStable.current?.(iframeRef.current);
|
||||
}, [compositionStack.length, refreshKey, iframeRef]);
|
||||
|
||||
// Resize divider handlers
|
||||
const handleDividerPointerDown = useCallback(
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { memo, useRef, useState, type Ref } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
|
||||
import { Player } from "../../player";
|
||||
import {
|
||||
DEFAULT_PREVIEW_ZOOM,
|
||||
clampPreviewPan,
|
||||
clampPreviewZoomPercent,
|
||||
resolvePreviewWheelZoom,
|
||||
toDomPrecision,
|
||||
type PreviewZoomState,
|
||||
} from "./previewZoom";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
|
||||
interface NLEPreviewProps {
|
||||
projectId: string;
|
||||
@@ -23,17 +32,20 @@ export function getPreviewPlayerKey({
|
||||
return directUrl ?? projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the composition preview with crossfade on reload.
|
||||
*
|
||||
* When refreshKey changes, a new Player is mounted alongside the old one.
|
||||
* The old Player stays visible (opacity 1) until the new one fires onLoad,
|
||||
* at which point the old is removed. This avoids the flash that a simple
|
||||
* key-swap remount would cause.
|
||||
*
|
||||
* Uses the render-time state adjustment pattern (React-sanctioned) to detect
|
||||
* refreshKey changes — no useEffect needed.
|
||||
*/
|
||||
const ZOOM_HUD_TIMEOUT_MS = 1200;
|
||||
const ZOOM_SETTLE_MS = 200;
|
||||
|
||||
function loadInitialZoom(): PreviewZoomState {
|
||||
const stored = readStudioUiPreferences().previewZoom;
|
||||
return stored
|
||||
? {
|
||||
zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
|
||||
panX: stored.panX,
|
||||
panY: stored.panY,
|
||||
}
|
||||
: DEFAULT_PREVIEW_ZOOM;
|
||||
}
|
||||
|
||||
export const NLEPreview = memo(function NLEPreview({
|
||||
projectId,
|
||||
iframeRef,
|
||||
@@ -46,12 +58,78 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
}: NLEPreviewProps) {
|
||||
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [retiringKey, setRetiringKey] = useState<string | null>(null);
|
||||
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Detect refreshKey change during render (React-sanctioned derived state pattern).
|
||||
// When the key changes, the current active player becomes the retiring player
|
||||
// and a new active player is mounted alongside it.
|
||||
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
||||
const hudRef = useRef<HTMLDivElement>(null);
|
||||
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const zoomingRef = useRef(false);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
|
||||
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
|
||||
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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.zoom = String(s);
|
||||
stage.style.transform = `translate(${px}px, ${py}px)`;
|
||||
}, []);
|
||||
|
||||
const applyZoom = useCallback(
|
||||
(next: PreviewZoomState) => {
|
||||
const clamped: PreviewZoomState = {
|
||||
zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
|
||||
panX: Number.isFinite(next.panX) ? next.panX : 0,
|
||||
panY: Number.isFinite(next.panY) ? next.panY : 0,
|
||||
};
|
||||
zoomRef.current = clamped;
|
||||
|
||||
if (!zoomingRef.current) {
|
||||
zoomingRef.current = true;
|
||||
const hud = hudRef.current;
|
||||
if (hud) hud.style.opacity = "1";
|
||||
}
|
||||
|
||||
writeTransform(clamped);
|
||||
|
||||
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
|
||||
settleTimerRef.current = setTimeout(() => {
|
||||
zoomingRef.current = false;
|
||||
const final = zoomRef.current;
|
||||
writeStudioUiPreferences({ previewZoom: final });
|
||||
const hud = hudRef.current;
|
||||
if (hud) {
|
||||
const zoomed = Math.abs(final.zoomPercent - 100) > 0.5;
|
||||
hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit";
|
||||
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
|
||||
hudTimerRef.current = setTimeout(() => {
|
||||
if (hudRef.current) hudRef.current.style.opacity = "0";
|
||||
}, ZOOM_HUD_TIMEOUT_MS);
|
||||
}
|
||||
}, ZOOM_SETTLE_MS);
|
||||
},
|
||||
[writeTransform],
|
||||
);
|
||||
|
||||
if (refreshKey !== prevRefreshKeyRef.current) {
|
||||
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
|
||||
prevRefreshKeyRef.current = refreshKey;
|
||||
@@ -60,8 +138,16 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
|
||||
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
|
||||
|
||||
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) {
|
||||
writeTransform(z);
|
||||
}
|
||||
}, [writeTransform]);
|
||||
|
||||
const handleNewPlayerLoad = () => {
|
||||
onIframeLoad();
|
||||
applyInitialZoom();
|
||||
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
|
||||
retiringTimerRef.current = setTimeout(() => {
|
||||
setRetiringKey(null);
|
||||
@@ -69,33 +155,167 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
}, 160);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
let lastZoomTime = 0;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
if (
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right ||
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (isZoomGesture) {
|
||||
lastZoomTime = Date.now();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const next = resolvePreviewWheelZoom({
|
||||
state: zoomRef.current,
|
||||
deltaY: event.deltaY,
|
||||
viewportWidth: rect.width,
|
||||
viewportHeight: rect.height,
|
||||
});
|
||||
applyZoom(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - lastZoomTime < 400) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
|
||||
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
}, [applyZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
const handleDblClick = (event: MouseEvent) => {
|
||||
if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return;
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
if (
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right ||
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyZoom(DEFAULT_PREVIEW_ZOOM);
|
||||
};
|
||||
|
||||
document.addEventListener("dblclick", handleDblClick, { capture: true });
|
||||
return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
|
||||
}, [applyZoom]);
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: zoomRef.current.panX,
|
||||
originY: zoomRef.current.panY,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
const viewport = viewportRef.current;
|
||||
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const pan = clampPreviewPan({
|
||||
panX: drag.originX + event.clientX - drag.startX,
|
||||
panY: drag.originY + event.clientY - drag.startY,
|
||||
zoomPercent: zoomRef.current.zoomPercent,
|
||||
viewportWidth: rect.width,
|
||||
viewportHeight: rect.height,
|
||||
});
|
||||
applyZoom({ ...zoomRef.current, ...pan });
|
||||
},
|
||||
[applyZoom],
|
||||
);
|
||||
|
||||
const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId === event.pointerId) {
|
||||
dragRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initial = zoomRef.current;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
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"
|
||||
tabIndex={0}
|
||||
aria-label="Composition preview"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={finishDrag}
|
||||
>
|
||||
{retiringKey && (
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="absolute inset-2"
|
||||
style={{
|
||||
zoom: toDomPrecision(initial.zoomPercent / 100),
|
||||
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
data-testid="preview-zoom-stage"
|
||||
>
|
||||
{retiringKey && (
|
||||
<Player
|
||||
key={retiringKey}
|
||||
projectId={directUrl ? undefined : projectId}
|
||||
directUrl={directUrl}
|
||||
onLoad={() => {}}
|
||||
portrait={portrait}
|
||||
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
|
||||
/>
|
||||
)}
|
||||
<Player
|
||||
key={retiringKey}
|
||||
key={activeKey}
|
||||
ref={iframeRef}
|
||||
projectId={directUrl ? undefined : projectId}
|
||||
directUrl={directUrl}
|
||||
onLoad={() => {}}
|
||||
onLoad={
|
||||
retiringKey
|
||||
? handleNewPlayerLoad
|
||||
: () => {
|
||||
onIframeLoad();
|
||||
applyInitialZoom();
|
||||
}
|
||||
}
|
||||
onCompositionLoadingChange={onCompositionLoadingChange}
|
||||
portrait={portrait}
|
||||
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
|
||||
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
||||
suppressLoadingOverlay={suppressLoadingOverlay}
|
||||
/>
|
||||
)}
|
||||
<Player
|
||||
key={activeKey}
|
||||
ref={iframeRef}
|
||||
projectId={directUrl ? undefined : projectId}
|
||||
directUrl={directUrl}
|
||||
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
|
||||
onCompositionLoadingChange={onCompositionLoadingChange}
|
||||
portrait={portrait}
|
||||
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
||||
suppressLoadingOverlay={suppressLoadingOverlay}
|
||||
</div>
|
||||
<div
|
||||
ref={hudRef}
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
|
||||
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
|
||||
aria-live="polite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PREVIEW_ZOOM,
|
||||
MAX_PREVIEW_ZOOM_PERCENT,
|
||||
MIN_PREVIEW_ZOOM_PERCENT,
|
||||
clampPreviewPan,
|
||||
clampPreviewZoomPercent,
|
||||
getNextPreviewZoomPercent,
|
||||
getPreviewWheelZoomPercent,
|
||||
resolvePreviewWheelZoom,
|
||||
toDomPrecision,
|
||||
} from "./previewZoom";
|
||||
|
||||
describe("toDomPrecision", () => {
|
||||
it("rounds to 4 decimal places", () => {
|
||||
expect(toDomPrecision(1.23456789)).toBe(1.2346);
|
||||
});
|
||||
|
||||
it("preserves zero", () => {
|
||||
expect(toDomPrecision(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("handles negative values", () => {
|
||||
expect(toDomPrecision(-3.14159)).toBe(-3.1416);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampPreviewZoomPercent", () => {
|
||||
it("falls back to fit zoom for invalid input", () => {
|
||||
expect(clampPreviewZoomPercent(Number.NaN)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps to supported preview zoom bounds", () => {
|
||||
expect(clampPreviewZoomPercent(1)).toBe(MIN_PREVIEW_ZOOM_PERCENT);
|
||||
expect(clampPreviewZoomPercent(5000)).toBe(MAX_PREVIEW_ZOOM_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPreviewWheelZoomPercent", () => {
|
||||
it("zooms in on negative deltaY (scroll up / pinch out)", () => {
|
||||
expect(getPreviewWheelZoomPercent(-5, 100)).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("zooms out on positive deltaY (scroll down / pinch in)", () => {
|
||||
expect(getPreviewWheelZoomPercent(5, 200)).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("clamps large deltas to prevent overshoot", () => {
|
||||
const small = getPreviewWheelZoomPercent(-5, 100);
|
||||
const large = getPreviewWheelZoomPercent(-50, 100);
|
||||
expect(large).toBeLessThan(small * 2);
|
||||
});
|
||||
|
||||
it("preserves the current zoom for invalid input", () => {
|
||||
expect(getPreviewWheelZoomPercent(Number.NaN, 180)).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextPreviewZoomPercent", () => {
|
||||
it("steps preview zoom in and out", () => {
|
||||
expect(getNextPreviewZoomPercent("in", 100)).toBe(125);
|
||||
expect(getNextPreviewZoomPercent("out", 125)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampPreviewPan", () => {
|
||||
it("centers the preview when fit or zoomed out", () => {
|
||||
expect(
|
||||
clampPreviewPan({
|
||||
panX: 120,
|
||||
panY: -90,
|
||||
zoomPercent: 100,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 600,
|
||||
}),
|
||||
).toEqual({ panX: 0, panY: 0 });
|
||||
});
|
||||
|
||||
it("keeps pan within the zoomed preview bounds", () => {
|
||||
expect(
|
||||
clampPreviewPan({
|
||||
panX: 900,
|
||||
panY: -900,
|
||||
zoomPercent: 200,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 600,
|
||||
}),
|
||||
).toEqual({ panX: 400, panY: -300 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePreviewWheelZoom", () => {
|
||||
it("zooms in from center without shifting pan", () => {
|
||||
const next = resolvePreviewWheelZoom({
|
||||
state: DEFAULT_PREVIEW_ZOOM,
|
||||
deltaY: -5,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 600,
|
||||
});
|
||||
|
||||
expect(next.zoomPercent).toBeGreaterThan(100);
|
||||
expect(next.panX).toBe(0);
|
||||
expect(next.panY).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps pan when zooming out past minimum", () => {
|
||||
const next = resolvePreviewWheelZoom({
|
||||
state: { zoomPercent: 26, panX: 20, panY: 20 },
|
||||
deltaY: 500,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 600,
|
||||
});
|
||||
|
||||
expect(next.zoomPercent).toBeCloseTo(MIN_PREVIEW_ZOOM_PERCENT, 0);
|
||||
expect(next.panX).toBe(0);
|
||||
expect(next.panY).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface PreviewZoomState {
|
||||
zoomPercent: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
export const MIN_PREVIEW_ZOOM_PERCENT = 25;
|
||||
export const MAX_PREVIEW_ZOOM_PERCENT = 400;
|
||||
export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = {
|
||||
zoomPercent: 100,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
};
|
||||
|
||||
const ZOOM_SENSITIVITY = 0.007;
|
||||
const MAX_DELTA = 10;
|
||||
|
||||
export function toDomPrecision(value: number): number {
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
export function clampPreviewZoomPercent(percent: number): number {
|
||||
if (!Number.isFinite(percent)) return 100;
|
||||
return Math.min(MAX_PREVIEW_ZOOM_PERCENT, Math.max(MIN_PREVIEW_ZOOM_PERCENT, percent));
|
||||
}
|
||||
|
||||
export function getPreviewWheelZoomPercent(deltaY: number, currentZoomPercent: number): number {
|
||||
if (!Number.isFinite(deltaY)) return clampPreviewZoomPercent(currentZoomPercent);
|
||||
const clamped = Math.abs(deltaY) > MAX_DELTA ? MAX_DELTA * Math.sign(deltaY) : deltaY;
|
||||
const step = -clamped * ZOOM_SENSITIVITY;
|
||||
const current = clampPreviewZoomPercent(currentZoomPercent);
|
||||
return clampPreviewZoomPercent(current * Math.exp(step));
|
||||
}
|
||||
|
||||
export function getNextPreviewZoomPercent(
|
||||
direction: "in" | "out",
|
||||
currentZoomPercent: number,
|
||||
): number {
|
||||
const current = clampPreviewZoomPercent(currentZoomPercent);
|
||||
const multiplier = direction === "in" ? 1.25 : 0.8;
|
||||
return clampPreviewZoomPercent(current * multiplier);
|
||||
}
|
||||
|
||||
export function clampPreviewPan(input: {
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoomPercent: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}): Pick<PreviewZoomState, "panX" | "panY"> {
|
||||
const scale = clampPreviewZoomPercent(input.zoomPercent) / 100;
|
||||
if (scale <= 1) return { panX: 0, panY: 0 };
|
||||
|
||||
const maxPanX = ((scale - 1) * input.viewportWidth) / 2;
|
||||
const maxPanY = ((scale - 1) * input.viewportHeight) / 2;
|
||||
return {
|
||||
panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)),
|
||||
panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePreviewWheelZoom(input: {
|
||||
state: PreviewZoomState;
|
||||
deltaY: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}): PreviewZoomState {
|
||||
const nextZoomPercent = getPreviewWheelZoomPercent(
|
||||
input.deltaY,
|
||||
clampPreviewZoomPercent(input.state.zoomPercent),
|
||||
);
|
||||
const pan = clampPreviewPan({
|
||||
panX: input.state.panX,
|
||||
panY: input.state.panY,
|
||||
zoomPercent: nextZoomPercent,
|
||||
viewportWidth: input.viewportWidth,
|
||||
viewportHeight: input.viewportHeight,
|
||||
});
|
||||
|
||||
return {
|
||||
zoomPercent: nextZoomPercent,
|
||||
...pan,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||
|
||||
export function usePanelLayout() {
|
||||
const [leftWidth, setLeftWidth] = useState(240);
|
||||
const [rightWidth, setRightWidth] = useState(400);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(
|
||||
() => readStudioUiPreferences().leftCollapsed ?? false,
|
||||
);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(true);
|
||||
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
||||
const panelDragRef = useRef<{
|
||||
@@ -14,7 +17,10 @@ export function usePanelLayout() {
|
||||
} | null>(null);
|
||||
|
||||
const toggleLeftSidebar = useCallback(() => {
|
||||
setLeftCollapsed((collapsed) => !collapsed);
|
||||
setLeftCollapsed((collapsed) => {
|
||||
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
||||
return !collapsed;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePanelResizeStart = useCallback(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
|
||||
export interface TimelineElement {
|
||||
id: string;
|
||||
@@ -88,7 +89,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
timelineReady: false,
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
playbackRate: 1,
|
||||
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
|
||||
loopEnabled: false,
|
||||
zoomMode: "fit",
|
||||
manualZoomPercent: 100,
|
||||
@@ -98,7 +99,10 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
||||
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setPlaybackRate: (rate) => set({ playbackRate: rate }),
|
||||
setPlaybackRate: (rate) => {
|
||||
writeStudioUiPreferences({ playbackRate: rate });
|
||||
set({ playbackRate: rate });
|
||||
},
|
||||
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
||||
setZoomMode: (mode) => set({ zoomMode: mode }),
|
||||
setManualZoomPercent: (percent) =>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
|
||||
|
||||
function createStorage(): Storage {
|
||||
const entries = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return entries.size;
|
||||
},
|
||||
clear: () => entries.clear(),
|
||||
getItem: (key) => entries.get(key) ?? null,
|
||||
key: (index) => Array.from(entries.keys())[index] ?? null,
|
||||
removeItem: (key) => entries.delete(key),
|
||||
setItem: (key, value) => entries.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("studio UI preferences", () => {
|
||||
it("merges preference patches into one localStorage entry", () => {
|
||||
const storage = createStorage();
|
||||
|
||||
writeStudioUiPreferences({ timelineVisible: false }, storage);
|
||||
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
|
||||
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
|
||||
|
||||
expect(readStudioUiPreferences(storage)).toEqual({
|
||||
timelineVisible: false,
|
||||
playbackRate: 1.5,
|
||||
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores malformed stored values", () => {
|
||||
const storage = createStorage();
|
||||
storage.setItem(
|
||||
"hf-studio-ui-preferences",
|
||||
JSON.stringify({
|
||||
leftCollapsed: "yes",
|
||||
timelineVisible: true,
|
||||
playbackRate: Number.NaN,
|
||||
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readStudioUiPreferences(storage)).toEqual({
|
||||
timelineVisible: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface StoredPreviewZoomState {
|
||||
zoomPercent: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
export interface StudioUiPreferences {
|
||||
leftCollapsed?: boolean;
|
||||
timelineVisible?: boolean;
|
||||
playbackRate?: number;
|
||||
previewZoom?: StoredPreviewZoomState;
|
||||
}
|
||||
|
||||
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getBrowserStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStorage(storage: Storage | null): StudioUiPreferences {
|
||||
if (!storage) return {};
|
||||
try {
|
||||
const raw = storage.getItem(STUDIO_UI_PREFERENCES_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) return {};
|
||||
|
||||
const preferences: StudioUiPreferences = {};
|
||||
if (typeof parsed.leftCollapsed === "boolean") {
|
||||
preferences.leftCollapsed = parsed.leftCollapsed;
|
||||
}
|
||||
if (typeof parsed.timelineVisible === "boolean") {
|
||||
preferences.timelineVisible = parsed.timelineVisible;
|
||||
}
|
||||
if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
|
||||
preferences.playbackRate = parsed.playbackRate;
|
||||
}
|
||||
if (isRecord(parsed.previewZoom)) {
|
||||
const { zoomPercent, panX, panY } = parsed.previewZoom;
|
||||
if (
|
||||
typeof zoomPercent === "number" &&
|
||||
Number.isFinite(zoomPercent) &&
|
||||
typeof panX === "number" &&
|
||||
Number.isFinite(panX) &&
|
||||
typeof panY === "number" &&
|
||||
Number.isFinite(panY)
|
||||
) {
|
||||
preferences.previewZoom = { zoomPercent, panX, panY };
|
||||
}
|
||||
}
|
||||
return preferences;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readStudioUiPreferences(storage: Storage | null = getBrowserStorage()) {
|
||||
return readStorage(storage);
|
||||
}
|
||||
|
||||
export function writeStudioUiPreferences(
|
||||
patch: StudioUiPreferences,
|
||||
storage: Storage | null = getBrowserStorage(),
|
||||
) {
|
||||
if (!storage) return;
|
||||
try {
|
||||
const next = {
|
||||
...readStorage(storage),
|
||||
...patch,
|
||||
};
|
||||
storage.setItem(STUDIO_UI_PREFERENCES_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* localStorage may be unavailable or full */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user