fix(studio): canvas zoom improvements — zoom to cursor, reset button, border fix

- Zoom anchors to cursor position instead of always zooming toward center.
  The resolvePreviewWheelZoom function now accepts cursorX/cursorY (offset
  from viewport center) and uses the standard zoom-to-point formula to
  adjust pan so the content point under the cursor stays fixed.

- Add visible "Reset" button (bottom-right) showing current zoom % when
  not at fit zoom. Driven by settledZoom state that updates after the
  200ms settle debounce, so no re-renders during active zoom gestures.

- Fix border-expands-inward bug: scaleIframeToFit in the player now uses
  offsetWidth/offsetHeight instead of getBoundingClientRect. The latter
  returns values inflated by ancestor CSS zoom, causing double-scaling
  that made the iframe appear smaller than its container.

- Fix zoom HUD appearing during pan: split applyZoom (shows HUD) from
  applyPan (silent) so trackpad/middle-mouse panning no longer flashes
  the zoom percentage overlay.

- Fix stale closure performance regression: replace stageSize in effect
  dependency arrays with stageSizeRef pattern. The old deps caused wheel
  and pointer handlers to re-register on every viewport resize.

- Widen pan clamp range (Math.abs instead of Math.max(0,...)) so content
  can float within the viewport when zoomed below fit — required for
  zoom-to-cursor to work correctly at any zoom level.

Closes #900
This commit is contained in:
Miguel Ángel
2026-05-18 13:24:17 -04:00
parent 8163f38077
commit 0150a0a1b4
5 changed files with 161 additions and 50 deletions
+4 -3
View File
@@ -64,9 +64,10 @@ export function scaleIframeToFit(
compositionWidth: number,
compositionHeight: number,
): void {
const rect = playerElement.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const scale = Math.min(rect.width / compositionWidth, rect.height / compositionHeight);
const w = playerElement.offsetWidth;
const h = playerElement.offsetHeight;
if (w === 0 || h === 0) return;
const scale = Math.min(w / compositionWidth, h / compositionHeight);
iframe.style.width = `${compositionWidth}px`;
iframe.style.height = `${compositionHeight}px`;
iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
@@ -36,8 +36,18 @@ vi.mock("../../utils/studioUiPreferences", () => ({
writeStudioUiPreferences: () => {},
}));
let resizeCallbacks: Array<() => void> = [];
class MockResizeObserver {
observe() {}
private cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
}
observe() {
const fire = () => this.cb([], this as unknown as ResizeObserver);
resizeCallbacks.push(fire);
fire();
}
disconnect() {}
}
@@ -61,6 +71,7 @@ function setRect(node: Element, rect: { width: number; height: number }) {
}
function renderPreview() {
resizeCallbacks = [];
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
@@ -82,6 +93,9 @@ function renderPreview() {
expect(stage).toBeTruthy();
setRect(viewport, { width: 800, height: 600 });
act(() => {
for (const fire of resizeCallbacks) fire();
});
return {
host,
@@ -167,7 +181,7 @@ describe("NLEPreview", () => {
);
});
expect(view.stage.style.transform).toContain("translate(48px, 40px)");
expect(view.stage.style.transform).toContain("translate3d(56px, 40px, 0)");
view.cleanup();
});
@@ -189,7 +203,7 @@ describe("NLEPreview", () => {
);
});
expect(view.stage.style.transform).toContain("translate(30px, -24px)");
expect(view.stage.style.transform).toContain("translate3d(30px, -24px, 0)");
view.cleanup();
});
});
@@ -103,6 +103,7 @@ export const NLEPreview = memo(function NLEPreview({
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
const hudRef = useRef<HTMLDivElement>(null);
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -138,17 +139,20 @@ export const NLEPreview = memo(function NLEPreview({
return () => observer.disconnect();
}, [portrait]);
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 = `translate(${px}px, ${py}px) scale(${s})`;
stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
}, []);
const applyZoom = useCallback(
(next: PreviewZoomState) => {
const applyTransform = useCallback(
(next: PreviewZoomState, showHud: boolean) => {
const clamped: PreviewZoomState = {
zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
panX: Number.isFinite(next.panX) ? next.panX : 0,
@@ -156,7 +160,7 @@ export const NLEPreview = memo(function NLEPreview({
};
zoomRef.current = clamped;
if (!zoomingRef.current) {
if (showHud && !zoomingRef.current) {
zoomingRef.current = true;
const hud = hudRef.current;
if (hud) hud.style.opacity = "1";
@@ -169,19 +173,32 @@ export const NLEPreview = memo(function NLEPreview({
zoomingRef.current = false;
const final = zoomRef.current;
writeStudioUiPreferences({ previewZoom: final });
const hud = hudRef.current;
if (hud) {
hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`;
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
hudTimerRef.current = setTimeout(() => {
if (hudRef.current) hudRef.current.style.opacity = "0";
}, ZOOM_HUD_TIMEOUT_MS);
setSettledZoom(final);
if (showHud) {
const hud = hudRef.current;
if (hud) {
hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`;
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],
);
const applyZoom = useCallback(
(next: PreviewZoomState) => applyTransform(next, true),
[applyTransform],
);
const applyPan = useCallback(
(next: PreviewZoomState) => applyTransform(next, false),
[applyTransform],
);
if (refreshKey !== prevRefreshKeyRef.current) {
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
prevRefreshKeyRef.current = refreshKey;
@@ -228,13 +245,18 @@ export const NLEPreview = memo(function NLEPreview({
event.preventDefault();
event.stopPropagation();
const sz = stageSizeRef.current;
const cursorX = event.clientX - (rect.left + rect.width / 2);
const cursorY = event.clientY - (rect.top + rect.height / 2);
const next = resolvePreviewWheelZoom({
state: zoomRef.current,
deltaY: event.deltaY,
viewportWidth: rect.width,
viewportHeight: rect.height,
contentWidth: stageSize.width,
contentHeight: stageSize.height,
contentWidth: sz.width,
contentHeight: sz.height,
cursorX,
cursorY,
});
applyZoom(next);
return;
@@ -245,21 +267,22 @@ export const NLEPreview = memo(function NLEPreview({
event.preventDefault();
event.stopPropagation();
const sz = stageSizeRef.current;
const next = resolvePreviewWheelPan({
state: zoomRef.current,
deltaX: event.deltaX,
deltaY: event.deltaY,
viewportWidth: rect.width,
viewportHeight: rect.height,
contentWidth: stageSize.width,
contentHeight: stageSize.height,
contentWidth: sz.width,
contentHeight: sz.height,
});
applyZoom(next);
applyPan(next);
};
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
}, [applyZoom, stageSize.height, stageSize.width]);
}, [applyZoom, applyPan]);
useEffect(() => {
const viewport = viewportRef.current;
@@ -320,16 +343,17 @@ export const NLEPreview = memo(function NLEPreview({
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const rect = viewport.getBoundingClientRect();
const sz = stageSizeRef.current;
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,
contentWidth: stageSize.width,
contentHeight: stageSize.height,
contentWidth: sz.width,
contentHeight: sz.height,
});
applyZoom({ ...zoomRef.current, ...pan });
applyPan({ ...zoomRef.current, ...pan });
};
const finishDrag = (event: PointerEvent) => {
@@ -357,7 +381,7 @@ export const NLEPreview = memo(function NLEPreview({
document.removeEventListener("pointercancel", finishDrag, { capture: true });
document.removeEventListener("auxclick", handleAuxClick, { capture: true });
};
}, [applyZoom, stageSize.height, stageSize.width]);
}, [applyPan]);
const initial = zoomRef.current;
@@ -376,7 +400,7 @@ export const NLEPreview = memo(function NLEPreview({
style={{
width: `${stageSize.width}px`,
height: `${stageSize.height}px`,
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
transform: `translate3d(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px, 0) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
transformOrigin: "center center",
}}
data-testid="preview-zoom-stage"
@@ -417,6 +441,17 @@ export const NLEPreview = memo(function NLEPreview({
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
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-sm 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>
)}
</div>
</div>
);
@@ -99,21 +99,23 @@ describe("clampPreviewPan", () => {
});
});
it("allows overscroll even when only one axis overflows", () => {
expect(
clampPreviewPan({
panX: 120,
panY: -90,
zoomPercent: 107.25,
viewportWidth: 1352,
viewportHeight: 682,
contentWidth: 1184,
contentHeight: 666,
}),
).toEqual({
panX: PREVIEW_PAN_OVERSCROLL_PX,
panY: -(16.142499999999984 + PREVIEW_PAN_OVERSCROLL_PX),
it("allows pan range for under-fitting and overflowing axes", () => {
const result = clampPreviewPan({
panX: 120,
panY: -90,
zoomPercent: 107.25,
viewportWidth: 1352,
viewportHeight: 682,
contentWidth: 1184,
contentHeight: 666,
});
const scale = 1.0725;
const expectedMaxPanX = Math.abs(1184 * scale - 1352) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
const expectedMaxPanY = Math.abs(666 * scale - 682) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
expect(result.panX).toBeCloseTo(expectedMaxPanX, 4);
expect(result.panY).toBeCloseTo(-expectedMaxPanY, 4);
});
});
@@ -187,6 +189,53 @@ describe("resolvePreviewWheelZoom", () => {
expect(next.panX).toBe(20);
expect(next.panY).toBe(20);
});
it("zooms toward the cursor when cursorX/cursorY are provided", () => {
const next = resolvePreviewWheelZoom({
state: DEFAULT_PREVIEW_ZOOM,
deltaY: -5,
viewportWidth: 800,
viewportHeight: 600,
cursorX: 200,
cursorY: 100,
});
expect(next.zoomPercent).toBeGreaterThan(100);
expect(next.panX).toBeLessThan(0);
expect(next.panY).toBeLessThan(0);
});
it("keeps pan at zero when cursor is at viewport center", () => {
const next = resolvePreviewWheelZoom({
state: DEFAULT_PREVIEW_ZOOM,
deltaY: -5,
viewportWidth: 800,
viewportHeight: 600,
cursorX: 0,
cursorY: 0,
});
expect(next.zoomPercent).toBeGreaterThan(100);
expect(next.panX).toBe(0);
expect(next.panY).toBe(0);
});
it("scales pan proportionally when cursor is at center", () => {
const next = resolvePreviewWheelZoom({
state: { zoomPercent: 200, panX: 50, panY: 30 },
deltaY: -5,
viewportWidth: 800,
viewportHeight: 600,
contentWidth: 800,
contentHeight: 450,
cursorX: 0,
cursorY: 0,
});
const ratio = next.zoomPercent / 200;
expect(next.panX).toBeCloseTo(50 * ratio, 1);
expect(next.panY).toBeCloseTo(30 * ratio, 1);
});
});
describe("resolvePreviewWheelPan", () => {
@@ -69,9 +69,9 @@ export function clampPreviewPan(input: {
const contentWidth = input.contentWidth ?? input.viewportWidth;
const contentHeight = input.contentHeight ?? input.viewportHeight;
const maxPanX =
Math.max(0, (contentWidth * scale - input.viewportWidth) / 2) + PREVIEW_PAN_OVERSCROLL_PX;
Math.abs(contentWidth * scale - input.viewportWidth) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
const maxPanY =
Math.max(0, (contentHeight * scale - input.viewportHeight) / 2) + PREVIEW_PAN_OVERSCROLL_PX;
Math.abs(contentHeight * scale - input.viewportHeight) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
return {
panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)),
panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)),
@@ -85,14 +85,26 @@ export function resolvePreviewWheelZoom(input: {
viewportHeight: number;
contentWidth?: number;
contentHeight?: number;
cursorX?: number;
cursorY?: number;
}): PreviewZoomState {
const nextZoomPercent = getPreviewWheelZoomPercent(
input.deltaY,
clampPreviewZoomPercent(input.state.zoomPercent),
);
const oldZoom = clampPreviewZoomPercent(input.state.zoomPercent);
const nextZoomPercent = getPreviewWheelZoomPercent(input.deltaY, oldZoom);
const oldScale = oldZoom / 100;
const newScale = nextZoomPercent / 100;
let panX = input.state.panX;
let panY = input.state.panY;
if (input.cursorX !== undefined && input.cursorY !== undefined && Math.abs(oldScale) > 1e-6) {
const ratio = newScale / oldScale;
panX = input.cursorX * (1 - ratio) + panX * ratio;
panY = input.cursorY * (1 - ratio) + panY * ratio;
}
const pan = clampPreviewPan({
panX: input.state.panX,
panY: input.state.panY,
panX,
panY,
zoomPercent: nextZoomPercent,
viewportWidth: input.viewportWidth,
viewportHeight: input.viewportHeight,