Sfoglia il codice sorgente

feat(desktop): add pinch zoom setting (#28632)

Brendan Allan 3 mesi fa
parent
commit
2caac055ef

+ 56 - 25
packages/app/src/components/settings-general.tsx

@@ -193,6 +193,12 @@ export const SettingsGeneral: Component = () => {
     { initialValue: null as DisplayBackend | null },
     { initialValue: null as DisplayBackend | null },
   )
   )
 
 
+  const [pinchZoom, { mutate: setPinchZoom }] = createResource(
+    () => (desktop() && platform.getPinchZoomEnabled ? true : false),
+    () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
+    { initialValue: false },
+  )
+
   onMount(() => {
   onMount(() => {
     void theme.loadThemes()
     void theme.loadThemes()
   })
   })
@@ -239,6 +245,13 @@ export const SettingsGeneral: Component = () => {
     })
     })
   }
   }
 
 
+  const onPinchZoomChange = (checked: boolean) => {
+    setPinchZoom(checked)
+    const update = platform.setPinchZoomEnabled?.(checked)
+    if (!update) return
+    void update.catch(() => setPinchZoom(!checked))
+  }
+
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
     { value: "system", label: language.t("theme.scheme.system") },
     { value: "system", label: language.t("theme.scheme.system") },
     { value: "light", label: language.t("theme.scheme.light") },
     { value: "light", label: language.t("theme.scheme.light") },
@@ -729,6 +742,48 @@ export const SettingsGeneral: Component = () => {
     </div>
     </div>
   )
   )
 
 
+  const DisplaySection = () => (
+    <Show when={desktop()}>
+      <div class="flex flex-col gap-1">
+        <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
+
+        <SettingsList>
+          <SettingsRow
+            title={language.t("settings.general.row.pinchZoom.title")}
+            description={language.t("settings.general.row.pinchZoom.description")}
+          >
+            <div data-action="settings-pinch-zoom">
+              <Switch
+                checked={pinchZoom.latest}
+                onChange={onPinchZoomChange}
+              />
+            </div>
+          </SettingsRow>
+
+          <Show when={linux()}>
+            <SettingsRow
+              title={
+                <div class="flex items-center gap-2">
+                  <span>{language.t("settings.general.row.wayland.title")}</span>
+                  <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
+                    <span class="text-text-weak">
+                      <Icon name="help" size="small" />
+                    </span>
+                  </Tooltip>
+                </div>
+              }
+              description={language.t("settings.general.row.wayland.description")}
+            >
+              <div data-action="settings-wayland">
+                <Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
+              </div>
+            </SettingsRow>
+          </Show>
+        </SettingsList>
+      </div>
+    </Show>
+  )
+
   console.log(import.meta.env)
   console.log(import.meta.env)
   return (
   return (
     <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
     <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
@@ -749,31 +804,7 @@ export const SettingsGeneral: Component = () => {
 
 
         <UpdatesSection />
         <UpdatesSection />
 
 
-        <Show when={linux()}>
-          <div class="flex flex-col gap-1">
-            <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
-
-            <SettingsList>
-              <SettingsRow
-                title={
-                  <div class="flex items-center gap-2">
-                    <span>{language.t("settings.general.row.wayland.title")}</span>
-                    <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
-                      <span class="text-text-weak">
-                        <Icon name="help" size="small" />
-                      </span>
-                    </Tooltip>
-                  </div>
-                }
-                description={language.t("settings.general.row.wayland.description")}
-              >
-                <div data-action="settings-wayland">
-                  <Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
-                </div>
-              </SettingsRow>
-            </SettingsList>
-          </div>
-        </Show>
+        <DisplaySection />
 
 
         <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
         <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
           <AdvancedSection />
           <AdvancedSection />

+ 33 - 4
packages/app/src/components/titlebar.tsx

@@ -22,6 +22,7 @@ import { iife } from "@opencode-ai/core/util/iife"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
 import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
 import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
 import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
+import { makeEventListener } from "@solid-primitives/event-listener"
 
 
 type TauriDesktopWindow = {
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
   startDragging?: () => Promise<void>
@@ -298,6 +299,34 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
               () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
             )
             )
 
 
+            const currentSessionTab = () => {
+              if (!params.dir || !params.id) return
+              const href = makeSessionHref(params.dir, params.id)
+              if (!tabsStore.some((tab) => tab.href === href)) return
+              return href
+            }
+
+            const closeCurrentSessionTab = () => {
+              const href = currentSessionTab()
+              if (!href) return false
+              tabsStoreActions.removeTab(href)
+              return true
+            }
+
+            makeEventListener(
+              document,
+              "keydown",
+              (event) => {
+                if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
+                if (event.key.toLowerCase() !== "w") return
+                if (!closeCurrentSessionTab()) return
+
+                event.preventDefault()
+                event.stopPropagation()
+              },
+              { capture: true },
+            )
+
             const tabsEnriched = iife(() => {
             const tabsEnriched = iife(() => {
               const base = mapArray(
               const base = mapArray(
                 () => tabsStore,
                 () => tabsStore,
@@ -578,15 +607,15 @@ function TabNavItem(props: {
   const isActive = () => !!match()
   const isActive = () => !!match()
   return (
   return (
     <div
     <div
-      class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
+      class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
       data-active={isActive()}
       data-active={isActive()}
     >
     >
       <a
       <a
         href={props.href}
         href={props.href}
-        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none tracking-[-0.04px] text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
+        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
       >
       >
         <ProjectTabAvatar project={props.project} directory={props.directory} />
         <ProjectTabAvatar project={props.project} directory={props.directory} />
-        <span class="truncate">{props.title}</span>
+        <span class="text-clip">{props.title}</span>
       </a>
       </a>
 
 
       <div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
       <div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
@@ -624,7 +653,7 @@ function ProjectTabAvatar(props: { project?: LocalProject; directory: string })
 
 
 function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
 function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
   return (
   return (
-    <div class="group relative flex h-7 w-[135px] min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
+    <div class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
       <a
       <a
         href={props.href}
         href={props.href}
         aria-current="page"
         aria-current="page"

+ 6 - 0
packages/app/src/context/platform.tsx

@@ -93,6 +93,12 @@ export type Platform = {
   /** Webview zoom level (desktop only) */
   /** Webview zoom level (desktop only) */
   webviewZoom?: Accessor<number>
   webviewZoom?: Accessor<number>
 
 
+  /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
+  getPinchZoomEnabled?(): Promise<boolean> | boolean
+
+  /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
+  setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
+
   /** Run a desktop-only menu action from the app chrome */
   /** Run a desktop-only menu action from the app chrome */
   runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
   runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
 
 

+ 2 - 0
packages/app/src/i18n/en.ts

@@ -784,6 +784,8 @@ export const dict = {
   "settings.general.row.showSessionProgressBar.title": "Show session progress bar",
   "settings.general.row.showSessionProgressBar.title": "Show session progress bar",
   "settings.general.row.showSessionProgressBar.description":
   "settings.general.row.showSessionProgressBar.description":
     "Display the animated progress bar at the top of the session when the agent is working",
     "Display the animated progress bar at the top of the session when the agent is working",
+  "settings.general.row.pinchZoom.title": "Pinch to zoom",
+  "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
 
 
   "settings.general.row.wayland.title": "Use native Wayland",
   "settings.general.row.wayland.title": "Use native Wayland",
   "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
   "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",

+ 1 - 0
packages/desktop/src/main/constants.ts

@@ -7,4 +7,5 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
 export const SETTINGS_STORE = "opencode.settings"
 export const SETTINGS_STORE = "opencode.settings"
 export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
 export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
 export const WSL_ENABLED_KEY = "wslEnabled"
 export const WSL_ENABLED_KEY = "wslEnabled"
+export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
 export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
 export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

+ 5 - 1
packages/desktop/src/main/ipc.ts

@@ -14,7 +14,7 @@ import type {
 } from "../preload/types"
 } from "../preload/types"
 import { runDesktopMenuAction } from "./desktop-menu-actions"
 import { runDesktopMenuAction } from "./desktop-menu-actions"
 import { getStore } from "./store"
 import { getStore } from "./store"
-import { setTitlebar, updateTitlebar } from "./windows"
+import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
 
 
 const pickerFilters = (ext?: string[]) => {
 const pickerFilters = (ext?: string[]) => {
   if (!ext || ext.length === 0) return undefined
   if (!ext || ext.length === 0) return undefined
@@ -202,6 +202,10 @@ export function registerIpcHandlers(deps: Deps) {
     if (!win) return
     if (!win) return
     updateTitlebar(win)
     updateTitlebar(win)
   })
   })
+  ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
+  ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
+    setPinchZoomEnabled(enabled)
+  })
   ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
   ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
     const win = BrowserWindow.fromWebContents(event.sender)
     const win = BrowserWindow.fromWebContents(event.sender)
     if (!win) return
     if (!win) return

+ 40 - 3
packages/desktop/src/main/windows.ts

@@ -3,7 +3,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
 import { dirname, isAbsolute, join, relative, resolve } from "node:path"
 import { dirname, isAbsolute, join, relative, resolve } from "node:path"
 import { fileURLToPath, pathToFileURL } from "node:url"
 import { fileURLToPath, pathToFileURL } from "node:url"
 import type { TitlebarTheme } from "../preload/types"
 import type { TitlebarTheme } from "../preload/types"
+import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
 import { exportDebugLogs, write as writeLog } from "./logging"
 import { exportDebugLogs, write as writeLog } from "./logging"
+import { getStore } from "./store"
 import { createUnresponsiveSampler } from "./unresponsive"
 import { createUnresponsiveSampler } from "./unresponsive"
 
 
 const root = dirname(fileURLToPath(import.meta.url))
 const root = dirname(fileURLToPath(import.meta.url))
@@ -33,7 +35,10 @@ let relaunchHandler = () => {
   app.exit(0)
   app.exit(0)
 }
 }
 const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
 const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
+const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
 const titlebarHeight = 40
 const titlebarHeight = 40
+const maxZoomLevel = 10
+const minZoomLevel = 0.2
 
 
 export function setRelaunchHandler(handler: () => void) {
 export function setRelaunchHandler(handler: () => void) {
   relaunchHandler = handler
   relaunchHandler = handler
@@ -79,6 +84,20 @@ export function updateTitlebar(win: BrowserWindow) {
   win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
   win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
 }
 }
 
 
+export function setPinchZoomEnabled(enabled: boolean) {
+  getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
+  for (const win of BrowserWindow.getAllWindows()) {
+    pinchZoomEnabled.set(win, enabled)
+    win.webContents.send("pinch-zoom-enabled-changed", enabled)
+    if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
+    updateZoom(win)
+  }
+}
+
+export function getPinchZoomEnabled() {
+  return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
+}
+
 export function setDockIcon() {
 export function setDockIcon() {
   if (process.platform !== "darwin") return
   if (process.platform !== "darwin") return
   const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
   const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
@@ -392,13 +411,31 @@ function isRendererUrl(value?: string, html = false) {
 }
 }
 
 
 function wireZoom(win: BrowserWindow) {
 function wireZoom(win: BrowserWindow) {
+  pinchZoomEnabled.set(win, getPinchZoomEnabled())
   win.webContents.setZoomFactor(1)
   win.webContents.setZoomFactor(1)
-  win.webContents.on("zoom-changed", () => {
-    win.webContents.setZoomFactor(1)
-    updateTitlebar(win)
+  win.webContents.on("zoom-changed", (event, zoomDirection) => {
+    event.preventDefault()
+    if (pinchZoomEnabled.get(win)) {
+      win.webContents.setZoomFactor(
+        clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)),
+      )
+      updateZoom(win)
+      return
+    }
+    if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
+    updateZoom(win)
   })
   })
 }
 }
 
 
+function clampZoom(value: number) {
+  return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
+}
+
+function updateZoom(win: BrowserWindow) {
+  updateTitlebar(win)
+  win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
+}
+
 function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
 function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
   const keyToChangeLower = keyToChange.toLowerCase()
   const keyToChangeLower = keyToChange.toLowerCase()
   for (const key of Object.keys(obj)) {
   for (const key of Object.keys(obj)) {

+ 12 - 0
packages/desktop/src/preload/index.ts

@@ -60,6 +60,18 @@ const api: ElectronAPI = {
   relaunch: () => ipcRenderer.send("relaunch"),
   relaunch: () => ipcRenderer.send("relaunch"),
   getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
   getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
   setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
   setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
+  getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"),
+  setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled),
+  onPinchZoomEnabledChanged: (cb) => {
+    const handler = (_: unknown, enabled: boolean) => cb(enabled)
+    ipcRenderer.on("pinch-zoom-enabled-changed", handler)
+    return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler)
+  },
+  onZoomFactorChanged: (cb) => {
+    const handler = (_: unknown, factor: number) => cb(factor)
+    ipcRenderer.on("zoom-factor-changed", handler)
+    return () => ipcRenderer.removeListener("zoom-factor-changed", handler)
+  },
   setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
   setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
   runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
   runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
   loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
   loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),

+ 4 - 0
packages/desktop/src/preload/types.ts

@@ -79,6 +79,10 @@ export type ElectronAPI = {
   relaunch: () => void
   relaunch: () => void
   getZoomFactor: () => Promise<number>
   getZoomFactor: () => Promise<number>
   setZoomFactor: (factor: number) => Promise<void>
   setZoomFactor: (factor: number) => Promise<void>
+  getPinchZoomEnabled: () => Promise<boolean>
+  setPinchZoomEnabled: (enabled: boolean) => Promise<void>
+  onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void
+  onZoomFactorChanged: (cb: (factor: number) => void) => () => void
   setTitlebar: (theme: TitlebarTheme) => Promise<void>
   setTitlebar: (theme: TitlebarTheme) => Promise<void>
   runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
   runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
   loadingWindowComplete: () => void
   loadingWindowComplete: () => void

+ 5 - 1
packages/desktop/src/renderer/index.tsx

@@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
 import { render } from "solid-js/web"
 import { render } from "solid-js/web"
 import pkg from "../../package.json"
 import pkg from "../../package.json"
 import { initI18n, t } from "./i18n"
 import { initI18n, t } from "./i18n"
-import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
+import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
 import "./styles.css"
 import "./styles.css"
 import { useTheme } from "@opencode-ai/ui/theme"
 import { useTheme } from "@opencode-ai/ui/theme"
 
 
@@ -274,6 +274,10 @@ const createPlatform = (): Platform => {
 
 
     webviewZoom,
     webviewZoom,
 
 
+    getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
+
+    setPinchZoomEnabled,
+
     runDesktopMenuAction,
     runDesktopMenuAction,
 
 
     checkAppExists: async (appName: string) => {
     checkAppExists: async (appName: string) => {

+ 80 - 1
packages/desktop/src/renderer/webview-zoom.ts

@@ -13,9 +13,21 @@ const OS_NAME = (() => {
 
 
 const [webviewZoom, setWebviewZoom] = createSignal(1)
 const [webviewZoom, setWebviewZoom] = createSignal(1)
 let requestedZoom = 1
 let requestedZoom = 1
+let pinchZoomEnabled = false
+let wheelPinch = undefined as
+  | {
+      active: boolean
+      startZoom: number
+      totalDelta: number
+      timeout: ReturnType<typeof setTimeout> | undefined
+    }
+  | undefined
 
 
 const MAX_ZOOM_LEVEL = 10
 const MAX_ZOOM_LEVEL = 10
 const MIN_ZOOM_LEVEL = 0.2
 const MIN_ZOOM_LEVEL = 0.2
+const WHEEL_PINCH_THRESHOLD = 20
+const WHEEL_PINCH_STEP = 0.2
+const WHEEL_PINCH_END_DELAY = 160
 
 
 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
 
 
@@ -33,10 +45,77 @@ const applyZoom = (next: number) => {
     })
     })
 }
 }
 
 
+window.api.onZoomFactorChanged((factor) => {
+  requestedZoom = clamp(factor)
+  setWebviewZoom(requestedZoom)
+})
+
+void window.api.getPinchZoomEnabled().then((enabled) => {
+  pinchZoomEnabled = enabled
+})
+
+window.api.onPinchZoomEnabledChanged((enabled) => {
+  pinchZoomEnabled = enabled
+  resetWheelPinch()
+})
+
+const setPinchZoomEnabled = (enabled: boolean) => {
+  pinchZoomEnabled = enabled
+  resetWheelPinch()
+  return window.api.setPinchZoomEnabled(enabled)
+}
+
 const resetZoom = () => applyZoom(1)
 const resetZoom = () => applyZoom(1)
 const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
 const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
 const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
 const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
 
 
+const resetWheelPinch = () => {
+  clearTimeout(wheelPinch?.timeout)
+  wheelPinch = undefined
+}
+
+const normalizeWheelDelta = (event: WheelEvent) => {
+  if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16
+  if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * window.innerHeight
+  return event.deltaY
+}
+
+const updateWheelPinch = (event: WheelEvent) => {
+  wheelPinch ??= {
+    active: false,
+    startZoom: requestedZoom,
+    totalDelta: 0,
+    timeout: undefined,
+  }
+
+  clearTimeout(wheelPinch.timeout)
+  wheelPinch.timeout = setTimeout(resetWheelPinch, WHEEL_PINCH_END_DELAY)
+  wheelPinch.totalDelta += normalizeWheelDelta(event)
+
+  if (!wheelPinch.active && Math.abs(wheelPinch.totalDelta) < WHEEL_PINCH_THRESHOLD) return
+  if (!wheelPinch.active) {
+    wheelPinch.active = true
+    wheelPinch.startZoom = requestedZoom
+    wheelPinch.totalDelta = 0
+    return
+  }
+
+  wheelPinch.active = true
+  applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP))
+}
+
+window.addEventListener(
+  "wheel",
+  (event) => {
+    if (!pinchZoomEnabled) return
+    if (!event.ctrlKey) return
+
+    event.preventDefault()
+    updateWheelPinch(event)
+  },
+  { passive: false },
+)
+
 window.addEventListener("keydown", (event) => {
 window.addEventListener("keydown", (event) => {
   if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
   if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
 
 
@@ -56,4 +135,4 @@ window.addEventListener("keydown", (event) => {
   }
   }
 })
 })
 
 
-export { webviewZoom, resetZoom, zoomIn, zoomOut }
+export { webviewZoom, resetZoom, setPinchZoomEnabled, zoomIn, zoomOut }