From d70d2256b6f45c176a796fe0ab7b8822eb908baa Mon Sep 17 00:00:00 2001 From: Leela Pakanati Date: Fri, 30 Oct 2026 00:54:01 -0500 Subject: [PATCH] fix(desktop): trackpad pinch magnifies the window A trackpad pinch did nothing in the desktop app. Electron turns off Chromium's pinch-zoom, and the zoom-changed handler from #21200 only receives mouse-wheel ticks, so pinch never reached either path. The main window now restores Chromium's visual pinch-zoom (100-500%) on each load. A pinch magnifies the window on the compositor without relayout, and content with its own wheel listener, such as expanded images, still claims the pinch. Actual Size also returns a magnified window to 100%. Native context menus and the snap-shot animation map their coordinates through the visual viewport so they stay under the pointer while magnified. Written by Claude Opus 5.5 in Claude Code (T3 Code). --- apps/desktop/src/window/DesktopWindow.test.ts | 8 +++++ apps/desktop/src/window/DesktopWindow.ts | 17 +++++++++++ apps/web/src/lib/snapShotAnimation.ts | 8 ++--- apps/web/src/lib/visualViewport.ts | 29 +++++++++++++++++++ apps/web/src/localApi.ts | 6 +++- 5 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/visualViewport.ts diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 16d345f97e4b6..ef19f8435c6f3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -77,6 +77,7 @@ function makeFakeBrowserWindow() { setZoomLevel: vi.fn((level: number) => { zoomLevel = level; }), + setVisualZoomLevelLimits: vi.fn((_minimum: number, _maximum: number) => Promise.resolve()), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -138,6 +139,7 @@ function makeFakeBrowserWindow() { toggleDevTools: webContents.toggleDevTools, send: webContents.send, setZoomLevel: webContents.setZoomLevel, + setVisualZoomLevelLimits: webContents.setVisualZoomLevelLimits, setWindowButtonPosition: window.setWindowButtonPosition, setBackgroundThrottling: webContents.setBackgroundThrottling, setAutoHideCursor: window.setAutoHideCursor, @@ -613,11 +615,17 @@ describe("DesktopWindow", () => { yield* desktopWindow.zoomMain("out"); yield* desktopWindow.zoomMain("in"); yield* desktopWindow.zoomMain("reset"); + // Actual Size also undoes a pinch magnification, then lets pinch magnify again. + yield* Effect.promise(() => fakeWindow.setVisualZoomLevelLimits.mock.results[0]!.value); assert.deepEqual( fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), [-0.5, -1, -0.5, 0], ); + assert.deepEqual(fakeWindow.setVisualZoomLevelLimits.mock.calls, [ + [1, 1], + [1, 5], + ]); assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); assert.equal(popup.setZoomLevel.mock.calls.length, 0); }).pipe(Effect.provide(layer)); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0d1db93290ee4..82fcb65c7c88f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -231,6 +231,17 @@ function concealPendingQuitWindow( window.setOpacity(0); } +const PINCH_MAGNIFY_MAX_SCALE = 5; + +function allowPinchMagnify(webContents: Electron.WebContents): Promise { + return webContents.setVisualZoomLevelLimits(1, PINCH_MAGNIFY_MAX_SCALE); +} + +// Collapsing the limits snaps a pinch-magnified window back to 100%. +function resetPinchMagnify(webContents: Electron.WebContents): Promise { + return webContents.setVisualZoomLevelLimits(1, 1).then(() => allowPinchMagnify(webContents)); +} + function getWindowTitleBarOptions( shouldUseDarkColors: boolean, platform: NodeJS.Platform, @@ -340,6 +351,7 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + if (direction === "reset") void resetPinchMagnify(webContents).catch(() => undefined); if (environment.platform === "darwin") syncMacosWindowButtons(window); // App zoom propagates to guests; restore each preview's own zoom. yield* previewManager.reapplyZoom(); @@ -671,6 +683,11 @@ export const make = Effect.gen(function* () { if (window.isDestroyed()) return; runFork(zoomWindow(window, direction)); }); + // Electron turns off trackpad pinch-zoom; restore it so a pinch magnifies the app without + // relayout. Elements with their own wheel listener (expanded images) still claim the pinch. + window.webContents.on("dom-ready", () => { + void allowPinchMagnify(window.webContents).catch(() => undefined); + }); window.on("page-title-updated", (event) => { event.preventDefault(); diff --git a/apps/web/src/lib/snapShotAnimation.ts b/apps/web/src/lib/snapShotAnimation.ts index 9ecca95db76d2..c9840d2f5e4fb 100644 --- a/apps/web/src/lib/snapShotAnimation.ts +++ b/apps/web/src/lib/snapShotAnimation.ts @@ -3,6 +3,7 @@ import type { ScopedThreadRef, SnapShotSource } from "@t3tools/contracts"; import type { DraftId } from "../composerDraftStore"; import { getDesktopSnapShotBridge } from "./desktopSnapShot"; +import { toVisualViewportRect } from "./visualViewport"; type SnapShotTarget = DraftId | ScopedThreadRef; @@ -117,12 +118,7 @@ export function setSnapShotAnimationDestination( const request = bridge .setSnapShotAnimationDestination({ id, - viewportFrame: { - x: frame.x, - y: frame.y, - width: frame.width, - height: frame.height, - }, + viewportFrame: toVisualViewportRect(frame), backgroundColor: style.backgroundColor, borderColor: style.borderTopColor, borderWidth: Number.isFinite(borderWidth) ? borderWidth : 0, diff --git a/apps/web/src/lib/visualViewport.ts b/apps/web/src/lib/visualViewport.ts new file mode 100644 index 0000000000000..19cbf5c4bcddc --- /dev/null +++ b/apps/web/src/lib/visualViewport.ts @@ -0,0 +1,29 @@ +interface Point { + readonly x: number; + readonly y: number; +} + +interface Rect extends Point { + readonly width: number; + readonly height: number; +} + +// clientX/Y and getBoundingClientRect are layout-viewport coordinates; native window coordinates +// follow what is drawn, which moves while the desktop window is pinch-magnified and panned. +export function toVisualViewportPoint(point: Point): Point { + const viewport = window.visualViewport; + if (!viewport) return point; + return { + x: (point.x - viewport.offsetLeft) * viewport.scale, + y: (point.y - viewport.offsetTop) * viewport.scale, + }; +} + +export function toVisualViewportRect(rect: Rect): Rect { + const scale = window.visualViewport?.scale ?? 1; + return { + ...toVisualViewportPoint(rect), + width: rect.width * scale, + height: rect.height * scale, + }; +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index b505a4f416200..8ca7eb634788b 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -3,6 +3,7 @@ import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/c import { requestConfirmDialog } from "./confirmDialog"; import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; +import { toVisualViewportPoint } from "./lib/visualViewport"; let cachedApi: LocalApi | undefined; @@ -45,7 +46,10 @@ export function createLocalApi(): LocalApi { position?: { x: number; y: number }, ): Promise => { if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; + return window.desktopBridge.showContextMenu( + items, + position && toVisualViewportPoint(position), + ) as Promise; } return showContextMenuFallback(items, position); },