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