Skip to content

Commit f118d10

Browse files
fix(code): preserve zoom across window state changes (#3413)
1 parent d942c02 commit f118d10

5 files changed

Lines changed: 273 additions & 42 deletions

File tree

apps/code/src/main/menu.ts

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { UIService } from "@posthog/core/ui/ui";
99
import type { UpdatesService } from "@posthog/core/updates/updates";
1010
import {
1111
app,
12+
type BaseWindow,
1213
BrowserWindow,
1314
clipboard,
1415
dialog,
@@ -20,25 +21,13 @@ import { container } from "./di/container";
2021
import { AUTH_SERVICE, UPDATES_SERVICE } from "./di/tokens";
2122
import { isDevBuild } from "./utils/env";
2223
import { getLogFilePath } from "./utils/logger";
23-
import { saveZoomLevel } from "./utils/store";
24+
import { adjustWindowZoom, ZOOM_STEP } from "./zoom";
2425

25-
// Zoom is measured in Electron "levels" (factor = 1.2 ** level; 0 = 100%).
26-
// ZOOM_STEP is one Zoom In/Out notch; the bounds clamp the level so a runaway
27-
// accelerator can't persist an unusable zoom across restarts.
28-
export const ZOOM_STEP = 0.5;
29-
const ZOOM_MIN = -3;
30-
const ZOOM_MAX = 3;
31-
32-
// Apply a zoom change to the focused window and persist the new level so it
33-
// survives restarts. `delta` adjusts relative to the current level; "reset"
34-
// returns to 100%.
35-
export function applyZoom(delta: number | "reset"): void {
36-
const webContents = BrowserWindow.getFocusedWindow()?.webContents;
37-
if (!webContents) return;
38-
const next = delta === "reset" ? 0 : webContents.getZoomLevel() + delta;
39-
const level = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, next));
40-
webContents.setZoomLevel(level);
41-
saveZoomLevel(level);
26+
function applyZoom(
27+
window: BaseWindow | undefined,
28+
delta: number | "reset",
29+
): void {
30+
if (window instanceof BrowserWindow) adjustWindowZoom(window, delta);
4231
}
4332

4433
function findLatestCrashDump(): string | null {
@@ -331,25 +320,25 @@ function buildViewMenu(): MenuItemConstructorOptions {
331320
{
332321
label: "Actual Size",
333322
accelerator: "CmdOrCtrl+0",
334-
click: () => applyZoom("reset"),
323+
click: (_menuItem, window) => applyZoom(window, "reset"),
335324
},
336325
{
337326
label: "Zoom In",
338327
accelerator: "CmdOrCtrl+Plus",
339-
click: () => applyZoom(ZOOM_STEP),
328+
click: (_menuItem, window) => applyZoom(window, ZOOM_STEP),
340329
},
341330
// Hidden duplicate so Cmd+= (i.e. Cmd++ without Shift) also zooms in,
342331
// matching the built-in zoomIn role's dual accelerator.
343332
{
344333
label: "Zoom In",
345334
accelerator: "CmdOrCtrl+=",
346335
visible: false,
347-
click: () => applyZoom(ZOOM_STEP),
336+
click: (_menuItem, window) => applyZoom(window, ZOOM_STEP),
348337
},
349338
{
350339
label: "Zoom Out",
351340
accelerator: "CmdOrCtrl+-",
352-
click: () => applyZoom(-ZOOM_STEP),
341+
click: (_menuItem, window) => applyZoom(window, -ZOOM_STEP),
353342
},
354343
{ type: "separator" },
355344
{ role: "togglefullscreen" },

apps/code/src/main/platform-adapters/electron-main-window.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { IMainWindow } from "@posthog/platform/main-window";
22
import { app, type BrowserWindow } from "electron";
33
import { injectable } from "inversify";
4-
import { applyZoom, ZOOM_STEP } from "../menu";
4+
import { adjustWindowZoom, ZOOM_STEP } from "../zoom";
55

66
@injectable()
77
export class ElectronMainWindow implements IMainWindow {
@@ -38,14 +38,17 @@ export class ElectronMainWindow implements IMainWindow {
3838
}
3939

4040
public zoomIn(): void {
41-
applyZoom(ZOOM_STEP);
41+
const window = this.getBrowserWindow();
42+
if (window) adjustWindowZoom(window, ZOOM_STEP);
4243
}
4344

4445
public zoomOut(): void {
45-
applyZoom(-ZOOM_STEP);
46+
const window = this.getBrowserWindow();
47+
if (window) adjustWindowZoom(window, -ZOOM_STEP);
4648
}
4749

4850
public resetZoom(): void {
49-
applyZoom("reset");
51+
const window = this.getBrowserWindow();
52+
if (window) adjustWindowZoom(window, "reset");
5053
}
5154
}

apps/code/src/main/window.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ import { isDevBuild } from "./utils/env";
2626
import { logger, readChromiumLogTail } from "./utils/logger";
2727
import {
2828
saveFullScreenState,
29-
saveZoomLevel,
3029
setRestoreFullScreenOnNextLaunch,
3130
type WindowStateSchema,
3231
windowStateStore,
3332
} from "./utils/store";
33+
import { setupWindowZoom } from "./zoom";
3434

3535
const log = logger.scope("window");
3636
const trpcLog = logger.scope("host-trpc");
@@ -270,21 +270,7 @@ export function createWindow(): void {
270270
mainWindow.once("ready-to-show", showWindow);
271271
const showFallback = setTimeout(showWindow, 3000);
272272

273-
// Restore the zoom level once the renderer has loaded. Read the latest
274-
// persisted value from the store (not the create-time snapshot) so zooming
275-
// done during the session survives in-app reloads, which otherwise reset
276-
// Chromium's per-webContents zoom.
277-
mainWindow.webContents.on("did-finish-load", () => {
278-
mainWindow?.webContents.setZoomLevel(windowStateStore.get("zoomLevel", 0));
279-
});
280-
281-
// Persist mouse-wheel/pinch zoom. Menu-driven zoom is persisted by the
282-
// menu items themselves (see buildViewMenu in menu.ts).
283-
mainWindow.webContents.on("zoom-changed", () => {
284-
if (mainWindow) {
285-
saveZoomLevel(mainWindow.webContents.getZoomLevel());
286-
}
287-
});
273+
setupWindowZoom(mainWindow);
288274

289275
// Persist window state on changes
290276
mainWindow.on(

apps/code/src/main/zoom.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { EventEmitter } from "node:events";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const store = vi.hoisted(() => {
5+
const state = { zoomLevel: 0.5 };
6+
return {
7+
get: vi.fn(() => state.zoomLevel),
8+
save: vi.fn((level: number) => {
9+
state.zoomLevel = level;
10+
}),
11+
state,
12+
};
13+
});
14+
15+
vi.mock("./utils/store", () => ({
16+
windowStateStore: { get: store.get },
17+
saveZoomLevel: store.save,
18+
}));
19+
20+
import { adjustWindowZoom, restoreWindowZoom, setupWindowZoom } from "./zoom";
21+
22+
class FakeWebContents extends EventEmitter {
23+
public zoomLevel = 0;
24+
25+
public getZoomLevel(): number {
26+
return this.zoomLevel;
27+
}
28+
29+
public setZoomLevel(level: number): void {
30+
this.zoomLevel = level;
31+
}
32+
}
33+
34+
class FakeWindow extends EventEmitter {
35+
public readonly webContents = new FakeWebContents();
36+
}
37+
38+
type ZoomWindow = Parameters<typeof adjustWindowZoom>[0];
39+
40+
function createWindow(): FakeWindow & ZoomWindow {
41+
return new FakeWindow() as FakeWindow & ZoomWindow;
42+
}
43+
44+
describe("window zoom", () => {
45+
beforeEach(() => {
46+
vi.useFakeTimers();
47+
store.state.zoomLevel = 0.5;
48+
store.get.mockReset();
49+
store.get.mockImplementation(() => store.state.zoomLevel);
50+
store.save.mockReset();
51+
store.save.mockImplementation((level: number) => {
52+
store.state.zoomLevel = level;
53+
});
54+
});
55+
56+
it("adjusts from the persisted level when Chromium has reset", () => {
57+
const window = createWindow();
58+
window.webContents.zoomLevel = 0;
59+
60+
adjustWindowZoom(window, 0.5);
61+
62+
expect({
63+
zoomLevel: window.webContents.zoomLevel,
64+
saved: store.save.mock.calls,
65+
}).toEqual({
66+
zoomLevel: 1,
67+
saved: [[1]],
68+
});
69+
});
70+
71+
it("restores the persisted level after maximizing", () => {
72+
const window = createWindow();
73+
setupWindowZoom(window);
74+
window.webContents.zoomLevel = 0;
75+
76+
window.emit("maximize");
77+
vi.runAllTimers();
78+
79+
expect(window.webContents.zoomLevel).toBe(0.5);
80+
});
81+
82+
it("restores the persisted level after renderer reloads", () => {
83+
const window = createWindow();
84+
setupWindowZoom(window);
85+
window.webContents.zoomLevel = 0;
86+
87+
window.webContents.emit("did-finish-load");
88+
89+
expect(window.webContents.zoomLevel).toBe(0.5);
90+
});
91+
92+
it("persists wheel zoom after Chromium updates its level", () => {
93+
const window = createWindow();
94+
setupWindowZoom(window);
95+
96+
window.webContents.emit("zoom-changed");
97+
window.webContents.zoomLevel = 1.5;
98+
vi.runAllTimers();
99+
100+
expect(store.save).toHaveBeenCalledWith(1.5);
101+
});
102+
103+
it("waits for native zoom before applying a menu adjustment", () => {
104+
const window = createWindow();
105+
setupWindowZoom(window);
106+
107+
window.webContents.emit("zoom-changed");
108+
window.webContents.zoomLevel = 1.5;
109+
adjustWindowZoom(window, 0.5);
110+
vi.runAllTimers();
111+
112+
expect({
113+
zoomLevel: window.webContents.zoomLevel,
114+
saved: store.save.mock.calls,
115+
}).toEqual({
116+
zoomLevel: 2,
117+
saved: [[1.5], [2]],
118+
});
119+
});
120+
121+
it("waits for native zoom before restoring after a reload", () => {
122+
const window = createWindow();
123+
setupWindowZoom(window);
124+
125+
window.webContents.emit("zoom-changed");
126+
window.webContents.zoomLevel = 1.5;
127+
window.webContents.emit("did-finish-load");
128+
vi.runAllTimers();
129+
130+
expect({
131+
zoomLevel: window.webContents.zoomLevel,
132+
saved: store.save.mock.calls,
133+
}).toEqual({
134+
zoomLevel: 1.5,
135+
saved: [[1.5]],
136+
});
137+
});
138+
139+
it("clamps invalid persisted levels before restoring", () => {
140+
store.get.mockReturnValue(10);
141+
const window = createWindow();
142+
143+
restoreWindowZoom(window);
144+
145+
expect(window.webContents.zoomLevel).toBe(3);
146+
});
147+
});

apps/code/src/main/zoom.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { saveZoomLevel, windowStateStore } from "./utils/store";
2+
3+
export const ZOOM_STEP = 0.5;
4+
5+
const ZOOM_MIN = -3;
6+
const ZOOM_MAX = 3;
7+
8+
interface ZoomWebContents {
9+
getZoomLevel(): number;
10+
on(event: "did-finish-load" | "zoom-changed", listener: () => void): void;
11+
setZoomLevel(level: number): void;
12+
}
13+
14+
interface ZoomWindow {
15+
on(
16+
event:
17+
| "enter-full-screen"
18+
| "leave-full-screen"
19+
| "maximize"
20+
| "resized"
21+
| "unmaximize",
22+
listener: () => void,
23+
): void;
24+
webContents: ZoomWebContents;
25+
}
26+
27+
interface ZoomState {
28+
deferredActions: Array<() => void>;
29+
nativeZoomTimeout: ReturnType<typeof setTimeout> | null;
30+
}
31+
32+
const zoomStates = new WeakMap<ZoomWindow, ZoomState>();
33+
34+
function clampZoomLevel(level: number): number {
35+
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level));
36+
}
37+
38+
function getSavedZoomLevel(): number {
39+
return clampZoomLevel(windowStateStore.get("zoomLevel", 0));
40+
}
41+
42+
function runAfterNativeZoom(window: ZoomWindow, action: () => void): void {
43+
const state = zoomStates.get(window);
44+
if (!state?.nativeZoomTimeout) {
45+
action();
46+
return;
47+
}
48+
49+
state.deferredActions.push(action);
50+
}
51+
52+
export function setWindowZoom(window: ZoomWindow, level: number): void {
53+
const nextLevel = clampZoomLevel(level);
54+
window.webContents.setZoomLevel(nextLevel);
55+
saveZoomLevel(nextLevel);
56+
}
57+
58+
export function adjustWindowZoom(
59+
window: ZoomWindow,
60+
delta: number | "reset",
61+
): void {
62+
runAfterNativeZoom(window, () => {
63+
const nextLevel = delta === "reset" ? 0 : getSavedZoomLevel() + delta;
64+
setWindowZoom(window, nextLevel);
65+
});
66+
}
67+
68+
export function restoreWindowZoom(window: ZoomWindow): void {
69+
runAfterNativeZoom(window, () => {
70+
window.webContents.setZoomLevel(getSavedZoomLevel());
71+
});
72+
}
73+
74+
export function setupWindowZoom(window: ZoomWindow): void {
75+
const state: ZoomState = {
76+
deferredActions: [],
77+
nativeZoomTimeout: null,
78+
};
79+
let restoreTimeout: ReturnType<typeof setTimeout> | null = null;
80+
zoomStates.set(window, state);
81+
82+
const scheduleRestore = () => {
83+
if (restoreTimeout) clearTimeout(restoreTimeout);
84+
restoreTimeout = setTimeout(() => {
85+
restoreTimeout = null;
86+
restoreWindowZoom(window);
87+
}, 0);
88+
};
89+
90+
window.webContents.on("did-finish-load", () => restoreWindowZoom(window));
91+
window.webContents.on("zoom-changed", () => {
92+
if (state.nativeZoomTimeout) clearTimeout(state.nativeZoomTimeout);
93+
state.nativeZoomTimeout = setTimeout(() => {
94+
state.nativeZoomTimeout = null;
95+
saveZoomLevel(clampZoomLevel(window.webContents.getZoomLevel()));
96+
const deferredActions = state.deferredActions.splice(0);
97+
for (const action of deferredActions) action();
98+
}, 0);
99+
});
100+
101+
window.on("maximize", scheduleRestore);
102+
window.on("unmaximize", scheduleRestore);
103+
window.on("resized", scheduleRestore);
104+
window.on("enter-full-screen", scheduleRestore);
105+
window.on("leave-full-screen", scheduleRestore);
106+
}

0 commit comments

Comments
 (0)