Skip to content

Commit f640f6f

Browse files
fix(code): preserve wheel zoom across resize (#3518)
1 parent e02e0c1 commit f640f6f

2 files changed

Lines changed: 99 additions & 33 deletions

File tree

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

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ import { adjustWindowZoom, restoreWindowZoom, setupWindowZoom } from "./zoom";
2222
class FakeWebContents extends EventEmitter {
2323
public zoomLevel = 0;
2424

25-
public getZoomLevel(): number {
26-
return this.zoomLevel;
27-
}
28-
2925
public setZoomLevel(level: number): void {
3026
this.zoomLevel = level;
3127
}
@@ -89,50 +85,100 @@ describe("window zoom", () => {
8985
expect(window.webContents.zoomLevel).toBe(0.5);
9086
});
9187

92-
it("persists wheel zoom after Chromium updates its level", () => {
88+
it.each([
89+
["in", 1],
90+
["out", 0],
91+
] as const)(
92+
"applies wheel zoom %s from the persisted level",
93+
(direction, expected) => {
94+
const window = createWindow();
95+
setupWindowZoom(window);
96+
const event = { preventDefault: vi.fn() };
97+
98+
window.webContents.emit("zoom-changed", event, direction);
99+
vi.runAllTimers();
100+
101+
expect({
102+
prevented: event.preventDefault.mock.calls.length,
103+
zoomLevel: window.webContents.zoomLevel,
104+
saved: store.save.mock.calls,
105+
}).toEqual({
106+
prevented: 1,
107+
zoomLevel: expected,
108+
saved: [[expected]],
109+
});
110+
},
111+
);
112+
113+
it("keeps wheel zoom after resizing", () => {
93114
const window = createWindow();
94115
setupWindowZoom(window);
95116

96-
window.webContents.emit("zoom-changed");
97-
window.webContents.zoomLevel = 1.5;
117+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
118+
window.emit("resized");
98119
vi.runAllTimers();
99120

100-
expect(store.save).toHaveBeenCalledWith(1.5);
121+
expect({
122+
zoomLevel: window.webContents.zoomLevel,
123+
saved: store.save.mock.calls,
124+
}).toEqual({
125+
zoomLevel: 1,
126+
saved: [[1]],
127+
});
101128
});
102129

103-
it("waits for native zoom before applying a menu adjustment", () => {
130+
it("keeps wheel zoom after a renderer reload", () => {
104131
const window = createWindow();
105132
setupWindowZoom(window);
106133

107-
window.webContents.emit("zoom-changed");
108-
window.webContents.zoomLevel = 1.5;
134+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
135+
window.webContents.emit("did-finish-load");
136+
vi.runAllTimers();
137+
138+
expect({
139+
zoomLevel: window.webContents.zoomLevel,
140+
saved: store.save.mock.calls,
141+
}).toEqual({
142+
zoomLevel: 1,
143+
saved: [[1]],
144+
});
145+
});
146+
147+
it("serializes wheel and menu zoom changes", () => {
148+
const window = createWindow();
149+
setupWindowZoom(window);
150+
151+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
109152
adjustWindowZoom(window, 0.5);
110153
vi.runAllTimers();
111154

112155
expect({
113156
zoomLevel: window.webContents.zoomLevel,
114157
saved: store.save.mock.calls,
115158
}).toEqual({
116-
zoomLevel: 2,
117-
saved: [[1.5], [2]],
159+
zoomLevel: 1.5,
160+
saved: [[1], [1.5]],
118161
});
119162
});
120163

121-
it("waits for native zoom before restoring after a reload", () => {
164+
it("uses the in-memory zoom level when persistence fails", () => {
122165
const window = createWindow();
123166
setupWindowZoom(window);
167+
store.save.mockImplementation(() => {});
124168

125-
window.webContents.emit("zoom-changed");
126-
window.webContents.zoomLevel = 1.5;
127-
window.webContents.emit("did-finish-load");
169+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
170+
vi.runAllTimers();
171+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
128172
vi.runAllTimers();
129173

130174
expect({
175+
persistedZoomLevel: store.state.zoomLevel,
131176
zoomLevel: window.webContents.zoomLevel,
132177
saved: store.save.mock.calls,
133178
}).toEqual({
179+
persistedZoomLevel: 0.5,
134180
zoomLevel: 1.5,
135-
saved: [[1.5]],
181+
saved: [[1], [1.5]],
136182
});
137183
});
138184

apps/code/src/main/zoom.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ const ZOOM_MIN = -3;
66
const ZOOM_MAX = 3;
77

88
interface ZoomWebContents {
9-
getZoomLevel(): number;
10-
on(event: "did-finish-load" | "zoom-changed", listener: () => void): void;
9+
on(event: "did-finish-load", listener: () => void): void;
10+
on(
11+
event: "zoom-changed",
12+
listener: (
13+
event: { preventDefault(): void },
14+
zoomDirection: "in" | "out",
15+
) => void,
16+
): void;
1117
setZoomLevel(level: number): void;
1218
}
1319

@@ -25,8 +31,10 @@ interface ZoomWindow {
2531
}
2632

2733
interface ZoomState {
34+
currentZoomLevel: number;
2835
deferredActions: Array<() => void>;
29-
nativeZoomTimeout: ReturnType<typeof setTimeout> | null;
36+
wheelZoomDelta: number;
37+
wheelZoomTimeout: ReturnType<typeof setTimeout> | null;
3038
}
3139

3240
const zoomStates = new WeakMap<ZoomWindow, ZoomState>();
@@ -39,9 +47,13 @@ function getSavedZoomLevel(): number {
3947
return clampZoomLevel(windowStateStore.get("zoomLevel", 0));
4048
}
4149

42-
function runAfterNativeZoom(window: ZoomWindow, action: () => void): void {
50+
function getCurrentZoomLevel(window: ZoomWindow): number {
51+
return zoomStates.get(window)?.currentZoomLevel ?? getSavedZoomLevel();
52+
}
53+
54+
function runAfterWheelZoom(window: ZoomWindow, action: () => void): void {
4355
const state = zoomStates.get(window);
44-
if (!state?.nativeZoomTimeout) {
56+
if (!state?.wheelZoomTimeout) {
4557
action();
4658
return;
4759
}
@@ -51,6 +63,8 @@ function runAfterNativeZoom(window: ZoomWindow, action: () => void): void {
5163

5264
export function setWindowZoom(window: ZoomWindow, level: number): void {
5365
const nextLevel = clampZoomLevel(level);
66+
const state = zoomStates.get(window);
67+
if (state) state.currentZoomLevel = nextLevel;
5468
window.webContents.setZoomLevel(nextLevel);
5569
saveZoomLevel(nextLevel);
5670
}
@@ -59,22 +73,25 @@ export function adjustWindowZoom(
5973
window: ZoomWindow,
6074
delta: number | "reset",
6175
): void {
62-
runAfterNativeZoom(window, () => {
63-
const nextLevel = delta === "reset" ? 0 : getSavedZoomLevel() + delta;
76+
runAfterWheelZoom(window, () => {
77+
const nextLevel =
78+
delta === "reset" ? 0 : getCurrentZoomLevel(window) + delta;
6479
setWindowZoom(window, nextLevel);
6580
});
6681
}
6782

6883
export function restoreWindowZoom(window: ZoomWindow): void {
69-
runAfterNativeZoom(window, () => {
70-
window.webContents.setZoomLevel(getSavedZoomLevel());
84+
runAfterWheelZoom(window, () => {
85+
window.webContents.setZoomLevel(getCurrentZoomLevel(window));
7186
});
7287
}
7388

7489
export function setupWindowZoom(window: ZoomWindow): void {
7590
const state: ZoomState = {
91+
currentZoomLevel: getSavedZoomLevel(),
7692
deferredActions: [],
77-
nativeZoomTimeout: null,
93+
wheelZoomDelta: 0,
94+
wheelZoomTimeout: null,
7895
};
7996
let restoreTimeout: ReturnType<typeof setTimeout> | null = null;
8097
zoomStates.set(window, state);
@@ -88,11 +105,14 @@ export function setupWindowZoom(window: ZoomWindow): void {
88105
};
89106

90107
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()));
108+
window.webContents.on("zoom-changed", (event, zoomDirection) => {
109+
event.preventDefault();
110+
state.wheelZoomDelta += zoomDirection === "in" ? ZOOM_STEP : -ZOOM_STEP;
111+
state.wheelZoomTimeout ??= setTimeout(() => {
112+
const nextLevel = state.currentZoomLevel + state.wheelZoomDelta;
113+
state.wheelZoomDelta = 0;
114+
state.wheelZoomTimeout = null;
115+
setWindowZoom(window, nextLevel);
96116
const deferredActions = state.deferredActions.splice(0);
97117
for (const action of deferredActions) action();
98118
}, 0);

0 commit comments

Comments
 (0)