diff --git a/apps/code/src/main/menu.ts b/apps/code/src/main/menu.ts index e6b8f133fa..99e85af21f 100644 --- a/apps/code/src/main/menu.ts +++ b/apps/code/src/main/menu.ts @@ -9,6 +9,7 @@ import type { UIService } from "@posthog/core/ui/ui"; import type { UpdatesService } from "@posthog/core/updates/updates"; import { app, + type BaseWindow, BrowserWindow, clipboard, dialog, @@ -20,25 +21,13 @@ import { container } from "./di/container"; import { AUTH_SERVICE, UPDATES_SERVICE } from "./di/tokens"; import { isDevBuild } from "./utils/env"; import { getLogFilePath } from "./utils/logger"; -import { saveZoomLevel } from "./utils/store"; +import { adjustWindowZoom, ZOOM_STEP } from "./zoom"; -// Zoom is measured in Electron "levels" (factor = 1.2 ** level; 0 = 100%). -// ZOOM_STEP is one Zoom In/Out notch; the bounds clamp the level so a runaway -// accelerator can't persist an unusable zoom across restarts. -export const ZOOM_STEP = 0.5; -const ZOOM_MIN = -3; -const ZOOM_MAX = 3; - -// Apply a zoom change to the focused window and persist the new level so it -// survives restarts. `delta` adjusts relative to the current level; "reset" -// returns to 100%. -export function applyZoom(delta: number | "reset"): void { - const webContents = BrowserWindow.getFocusedWindow()?.webContents; - if (!webContents) return; - const next = delta === "reset" ? 0 : webContents.getZoomLevel() + delta; - const level = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, next)); - webContents.setZoomLevel(level); - saveZoomLevel(level); +function applyZoom( + window: BaseWindow | undefined, + delta: number | "reset", +): void { + if (window instanceof BrowserWindow) adjustWindowZoom(window, delta); } function findLatestCrashDump(): string | null { @@ -331,12 +320,12 @@ function buildViewMenu(): MenuItemConstructorOptions { { label: "Actual Size", accelerator: "CmdOrCtrl+0", - click: () => applyZoom("reset"), + click: (_menuItem, window) => applyZoom(window, "reset"), }, { label: "Zoom In", accelerator: "CmdOrCtrl+Plus", - click: () => applyZoom(ZOOM_STEP), + click: (_menuItem, window) => applyZoom(window, ZOOM_STEP), }, // Hidden duplicate so Cmd+= (i.e. Cmd++ without Shift) also zooms in, // matching the built-in zoomIn role's dual accelerator. @@ -344,12 +333,12 @@ function buildViewMenu(): MenuItemConstructorOptions { label: "Zoom In", accelerator: "CmdOrCtrl+=", visible: false, - click: () => applyZoom(ZOOM_STEP), + click: (_menuItem, window) => applyZoom(window, ZOOM_STEP), }, { label: "Zoom Out", accelerator: "CmdOrCtrl+-", - click: () => applyZoom(-ZOOM_STEP), + click: (_menuItem, window) => applyZoom(window, -ZOOM_STEP), }, { type: "separator" }, { role: "togglefullscreen" }, diff --git a/apps/code/src/main/platform-adapters/electron-main-window.ts b/apps/code/src/main/platform-adapters/electron-main-window.ts index 2a758f0d51..109969925a 100644 --- a/apps/code/src/main/platform-adapters/electron-main-window.ts +++ b/apps/code/src/main/platform-adapters/electron-main-window.ts @@ -1,7 +1,7 @@ import type { IMainWindow } from "@posthog/platform/main-window"; import { app, type BrowserWindow } from "electron"; import { injectable } from "inversify"; -import { applyZoom, ZOOM_STEP } from "../menu"; +import { adjustWindowZoom, ZOOM_STEP } from "../zoom"; @injectable() export class ElectronMainWindow implements IMainWindow { @@ -38,14 +38,17 @@ export class ElectronMainWindow implements IMainWindow { } public zoomIn(): void { - applyZoom(ZOOM_STEP); + const window = this.getBrowserWindow(); + if (window) adjustWindowZoom(window, ZOOM_STEP); } public zoomOut(): void { - applyZoom(-ZOOM_STEP); + const window = this.getBrowserWindow(); + if (window) adjustWindowZoom(window, -ZOOM_STEP); } public resetZoom(): void { - applyZoom("reset"); + const window = this.getBrowserWindow(); + if (window) adjustWindowZoom(window, "reset"); } } diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 7951613c9a..ad56a34d03 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -26,11 +26,11 @@ import { isDevBuild } from "./utils/env"; import { logger, readChromiumLogTail } from "./utils/logger"; import { saveFullScreenState, - saveZoomLevel, setRestoreFullScreenOnNextLaunch, type WindowStateSchema, windowStateStore, } from "./utils/store"; +import { setupWindowZoom } from "./zoom"; const log = logger.scope("window"); const trpcLog = logger.scope("host-trpc"); @@ -270,21 +270,7 @@ export function createWindow(): void { mainWindow.once("ready-to-show", showWindow); const showFallback = setTimeout(showWindow, 3000); - // Restore the zoom level once the renderer has loaded. Read the latest - // persisted value from the store (not the create-time snapshot) so zooming - // done during the session survives in-app reloads, which otherwise reset - // Chromium's per-webContents zoom. - mainWindow.webContents.on("did-finish-load", () => { - mainWindow?.webContents.setZoomLevel(windowStateStore.get("zoomLevel", 0)); - }); - - // Persist mouse-wheel/pinch zoom. Menu-driven zoom is persisted by the - // menu items themselves (see buildViewMenu in menu.ts). - mainWindow.webContents.on("zoom-changed", () => { - if (mainWindow) { - saveZoomLevel(mainWindow.webContents.getZoomLevel()); - } - }); + setupWindowZoom(mainWindow); // Persist window state on changes mainWindow.on( diff --git a/apps/code/src/main/zoom.test.ts b/apps/code/src/main/zoom.test.ts new file mode 100644 index 0000000000..b4ddb6d79e --- /dev/null +++ b/apps/code/src/main/zoom.test.ts @@ -0,0 +1,147 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => { + const state = { zoomLevel: 0.5 }; + return { + get: vi.fn(() => state.zoomLevel), + save: vi.fn((level: number) => { + state.zoomLevel = level; + }), + state, + }; +}); + +vi.mock("./utils/store", () => ({ + windowStateStore: { get: store.get }, + saveZoomLevel: store.save, +})); + +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; + } +} + +class FakeWindow extends EventEmitter { + public readonly webContents = new FakeWebContents(); +} + +type ZoomWindow = Parameters[0]; + +function createWindow(): FakeWindow & ZoomWindow { + return new FakeWindow() as FakeWindow & ZoomWindow; +} + +describe("window zoom", () => { + beforeEach(() => { + vi.useFakeTimers(); + store.state.zoomLevel = 0.5; + store.get.mockReset(); + store.get.mockImplementation(() => store.state.zoomLevel); + store.save.mockReset(); + store.save.mockImplementation((level: number) => { + store.state.zoomLevel = level; + }); + }); + + it("adjusts from the persisted level when Chromium has reset", () => { + const window = createWindow(); + window.webContents.zoomLevel = 0; + + adjustWindowZoom(window, 0.5); + + expect({ + zoomLevel: window.webContents.zoomLevel, + saved: store.save.mock.calls, + }).toEqual({ + zoomLevel: 1, + saved: [[1]], + }); + }); + + it("restores the persisted level after maximizing", () => { + const window = createWindow(); + setupWindowZoom(window); + window.webContents.zoomLevel = 0; + + window.emit("maximize"); + vi.runAllTimers(); + + expect(window.webContents.zoomLevel).toBe(0.5); + }); + + it("restores the persisted level after renderer reloads", () => { + const window = createWindow(); + setupWindowZoom(window); + window.webContents.zoomLevel = 0; + + window.webContents.emit("did-finish-load"); + + expect(window.webContents.zoomLevel).toBe(0.5); + }); + + it("persists wheel zoom after Chromium updates its level", () => { + const window = createWindow(); + setupWindowZoom(window); + + window.webContents.emit("zoom-changed"); + window.webContents.zoomLevel = 1.5; + vi.runAllTimers(); + + expect(store.save).toHaveBeenCalledWith(1.5); + }); + + it("waits for native zoom before applying a menu adjustment", () => { + const window = createWindow(); + setupWindowZoom(window); + + window.webContents.emit("zoom-changed"); + window.webContents.zoomLevel = 1.5; + adjustWindowZoom(window, 0.5); + vi.runAllTimers(); + + expect({ + zoomLevel: window.webContents.zoomLevel, + saved: store.save.mock.calls, + }).toEqual({ + zoomLevel: 2, + saved: [[1.5], [2]], + }); + }); + + it("waits for native zoom before restoring after a reload", () => { + const window = createWindow(); + setupWindowZoom(window); + + window.webContents.emit("zoom-changed"); + window.webContents.zoomLevel = 1.5; + window.webContents.emit("did-finish-load"); + vi.runAllTimers(); + + expect({ + zoomLevel: window.webContents.zoomLevel, + saved: store.save.mock.calls, + }).toEqual({ + zoomLevel: 1.5, + saved: [[1.5]], + }); + }); + + it("clamps invalid persisted levels before restoring", () => { + store.get.mockReturnValue(10); + const window = createWindow(); + + restoreWindowZoom(window); + + expect(window.webContents.zoomLevel).toBe(3); + }); +}); diff --git a/apps/code/src/main/zoom.ts b/apps/code/src/main/zoom.ts new file mode 100644 index 0000000000..428d126bd3 --- /dev/null +++ b/apps/code/src/main/zoom.ts @@ -0,0 +1,106 @@ +import { saveZoomLevel, windowStateStore } from "./utils/store"; + +export const ZOOM_STEP = 0.5; + +const ZOOM_MIN = -3; +const ZOOM_MAX = 3; + +interface ZoomWebContents { + getZoomLevel(): number; + on(event: "did-finish-load" | "zoom-changed", listener: () => void): void; + setZoomLevel(level: number): void; +} + +interface ZoomWindow { + on( + event: + | "enter-full-screen" + | "leave-full-screen" + | "maximize" + | "resized" + | "unmaximize", + listener: () => void, + ): void; + webContents: ZoomWebContents; +} + +interface ZoomState { + deferredActions: Array<() => void>; + nativeZoomTimeout: ReturnType | null; +} + +const zoomStates = new WeakMap(); + +function clampZoomLevel(level: number): number { + return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, level)); +} + +function getSavedZoomLevel(): number { + return clampZoomLevel(windowStateStore.get("zoomLevel", 0)); +} + +function runAfterNativeZoom(window: ZoomWindow, action: () => void): void { + const state = zoomStates.get(window); + if (!state?.nativeZoomTimeout) { + action(); + return; + } + + state.deferredActions.push(action); +} + +export function setWindowZoom(window: ZoomWindow, level: number): void { + const nextLevel = clampZoomLevel(level); + window.webContents.setZoomLevel(nextLevel); + saveZoomLevel(nextLevel); +} + +export function adjustWindowZoom( + window: ZoomWindow, + delta: number | "reset", +): void { + runAfterNativeZoom(window, () => { + const nextLevel = delta === "reset" ? 0 : getSavedZoomLevel() + delta; + setWindowZoom(window, nextLevel); + }); +} + +export function restoreWindowZoom(window: ZoomWindow): void { + runAfterNativeZoom(window, () => { + window.webContents.setZoomLevel(getSavedZoomLevel()); + }); +} + +export function setupWindowZoom(window: ZoomWindow): void { + const state: ZoomState = { + deferredActions: [], + nativeZoomTimeout: null, + }; + let restoreTimeout: ReturnType | null = null; + zoomStates.set(window, state); + + const scheduleRestore = () => { + if (restoreTimeout) clearTimeout(restoreTimeout); + restoreTimeout = setTimeout(() => { + restoreTimeout = null; + restoreWindowZoom(window); + }, 0); + }; + + 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())); + const deferredActions = state.deferredActions.splice(0); + for (const action of deferredActions) action(); + }, 0); + }); + + window.on("maximize", scheduleRestore); + window.on("unmaximize", scheduleRestore); + window.on("resized", scheduleRestore); + window.on("enter-full-screen", scheduleRestore); + window.on("leave-full-screen", scheduleRestore); +}