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
+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(