diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index fcf209fb67..f55554a8eb 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -75,9 +75,9 @@ export function createMenu(deps: Deps) { { role: "reload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, + { label: "Actual Size", accelerator: "Cmd+0", click: () => deps.trigger("zoom.reset") }, + { label: "Zoom In", accelerator: "Cmd+=", click: () => deps.trigger("zoom.in") }, + { label: "Zoom Out", accelerator: "Cmd+-", click: () => deps.trigger("zoom.out") }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index b5cdf6c4d8..26f138f5fb 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -159,7 +159,9 @@ function injectGlobals(win: BrowserWindow, globals: Globals) { function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) - win.webContents.on("zoom-changed", () => { - win.webContents.setZoomFactor(1) - }) + // Disable Chromium's touch/pinch zoom. Keyboard and wheel zoom are handled + // in the renderer so the Solid `webviewZoom` signal stays the single source + // of truth; a stray `zoom-changed` handler here would race with the renderer + // and intermittently snap the factor back to 1. + void win.webContents.setVisualZoomLevelLimits(1, 1).catch(() => undefined) } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index edf948d4d8..aa31dc3b64 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -23,7 +23,7 @@ import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { UPDATER_ENABLED } from "./updater" -import { webviewZoom } from "./webview-zoom" +import { webviewZoom, zoomIn, zoomOut, zoomReset } from "./webview-zoom" import "./styles.css" import { Button } from "@opencode-ai/ui/button" import { Splash } from "@opencode-ai/ui/logo" @@ -265,6 +265,9 @@ const createPlatform = (): Platform => { let menuTrigger = null as null | ((id: string) => void) window.api.onMenuCommand((id) => { + if (id === "zoom.in") return zoomIn() + if (id === "zoom.out") return zoomOut() + if (id === "zoom.reset") return zoomReset() menuTrigger?.(id) }) listenForDeepLinks() diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 9c0a3a3a35..6ff35e2459 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -11,28 +11,73 @@ const OS_NAME = (() => { return "unknown" })() +const MIN_ZOOM = 0.2 +const MAX_ZOOM = 10 +const KEY_STEP = 0.2 +const WHEEL_STEP = 0.1 + +const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM) + const [webviewZoom, setWebviewZoom] = createSignal(1) -const MAX_ZOOM_LEVEL = 10 -const MIN_ZOOM_LEVEL = 0.2 - -const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) - -const applyZoom = (next: number) => { - setWebviewZoom(next) - void window.api.setZoomFactor(next) +const apply = (next: number) => { + const clamped = clamp(next) + if (Math.abs(clamped - webviewZoom()) < 1e-6) return + setWebviewZoom(clamped) + void window.api.setZoomFactor(clamped).catch(() => undefined) } +export const zoomIn = () => apply(webviewZoom() + KEY_STEP) +export const zoomOut = () => apply(webviewZoom() - KEY_STEP) +export const zoomReset = () => apply(1) + +// Seed the signal from the main process so renderer and webContents agree +// across cold starts, reloads, and HMR refreshes (which would otherwise +// reinitialize the signal to 1 while webContents kept its prior factor). +void window.api + .getZoomFactor() + .then((initial) => { + if (typeof initial === "number" && Number.isFinite(initial)) { + setWebviewZoom(clamp(initial)) + } + }) + .catch(() => undefined) + +// Keyboard accelerators. preventDefault stops Chromium's built-in zoom +// accelerators from firing in parallel (which previously caused races). window.addEventListener("keydown", (event) => { - if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return + const mod = OS_NAME === "macos" ? event.metaKey : event.ctrlKey + if (!mod || event.altKey) return - let newZoom = webviewZoom() - - if (event.key === "-") newZoom -= 0.2 - if (event.key === "=" || event.key === "+") newZoom += 0.2 - if (event.key === "0") newZoom = 1 - - applyZoom(clamp(newZoom)) + if (event.key === "-" || event.key === "_") { + event.preventDefault() + zoomOut() + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + zoomIn() + return + } + if (event.key === "0") { + event.preventDefault() + zoomReset() + return + } }) +// Wheel zoom. Chromium synthesizes `wheel` with `ctrlKey: true` for trackpad +// pinch on every platform, so checking ctrlKey uniformly covers pinch-to-zoom +// as well as real ctrl+scroll / cmd+scroll. +window.addEventListener( + "wheel", + (event) => { + if (!event.ctrlKey && !event.metaKey) return + event.preventDefault() + const step = event.deltaY > 0 ? -WHEEL_STEP : WHEEL_STEP + apply(webviewZoom() + step) + }, + { passive: false }, +) + export { webviewZoom }