Skip to content

Commit 4c29b43

Browse files
Merge pull request #935 from heygen-com/worktree-fix+canvas-zoom-improvements
fix(studio): canvas zoom improvements — zoom to cursor, reset button, border fix
2 parents 3af7f1c + e25bcf9 commit 4c29b43

5 files changed

Lines changed: 233 additions & 40 deletions

File tree

packages/player/src/iframe-dom.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ export function scaleIframeToFit(
6464
compositionWidth: number,
6565
compositionHeight: number,
6666
): void {
67-
const rect = playerElement.getBoundingClientRect();
68-
if (rect.width === 0 || rect.height === 0) return;
69-
const scale = Math.min(rect.width / compositionWidth, rect.height / compositionHeight);
67+
const w = playerElement.offsetWidth;
68+
const h = playerElement.offsetHeight;
69+
if (w === 0 || h === 0) return;
70+
const scale = Math.min(w / compositionWidth, h / compositionHeight);
7071
iframe.style.width = `${compositionWidth}px`;
7172
iframe.style.height = `${compositionHeight}px`;
7273
iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;

packages/studio/src/components/nle/NLEPreview.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,18 @@ vi.mock("../../utils/studioUiPreferences", () => ({
3636
writeStudioUiPreferences: () => {},
3737
}));
3838

39+
let resizeCallbacks: Array<() => void> = [];
40+
3941
class MockResizeObserver {
40-
observe() {}
42+
private cb: ResizeObserverCallback;
43+
constructor(cb: ResizeObserverCallback) {
44+
this.cb = cb;
45+
}
46+
observe() {
47+
const fire = () => this.cb([], this as unknown as ResizeObserver);
48+
resizeCallbacks.push(fire);
49+
fire();
50+
}
4151
disconnect() {}
4252
}
4353

@@ -61,6 +71,7 @@ function setRect(node: Element, rect: { width: number; height: number }) {
6171
}
6272

6373
function renderPreview() {
74+
resizeCallbacks = [];
6475
const host = document.createElement("div");
6576
document.body.append(host);
6677
const root = createRoot(host);
@@ -82,6 +93,9 @@ function renderPreview() {
8293
expect(stage).toBeTruthy();
8394

8495
setRect(viewport, { width: 800, height: 600 });
96+
act(() => {
97+
for (const fire of resizeCallbacks) fire();
98+
});
8599

86100
return {
87101
host,
@@ -167,7 +181,7 @@ describe("NLEPreview", () => {
167181
);
168182
});
169183

170-
expect(view.stage.style.transform).toContain("translate(48px, 40px)");
184+
expect(view.stage.style.transform).toContain("translate3d(48px, 40px, 0)");
171185
view.cleanup();
172186
});
173187

@@ -189,7 +203,7 @@ describe("NLEPreview", () => {
189203
);
190204
});
191205

192-
expect(view.stage.style.transform).toContain("translate(30px, -24px)");
206+
expect(view.stage.style.transform).toContain("translate3d(30px, -24px, 0)");
193207
view.cleanup();
194208
});
195209
});

packages/studio/src/components/nle/NLEPreview.tsx

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export const NLEPreview = memo(function NLEPreview({
103103
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
104104

105105
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
106+
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
106107
const hudRef = useRef<HTMLDivElement>(null);
107108
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
108109
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -138,25 +139,28 @@ export const NLEPreview = memo(function NLEPreview({
138139
return () => observer.disconnect();
139140
}, [portrait]);
140141

142+
const stageSizeRef = useRef(stageSize);
143+
stageSizeRef.current = stageSize;
144+
141145
const writeTransform = useCallback((state: PreviewZoomState) => {
142146
const stage = stageRef.current;
143147
if (!stage) return;
144148
const s = toDomPrecision(state.zoomPercent / 100);
145149
const px = toDomPrecision(state.panX);
146150
const py = toDomPrecision(state.panY);
147-
stage.style.transform = `translate(${px}px, ${py}px) scale(${s})`;
151+
stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
148152
}, []);
149153

150-
const applyZoom = useCallback(
151-
(next: PreviewZoomState) => {
154+
const applyTransform = useCallback(
155+
(next: PreviewZoomState, showHud: boolean) => {
152156
const clamped: PreviewZoomState = {
153157
zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
154158
panX: Number.isFinite(next.panX) ? next.panX : 0,
155159
panY: Number.isFinite(next.panY) ? next.panY : 0,
156160
};
157161
zoomRef.current = clamped;
158162

159-
if (!zoomingRef.current) {
163+
if (showHud && !zoomingRef.current) {
160164
zoomingRef.current = true;
161165
const hud = hudRef.current;
162166
if (hud) hud.style.opacity = "1";
@@ -169,19 +173,38 @@ export const NLEPreview = memo(function NLEPreview({
169173
zoomingRef.current = false;
170174
const final = zoomRef.current;
171175
writeStudioUiPreferences({ previewZoom: final });
172-
const hud = hudRef.current;
173-
if (hud) {
174-
hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`;
175-
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
176-
hudTimerRef.current = setTimeout(() => {
177-
if (hudRef.current) hudRef.current.style.opacity = "0";
178-
}, ZOOM_HUD_TIMEOUT_MS);
176+
setSettledZoom((prev) =>
177+
prev.zoomPercent === final.zoomPercent &&
178+
prev.panX === final.panX &&
179+
prev.panY === final.panY
180+
? prev
181+
: final,
182+
);
183+
if (showHud) {
184+
const hud = hudRef.current;
185+
if (hud) {
186+
hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`;
187+
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
188+
hudTimerRef.current = setTimeout(() => {
189+
if (hudRef.current) hudRef.current.style.opacity = "0";
190+
}, ZOOM_HUD_TIMEOUT_MS);
191+
}
179192
}
180193
}, ZOOM_SETTLE_MS);
181194
},
182195
[writeTransform],
183196
);
184197

198+
const applyZoom = useCallback(
199+
(next: PreviewZoomState) => applyTransform(next, true),
200+
[applyTransform],
201+
);
202+
203+
const applyPan = useCallback(
204+
(next: PreviewZoomState) => applyTransform(next, false),
205+
[applyTransform],
206+
);
207+
185208
if (refreshKey !== prevRefreshKeyRef.current) {
186209
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
187210
prevRefreshKeyRef.current = refreshKey;
@@ -228,13 +251,18 @@ export const NLEPreview = memo(function NLEPreview({
228251
event.preventDefault();
229252
event.stopPropagation();
230253

254+
const sz = stageSizeRef.current;
255+
const cursorX = event.clientX - (rect.left + rect.width / 2);
256+
const cursorY = event.clientY - (rect.top + rect.height / 2);
231257
const next = resolvePreviewWheelZoom({
232258
state: zoomRef.current,
233259
deltaY: event.deltaY,
234260
viewportWidth: rect.width,
235261
viewportHeight: rect.height,
236-
contentWidth: stageSize.width,
237-
contentHeight: stageSize.height,
262+
contentWidth: sz.width,
263+
contentHeight: sz.height,
264+
cursorX,
265+
cursorY,
238266
});
239267
applyZoom(next);
240268
return;
@@ -245,21 +273,22 @@ export const NLEPreview = memo(function NLEPreview({
245273
event.preventDefault();
246274
event.stopPropagation();
247275

276+
const sz = stageSizeRef.current;
248277
const next = resolvePreviewWheelPan({
249278
state: zoomRef.current,
250279
deltaX: event.deltaX,
251280
deltaY: event.deltaY,
252281
viewportWidth: rect.width,
253282
viewportHeight: rect.height,
254-
contentWidth: stageSize.width,
255-
contentHeight: stageSize.height,
283+
contentWidth: sz.width,
284+
contentHeight: sz.height,
256285
});
257-
applyZoom(next);
286+
applyPan(next);
258287
};
259288

260289
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
261290
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
262-
}, [applyZoom, stageSize.height, stageSize.width]);
291+
}, [applyZoom, applyPan]);
263292

264293
useEffect(() => {
265294
const viewport = viewportRef.current;
@@ -320,16 +349,17 @@ export const NLEPreview = memo(function NLEPreview({
320349
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
321350
event.preventDefault();
322351
const rect = viewport.getBoundingClientRect();
352+
const sz = stageSizeRef.current;
323353
const pan = clampPreviewPan({
324354
panX: drag.originX + event.clientX - drag.startX,
325355
panY: drag.originY + event.clientY - drag.startY,
326356
zoomPercent: zoomRef.current.zoomPercent,
327357
viewportWidth: rect.width,
328358
viewportHeight: rect.height,
329-
contentWidth: stageSize.width,
330-
contentHeight: stageSize.height,
359+
contentWidth: sz.width,
360+
contentHeight: sz.height,
331361
});
332-
applyZoom({ ...zoomRef.current, ...pan });
362+
applyPan({ ...zoomRef.current, ...pan });
333363
};
334364

335365
const finishDrag = (event: PointerEvent) => {
@@ -357,7 +387,7 @@ export const NLEPreview = memo(function NLEPreview({
357387
document.removeEventListener("pointercancel", finishDrag, { capture: true });
358388
document.removeEventListener("auxclick", handleAuxClick, { capture: true });
359389
};
360-
}, [applyZoom, stageSize.height, stageSize.width]);
390+
}, [applyPan]);
361391

362392
const initial = zoomRef.current;
363393

@@ -376,7 +406,8 @@ export const NLEPreview = memo(function NLEPreview({
376406
style={{
377407
width: `${stageSize.width}px`,
378408
height: `${stageSize.height}px`,
379-
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
409+
transform: `translate3d(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px, 0) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
410+
// resolvePreviewWheelZoom cursor math assumes center-center pivot
380411
transformOrigin: "center center",
381412
}}
382413
data-testid="preview-zoom-stage"
@@ -417,6 +448,17 @@ export const NLEPreview = memo(function NLEPreview({
417448
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
418449
aria-live="polite"
419450
/>
451+
{!isPreviewAtFit(settledZoom) && (
452+
<button
453+
type="button"
454+
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"
455+
onClick={() => applyZoom(DEFAULT_PREVIEW_ZOOM)}
456+
aria-label="Reset zoom to fit"
457+
data-testid="preview-reset-zoom"
458+
>
459+
{Math.round(settledZoom.zoomPercent)}% — Reset
460+
</button>
461+
)}
420462
</div>
421463
</div>
422464
);

packages/studio/src/components/nle/previewZoom.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,110 @@ describe("resolvePreviewWheelZoom", () => {
187187
expect(next.panX).toBe(20);
188188
expect(next.panY).toBe(20);
189189
});
190+
191+
it("zooms toward the cursor when cursorX/cursorY are provided", () => {
192+
const next = resolvePreviewWheelZoom({
193+
state: DEFAULT_PREVIEW_ZOOM,
194+
deltaY: -5,
195+
viewportWidth: 800,
196+
viewportHeight: 600,
197+
cursorX: 200,
198+
cursorY: 100,
199+
});
200+
201+
expect(next.zoomPercent).toBeGreaterThan(100);
202+
expect(next.panX).toBeLessThan(0);
203+
expect(next.panY).toBeLessThan(0);
204+
});
205+
206+
it("keeps pan at zero when cursor is at viewport center", () => {
207+
const next = resolvePreviewWheelZoom({
208+
state: DEFAULT_PREVIEW_ZOOM,
209+
deltaY: -5,
210+
viewportWidth: 800,
211+
viewportHeight: 600,
212+
cursorX: 0,
213+
cursorY: 0,
214+
});
215+
216+
expect(next.zoomPercent).toBeGreaterThan(100);
217+
expect(next.panX).toBe(0);
218+
expect(next.panY).toBe(0);
219+
});
220+
221+
it("scales pan proportionally when cursor is at center", () => {
222+
const next = resolvePreviewWheelZoom({
223+
state: { zoomPercent: 200, panX: 50, panY: 30 },
224+
deltaY: -5,
225+
viewportWidth: 800,
226+
viewportHeight: 600,
227+
contentWidth: 800,
228+
contentHeight: 450,
229+
cursorX: 0,
230+
cursorY: 0,
231+
});
232+
233+
const ratio = next.zoomPercent / 200;
234+
expect(next.panX).toBeCloseTo(50 * ratio, 1);
235+
expect(next.panY).toBeCloseTo(30 * ratio, 1);
236+
});
237+
238+
it("keeps the content point under a non-center cursor fixed after zoom", () => {
239+
const cursorX = 150;
240+
const cursorY = -80;
241+
const state: PreviewZoomState = { zoomPercent: 150, panX: 20, panY: -10 };
242+
const oldScale = state.zoomPercent / 100;
243+
244+
const next = resolvePreviewWheelZoom({
245+
state,
246+
deltaY: -5,
247+
viewportWidth: 800,
248+
viewportHeight: 600,
249+
contentWidth: 800,
250+
contentHeight: 450,
251+
cursorX,
252+
cursorY,
253+
});
254+
255+
const newScale = next.zoomPercent / 100;
256+
const contentXBefore = (cursorX - state.panX) / oldScale;
257+
const contentXAfter = (cursorX - next.panX) / newScale;
258+
const contentYBefore = (cursorY - state.panY) / oldScale;
259+
const contentYAfter = (cursorY - next.panY) / newScale;
260+
261+
expect(contentXAfter).toBeCloseTo(contentXBefore, 6);
262+
expect(contentYAfter).toBeCloseTo(contentYBefore, 6);
263+
});
264+
265+
it("uses wider pan range for cursor zoom than manual drag", () => {
266+
let state: PreviewZoomState = { zoomPercent: 100, panX: 0, panY: 0 };
267+
for (let i = 0; i < 40; i++) {
268+
state = resolvePreviewWheelZoom({
269+
state,
270+
deltaY: 5,
271+
viewportWidth: 800,
272+
viewportHeight: 600,
273+
contentWidth: 800,
274+
contentHeight: 450,
275+
cursorX: -300,
276+
cursorY: 0,
277+
});
278+
}
279+
280+
expect(state.zoomPercent).toBeLessThan(100);
281+
282+
const dragClamped = clampPreviewPan({
283+
panX: state.panX,
284+
panY: state.panY,
285+
zoomPercent: state.zoomPercent,
286+
viewportWidth: 800,
287+
viewportHeight: 600,
288+
contentWidth: 800,
289+
contentHeight: 450,
290+
});
291+
292+
expect(Math.abs(state.panX)).toBeGreaterThan(Math.abs(dragClamped.panX));
293+
});
190294
});
191295

192296
describe("resolvePreviewWheelPan", () => {

0 commit comments

Comments
 (0)