Skip to content

Commit 0150a0a

Browse files
committed
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
1 parent 8163f38 commit 0150a0a

5 files changed

Lines changed: 161 additions & 50 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(56px, 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: 57 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,32 @@ 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(final);
177+
if (showHud) {
178+
const hud = hudRef.current;
179+
if (hud) {
180+
hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`;
181+
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
182+
hudTimerRef.current = setTimeout(() => {
183+
if (hudRef.current) hudRef.current.style.opacity = "0";
184+
}, ZOOM_HUD_TIMEOUT_MS);
185+
}
179186
}
180187
}, ZOOM_SETTLE_MS);
181188
},
182189
[writeTransform],
183190
);
184191

192+
const applyZoom = useCallback(
193+
(next: PreviewZoomState) => applyTransform(next, true),
194+
[applyTransform],
195+
);
196+
197+
const applyPan = useCallback(
198+
(next: PreviewZoomState) => applyTransform(next, false),
199+
[applyTransform],
200+
);
201+
185202
if (refreshKey !== prevRefreshKeyRef.current) {
186203
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
187204
prevRefreshKeyRef.current = refreshKey;
@@ -228,13 +245,18 @@ export const NLEPreview = memo(function NLEPreview({
228245
event.preventDefault();
229246
event.stopPropagation();
230247

248+
const sz = stageSizeRef.current;
249+
const cursorX = event.clientX - (rect.left + rect.width / 2);
250+
const cursorY = event.clientY - (rect.top + rect.height / 2);
231251
const next = resolvePreviewWheelZoom({
232252
state: zoomRef.current,
233253
deltaY: event.deltaY,
234254
viewportWidth: rect.width,
235255
viewportHeight: rect.height,
236-
contentWidth: stageSize.width,
237-
contentHeight: stageSize.height,
256+
contentWidth: sz.width,
257+
contentHeight: sz.height,
258+
cursorX,
259+
cursorY,
238260
});
239261
applyZoom(next);
240262
return;
@@ -245,21 +267,22 @@ export const NLEPreview = memo(function NLEPreview({
245267
event.preventDefault();
246268
event.stopPropagation();
247269

270+
const sz = stageSizeRef.current;
248271
const next = resolvePreviewWheelPan({
249272
state: zoomRef.current,
250273
deltaX: event.deltaX,
251274
deltaY: event.deltaY,
252275
viewportWidth: rect.width,
253276
viewportHeight: rect.height,
254-
contentWidth: stageSize.width,
255-
contentHeight: stageSize.height,
277+
contentWidth: sz.width,
278+
contentHeight: sz.height,
256279
});
257-
applyZoom(next);
280+
applyPan(next);
258281
};
259282

260283
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
261284
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
262-
}, [applyZoom, stageSize.height, stageSize.width]);
285+
}, [applyZoom, applyPan]);
263286

264287
useEffect(() => {
265288
const viewport = viewportRef.current;
@@ -320,16 +343,17 @@ export const NLEPreview = memo(function NLEPreview({
320343
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
321344
event.preventDefault();
322345
const rect = viewport.getBoundingClientRect();
346+
const sz = stageSizeRef.current;
323347
const pan = clampPreviewPan({
324348
panX: drag.originX + event.clientX - drag.startX,
325349
panY: drag.originY + event.clientY - drag.startY,
326350
zoomPercent: zoomRef.current.zoomPercent,
327351
viewportWidth: rect.width,
328352
viewportHeight: rect.height,
329-
contentWidth: stageSize.width,
330-
contentHeight: stageSize.height,
353+
contentWidth: sz.width,
354+
contentHeight: sz.height,
331355
});
332-
applyZoom({ ...zoomRef.current, ...pan });
356+
applyPan({ ...zoomRef.current, ...pan });
333357
};
334358

335359
const finishDrag = (event: PointerEvent) => {
@@ -357,7 +381,7 @@ export const NLEPreview = memo(function NLEPreview({
357381
document.removeEventListener("pointercancel", finishDrag, { capture: true });
358382
document.removeEventListener("auxclick", handleAuxClick, { capture: true });
359383
};
360-
}, [applyZoom, stageSize.height, stageSize.width]);
384+
}, [applyPan]);
361385

362386
const initial = zoomRef.current;
363387

@@ -376,7 +400,7 @@ export const NLEPreview = memo(function NLEPreview({
376400
style={{
377401
width: `${stageSize.width}px`,
378402
height: `${stageSize.height}px`,
379-
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
403+
transform: `translate3d(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px, 0) scale(${toDomPrecision(initial.zoomPercent / 100)})`,
380404
transformOrigin: "center center",
381405
}}
382406
data-testid="preview-zoom-stage"
@@ -417,6 +441,17 @@ export const NLEPreview = memo(function NLEPreview({
417441
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
418442
aria-live="polite"
419443
/>
444+
{!isPreviewAtFit(settledZoom) && (
445+
<button
446+
type="button"
447+
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"
448+
onClick={() => applyZoom(DEFAULT_PREVIEW_ZOOM)}
449+
aria-label="Reset zoom to fit"
450+
data-testid="preview-reset-zoom"
451+
>
452+
{Math.round(settledZoom.zoomPercent)}% — Reset
453+
</button>
454+
)}
420455
</div>
421456
</div>
422457
);

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

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,21 +99,23 @@ describe("clampPreviewPan", () => {
9999
});
100100
});
101101

102-
it("allows overscroll even when only one axis overflows", () => {
103-
expect(
104-
clampPreviewPan({
105-
panX: 120,
106-
panY: -90,
107-
zoomPercent: 107.25,
108-
viewportWidth: 1352,
109-
viewportHeight: 682,
110-
contentWidth: 1184,
111-
contentHeight: 666,
112-
}),
113-
).toEqual({
114-
panX: PREVIEW_PAN_OVERSCROLL_PX,
115-
panY: -(16.142499999999984 + PREVIEW_PAN_OVERSCROLL_PX),
102+
it("allows pan range for under-fitting and overflowing axes", () => {
103+
const result = clampPreviewPan({
104+
panX: 120,
105+
panY: -90,
106+
zoomPercent: 107.25,
107+
viewportWidth: 1352,
108+
viewportHeight: 682,
109+
contentWidth: 1184,
110+
contentHeight: 666,
116111
});
112+
113+
const scale = 1.0725;
114+
const expectedMaxPanX = Math.abs(1184 * scale - 1352) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
115+
const expectedMaxPanY = Math.abs(666 * scale - 682) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
116+
117+
expect(result.panX).toBeCloseTo(expectedMaxPanX, 4);
118+
expect(result.panY).toBeCloseTo(-expectedMaxPanY, 4);
117119
});
118120
});
119121

@@ -187,6 +189,53 @@ describe("resolvePreviewWheelZoom", () => {
187189
expect(next.panX).toBe(20);
188190
expect(next.panY).toBe(20);
189191
});
192+
193+
it("zooms toward the cursor when cursorX/cursorY are provided", () => {
194+
const next = resolvePreviewWheelZoom({
195+
state: DEFAULT_PREVIEW_ZOOM,
196+
deltaY: -5,
197+
viewportWidth: 800,
198+
viewportHeight: 600,
199+
cursorX: 200,
200+
cursorY: 100,
201+
});
202+
203+
expect(next.zoomPercent).toBeGreaterThan(100);
204+
expect(next.panX).toBeLessThan(0);
205+
expect(next.panY).toBeLessThan(0);
206+
});
207+
208+
it("keeps pan at zero when cursor is at viewport center", () => {
209+
const next = resolvePreviewWheelZoom({
210+
state: DEFAULT_PREVIEW_ZOOM,
211+
deltaY: -5,
212+
viewportWidth: 800,
213+
viewportHeight: 600,
214+
cursorX: 0,
215+
cursorY: 0,
216+
});
217+
218+
expect(next.zoomPercent).toBeGreaterThan(100);
219+
expect(next.panX).toBe(0);
220+
expect(next.panY).toBe(0);
221+
});
222+
223+
it("scales pan proportionally when cursor is at center", () => {
224+
const next = resolvePreviewWheelZoom({
225+
state: { zoomPercent: 200, panX: 50, panY: 30 },
226+
deltaY: -5,
227+
viewportWidth: 800,
228+
viewportHeight: 600,
229+
contentWidth: 800,
230+
contentHeight: 450,
231+
cursorX: 0,
232+
cursorY: 0,
233+
});
234+
235+
const ratio = next.zoomPercent / 200;
236+
expect(next.panX).toBeCloseTo(50 * ratio, 1);
237+
expect(next.panY).toBeCloseTo(30 * ratio, 1);
238+
});
190239
});
191240

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

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ export function clampPreviewPan(input: {
6969
const contentWidth = input.contentWidth ?? input.viewportWidth;
7070
const contentHeight = input.contentHeight ?? input.viewportHeight;
7171
const maxPanX =
72-
Math.max(0, (contentWidth * scale - input.viewportWidth) / 2) + PREVIEW_PAN_OVERSCROLL_PX;
72+
Math.abs(contentWidth * scale - input.viewportWidth) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
7373
const maxPanY =
74-
Math.max(0, (contentHeight * scale - input.viewportHeight) / 2) + PREVIEW_PAN_OVERSCROLL_PX;
74+
Math.abs(contentHeight * scale - input.viewportHeight) / 2 + PREVIEW_PAN_OVERSCROLL_PX;
7575
return {
7676
panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)),
7777
panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)),
@@ -85,14 +85,26 @@ export function resolvePreviewWheelZoom(input: {
8585
viewportHeight: number;
8686
contentWidth?: number;
8787
contentHeight?: number;
88+
cursorX?: number;
89+
cursorY?: number;
8890
}): PreviewZoomState {
89-
const nextZoomPercent = getPreviewWheelZoomPercent(
90-
input.deltaY,
91-
clampPreviewZoomPercent(input.state.zoomPercent),
92-
);
91+
const oldZoom = clampPreviewZoomPercent(input.state.zoomPercent);
92+
const nextZoomPercent = getPreviewWheelZoomPercent(input.deltaY, oldZoom);
93+
const oldScale = oldZoom / 100;
94+
const newScale = nextZoomPercent / 100;
95+
96+
let panX = input.state.panX;
97+
let panY = input.state.panY;
98+
99+
if (input.cursorX !== undefined && input.cursorY !== undefined && Math.abs(oldScale) > 1e-6) {
100+
const ratio = newScale / oldScale;
101+
panX = input.cursorX * (1 - ratio) + panX * ratio;
102+
panY = input.cursorY * (1 - ratio) + panY * ratio;
103+
}
104+
93105
const pan = clampPreviewPan({
94-
panX: input.state.panX,
95-
panY: input.state.panY,
106+
panX,
107+
panY,
96108
zoomPercent: nextZoomPercent,
97109
viewportWidth: input.viewportWidth,
98110
viewportHeight: input.viewportHeight,

0 commit comments

Comments
 (0)