mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user