fix(studio): address PR review — split pan clamp, pin invariant, drop dead guard

- Split pan clamping: clampPreviewPan (drag/wheel-pan) stays narrow
  (Math.max(0,...) — content pins to center when smaller than viewport).
  New clampPreviewPanForZoom (Math.abs) gives the wide range only to
  cursor-anchored zoom, preventing middle-mouse drag from pushing content
  off-screen at low zoom levels.

- Pin transform-origin invariant: comment on the stage div noting that
  resolvePreviewWheelZoom cursor math depends on center-center pivot.
  New test verifies a non-center cursor keeps the same content-space
  point fixed across a zoom step.

- Remove dead Math.abs(oldScale) > 1e-6 guard — oldScale >= 0.25 always
  (clampPreviewZoomPercent floors at MIN_PREVIEW_ZOOM_PERCENT = 25).

- Skip setSettledZoom re-render when the value didn't change — uses a
  functional updater that returns the previous state object when all
  three fields match, avoiding a React re-render cascade through Player.
This commit is contained in:
Miguel Ángel
2026-05-18 14:04:53 -04:00
parent 0150a0a1b4
commit e25bcf989a
4 changed files with 110 additions and 28 deletions
@@ -181,7 +181,7 @@ describe("NLEPreview", () => {
);
});
expect(view.stage.style.transform).toContain("translate3d(56px, 40px, 0)");
expect(view.stage.style.transform).toContain("translate3d(48px, 40px, 0)");
view.cleanup();
});
@@ -173,7 +173,13 @@ export const NLEPreview = memo(function NLEPreview({
zoomingRef.current = false;
const final = zoomRef.current;
writeStudioUiPreferences({ previewZoom: final });
setSettledZoom(final);
setSettledZoom((prev) =>
prev.zoomPercent === final.zoomPercent &&
prev.panX === final.panX &&
prev.panY === final.panY
? prev
: final,
);
if (showHud) {
const hud = hudRef.current;
if (hud) {
@@ -401,6 +407,7 @@ export const NLEPreview = memo(function NLEPreview({
width: `${stageSize.width}px`,
height: `${stageSize.height}px`,
transform: `translate3d(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px, 0) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
// resolvePreviewWheelZoom cursor math assumes center-center pivot
transformOrigin: "center center",
}}
data-testid="preview-zoom-stage"
@@ -99,23 +99,21 @@ describe("clampPreviewPan", () => {
});
});
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,
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),
});
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);
});
});
@@ -236,6 +234,63 @@ describe("resolvePreviewWheelZoom", () => {
expect(next.panX).toBeCloseTo(50 * ratio, 1);
expect(next.panY).toBeCloseTo(30 * ratio, 1);
});
it("keeps the content point under a non-center cursor fixed after zoom", () => {
const cursorX = 150;
const cursorY = -80;
const state: PreviewZoomState = { zoomPercent: 150, panX: 20, panY: -10 };
const oldScale = state.zoomPercent / 100;
const next = resolvePreviewWheelZoom({
state,
deltaY: -5,
viewportWidth: 800,
viewportHeight: 600,
contentWidth: 800,
contentHeight: 450,
cursorX,
cursorY,
});
const newScale = next.zoomPercent / 100;
const contentXBefore = (cursorX - state.panX) / oldScale;
const contentXAfter = (cursorX - next.panX) / newScale;
const contentYBefore = (cursorY - state.panY) / oldScale;
const contentYAfter = (cursorY - next.panY) / newScale;
expect(contentXAfter).toBeCloseTo(contentXBefore, 6);
expect(contentYAfter).toBeCloseTo(contentYBefore, 6);
});
it("uses wider pan range for cursor zoom than manual drag", () => {
let state: PreviewZoomState = { zoomPercent: 100, panX: 0, panY: 0 };
for (let i = 0; i < 40; i++) {
state = resolvePreviewWheelZoom({
state,
deltaY: 5,
viewportWidth: 800,
viewportHeight: 600,
contentWidth: 800,
contentHeight: 450,
cursorX: -300,
cursorY: 0,
});
}
expect(state.zoomPercent).toBeLessThan(100);
const dragClamped = clampPreviewPan({
panX: state.panX,
panY: state.panY,
zoomPercent: state.zoomPercent,
viewportWidth: 800,
viewportHeight: 600,
contentWidth: 800,
contentHeight: 450,
});
expect(Math.abs(state.panX)).toBeGreaterThan(Math.abs(dragClamped.panX));
});
});
describe("resolvePreviewWheelPan", () => {
@@ -69,15 +69,33 @@ export function clampPreviewPan(input: {
const contentWidth = input.contentWidth ?? input.viewportWidth;
const contentHeight = input.contentHeight ?? input.viewportHeight;
const maxPanX =
Math.abs(contentWidth * scale - input.viewportWidth) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
Math.max(0, (contentWidth * scale - input.viewportWidth) / 2) + PREVIEW_PAN_OVERSCROLL_PX;
const maxPanY =
Math.abs(contentHeight * scale - input.viewportHeight) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
Math.max(0, (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)),
};
}
function clampPreviewPanForZoom(
panX: number,
panY: number,
zoomPercent: number,
viewportWidth: number,
viewportHeight: number,
contentWidth: number,
contentHeight: number,
): Pick<PreviewZoomState, "panX" | "panY"> {
const scale = clampPreviewZoomPercent(zoomPercent) / 100;
const maxPanX = Math.abs(contentWidth * scale - viewportWidth) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
const maxPanY = Math.abs(contentHeight * scale - viewportHeight) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
return {
panX: Math.min(maxPanX, Math.max(-maxPanX, panX)),
panY: Math.min(maxPanY, Math.max(-maxPanY, panY)),
};
}
export function resolvePreviewWheelZoom(input: {
state: PreviewZoomState;
deltaY: number;
@@ -96,21 +114,23 @@ export function resolvePreviewWheelZoom(input: {
let panX = input.state.panX;
let panY = input.state.panY;
if (input.cursorX !== undefined && input.cursorY !== undefined && Math.abs(oldScale) > 1e-6) {
if (input.cursorX !== undefined && input.cursorY !== undefined) {
const ratio = newScale / oldScale;
panX = input.cursorX * (1 - ratio) + panX * ratio;
panY = input.cursorY * (1 - ratio) + panY * ratio;
}
const pan = clampPreviewPan({
const cw = input.contentWidth ?? input.viewportWidth;
const ch = input.contentHeight ?? input.viewportHeight;
const pan = clampPreviewPanForZoom(
panX,
panY,
zoomPercent: nextZoomPercent,
viewportWidth: input.viewportWidth,
viewportHeight: input.viewportHeight,
contentWidth: input.contentWidth,
contentHeight: input.contentHeight,
});
nextZoomPercent,
input.viewportWidth,
input.viewportHeight,
cw,
ch,
);
return {
zoomPercent: nextZoomPercent,