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:
Miguel Ángel
2026-05-13 09:29:01 +02:00
committed by GitHub
parent 10fb968583
commit 2e6022972b
10 changed files with 614 additions and 39 deletions
+10 -2
View File
@@ -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(
+250 -30
View File
@@ -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,
};
}
+8 -2
View File
@@ -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 */
}
}