diff --git a/apps/code/src/main/zoom.test.ts b/apps/code/src/main/zoom.test.ts index b4ddb6d79e..a7b5792377 100644 --- a/apps/code/src/main/zoom.test.ts +++ b/apps/code/src/main/zoom.test.ts @@ -22,10 +22,6 @@ import { adjustWindowZoom, restoreWindowZoom, setupWindowZoom } from "./zoom"; class FakeWebContents extends EventEmitter { public zoomLevel = 0; - public getZoomLevel(): number { - return this.zoomLevel; - } - public setZoomLevel(level: number): void { this.zoomLevel = level; } @@ -89,23 +85,70 @@ describe("window zoom", () => { expect(window.webContents.zoomLevel).toBe(0.5); }); - it("persists wheel zoom after Chromium updates its level", () => { + it.each([ + ["in", 1], + ["out", 0], + ] as const)( + "applies wheel zoom %s from the persisted level", + (direction, expected) => { + const window = createWindow(); + setupWindowZoom(window); + const event = { preventDefault: vi.fn() }; + + window.webContents.emit("zoom-changed", event, direction); + vi.runAllTimers(); + + expect({ + prevented: event.preventDefault.mock.calls.length, + zoomLevel: window.webContents.zoomLevel, + saved: store.save.mock.calls, + }).toEqual({ + prevented: 1, + zoomLevel: expected, + saved: [[expected]], + }); + }, + ); + + it("keeps wheel zoom after resizing", () => { const window = createWindow(); setupWindowZoom(window); - window.webContents.emit("zoom-changed"); - window.webContents.zoomLevel = 1.5; + window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in"); + window.emit("resized"); vi.runAllTimers(); - expect(store.save).toHaveBeenCalledWith(1.5); + expect({ + zoomLevel: window.webContents.zoomLevel, + saved: store.save.mock.calls, + }).toEqual({ + zoomLevel: 1, + saved: [[1]], + }); }); - it("waits for native zoom before applying a menu adjustment", () => { + it("keeps wheel zoom after a renderer reload", () => { const window = createWindow(); setupWindowZoom(window); - window.webContents.emit("zoom-changed"); - window.webContents.zoomLevel = 1.5; + window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in"); + window.webContents.emit("did-finish-load"); + vi.runAllTimers(); + + expect({ + zoomLevel: window.webContents.zoomLevel, + saved: store.save.mock.calls, + }).toEqual({ + zoomLevel: 1, + saved: [[1]], + }); + }); + + it("serializes wheel and menu zoom changes", () => { + const window = createWindow(); + setupWindowZoom(window); + + window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in"); adjustWindowZoom(window, 0.5); vi.runAllTimers(); @@ -113,26 +156,29 @@ describe("window zoom", () => { zoomLevel: window.webContents.zoomLevel, saved: store.save.mock.calls, }).toEqual({ - zoomLevel: 2, - saved: [[1.5], [2]], + zoomLevel: 1.5, + saved: [[1], [1.5]], }); }); - it("waits for native zoom before restoring after a reload", () => { + it("uses the in-memory zoom level when persistence fails", () => { const window = createWindow(); setupWindowZoom(window); + store.save.mockImplementation(() => {}); - window.webContents.emit("zoom-changed"); - window.webContents.zoomLevel = 1.5; - window.webContents.emit("did-finish-load"); + window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in"); + vi.runAllTimers(); + window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in"); vi.runAllTimers(); expect({ + persistedZoomLevel: store.state.zoomLevel, zoomLevel: window.webContents.zoomLevel, saved: store.save.mock.calls, }).toEqual({ + persistedZoomLevel: 0.5, zoomLevel: 1.5, - saved: [[1.5]], + saved: [[1], [1.5]], }); }); diff --git a/apps/code/src/main/zoom.ts b/apps/code/src/main/zoom.ts index 428d126bd3..af4e49ddba 100644 --- a/apps/code/src/main/zoom.ts +++ b/apps/code/src/main/zoom.ts @@ -6,8 +6,14 @@ const ZOOM_MIN = -3; const ZOOM_MAX = 3; interface ZoomWebContents { - getZoomLevel(): number; - on(event: "did-finish-load" | "zoom-changed", listener: () => void): void; + on(event: "did-finish-load", listener: () => void): void; + on( + event: "zoom-changed", + listener: ( + event: { preventDefault(): void }, + zoomDirection: "in" | "out", + ) => void, + ): void; setZoomLevel(level: number): void; } @@ -25,8 +31,10 @@ interface ZoomWindow { } interface ZoomState { + currentZoomLevel: number; deferredActions: Array<() => void>; - nativeZoomTimeout: ReturnType | null; + wheelZoomDelta: number; + wheelZoomTimeout: ReturnType | null; } const zoomStates = new WeakMap(); @@ -39,9 +47,13 @@ function getSavedZoomLevel(): number { return clampZoomLevel(windowStateStore.get("zoomLevel", 0)); } -function runAfterNativeZoom(window: ZoomWindow, action: () => void): void { +function getCurrentZoomLevel(window: ZoomWindow): number { + return zoomStates.get(window)?.currentZoomLevel ?? getSavedZoomLevel(); +} + +function runAfterWheelZoom(window: ZoomWindow, action: () => void): void { const state = zoomStates.get(window); - if (!state?.nativeZoomTimeout) { + if (!state?.wheelZoomTimeout) { action(); return; } @@ -51,6 +63,8 @@ function runAfterNativeZoom(window: ZoomWindow, action: () => void): void { export function setWindowZoom(window: ZoomWindow, level: number): void { const nextLevel = clampZoomLevel(level); + const state = zoomStates.get(window); + if (state) state.currentZoomLevel = nextLevel; window.webContents.setZoomLevel(nextLevel); saveZoomLevel(nextLevel); } @@ -59,22 +73,25 @@ export function adjustWindowZoom( window: ZoomWindow, delta: number | "reset", ): void { - runAfterNativeZoom(window, () => { - const nextLevel = delta === "reset" ? 0 : getSavedZoomLevel() + delta; + runAfterWheelZoom(window, () => { + const nextLevel = + delta === "reset" ? 0 : getCurrentZoomLevel(window) + delta; setWindowZoom(window, nextLevel); }); } export function restoreWindowZoom(window: ZoomWindow): void { - runAfterNativeZoom(window, () => { - window.webContents.setZoomLevel(getSavedZoomLevel()); + runAfterWheelZoom(window, () => { + window.webContents.setZoomLevel(getCurrentZoomLevel(window)); }); } export function setupWindowZoom(window: ZoomWindow): void { const state: ZoomState = { + currentZoomLevel: getSavedZoomLevel(), deferredActions: [], - nativeZoomTimeout: null, + wheelZoomDelta: 0, + wheelZoomTimeout: null, }; let restoreTimeout: ReturnType | null = null; zoomStates.set(window, state); @@ -88,11 +105,14 @@ export function setupWindowZoom(window: ZoomWindow): void { }; window.webContents.on("did-finish-load", () => restoreWindowZoom(window)); - window.webContents.on("zoom-changed", () => { - if (state.nativeZoomTimeout) clearTimeout(state.nativeZoomTimeout); - state.nativeZoomTimeout = setTimeout(() => { - state.nativeZoomTimeout = null; - saveZoomLevel(clampZoomLevel(window.webContents.getZoomLevel())); + window.webContents.on("zoom-changed", (event, zoomDirection) => { + event.preventDefault(); + state.wheelZoomDelta += zoomDirection === "in" ? ZOOM_STEP : -ZOOM_STEP; + state.wheelZoomTimeout ??= setTimeout(() => { + const nextLevel = state.currentZoomLevel + state.wheelZoomDelta; + state.wheelZoomDelta = 0; + state.wheelZoomTimeout = null; + setWindowZoom(window, nextLevel); const deferredActions = state.deferredActions.splice(0); for (const action of deferredActions) action(); }, 0);