diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index d3be9aac78..73a711c416 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -38,7 +38,6 @@ export type BrowserStatePayload = { declare global { interface Window { - __OPENWORK_ZOOM_FACTOR__?: number; __OPENWORK_ELECTRON__?: { invokeDesktop?: (command: string, ...args: unknown[]) => Promise; shell?: { diff --git a/apps/app/src/react-app/domains/session/panel/utils.ts b/apps/app/src/react-app/domains/session/panel/utils.ts index 3ae2fa4d27..b312dc95c7 100644 --- a/apps/app/src/react-app/domains/session/panel/utils.ts +++ b/apps/app/src/react-app/domains/session/panel/utils.ts @@ -8,27 +8,20 @@ export function getElectronBrowser() { return window.__OPENWORK_ELECTRON__?.browser ?? null; } -// The renderer uses Electron's webContents.setZoomFactor, which scales the page -// so getBoundingClientRect() / innerWidth report CSS pixels DIVIDED by the zoom -// factor (e.g. at zoom 1.5 a 1180 DIP window measures ~786). WebContentsView -// bounds, however, are in window device-independent pixels. So renderer rects -// must be multiplied back by the zoom factor to land in the native coordinate -// space. At zoom = 1 this is the identity. -function getZoomFactor() { - const zoom = window.__OPENWORK_ZOOM_FACTOR__; - return typeof zoom === "number" && zoom > 0 ? zoom : 1; -} - +// Bounds and points are sent to the main process in the page's CSS pixels. +// The main process converts them to window DIPs using the authoritative +// webContents.getZoomFactor() at apply time (see scaleBoundsForZoom in +// apps/desktop/electron/main.mjs). The renderer must NOT pre-scale: it has no +// reliable view of the zoom factor (native View menu zoom roles change it +// without notifying the page). export function getNativeMenuPoint( el: HTMLElement | null, point?: { clientX: number; clientY: number }, ) { - const zoom = getZoomFactor(); - if (point) { return { - x: Math.round(point.clientX * zoom), - y: Math.round(point.clientY * zoom), + x: Math.round(point.clientX), + y: Math.round(point.clientY), }; } @@ -39,24 +32,21 @@ export function getNativeMenuPoint( const rect = el.getBoundingClientRect(); return { - x: Math.round((rect.left + 8) * zoom), - y: Math.round((rect.bottom + 4) * zoom), + x: Math.round(rect.left + 8), + y: Math.round(rect.bottom + 4), }; } export function computeBounds(el: HTMLElement) { - // Scale each edge to native DIP, then derive width/height from the rounded - // edges so the far edge has no sub-pixel seam at any zoom level. const rect = el.getBoundingClientRect(); - const zoom = getZoomFactor(); - const x = Math.round(rect.x * zoom); - const y = Math.round(rect.y * zoom); + const x = Math.round(rect.x); + const y = Math.round(rect.y); return { x, y, - width: Math.round(rect.right * zoom) - x, - height: Math.round(rect.bottom * zoom) - y, + width: Math.round(rect.right) - x, + height: Math.round(rect.bottom) - y, }; } diff --git a/apps/app/src/react-app/shell/font-zoom.ts b/apps/app/src/react-app/shell/font-zoom.ts index 6cb8e6c0ef..c93795e20e 100644 --- a/apps/app/src/react-app/shell/font-zoom.ts +++ b/apps/app/src/react-app/shell/font-zoom.ts @@ -21,10 +21,6 @@ export function useDesktopFontZoomBehavior() { const next = normalizeFontZoom(value); persistFontZoom(window.localStorage, next); - // Keep the current desktop zoom available so native WebContentsView bounds - // can be converted from renderer CSS pixels to contentView coordinates. - window.__OPENWORK_ZOOM_FACTOR__ = next; - void setDesktopZoomFactor(next) .then((applied) => { if (applied) { diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 0ecf3057ab..c9be3e3721 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -939,6 +939,37 @@ function isHttpUrl(url) { } } +// Renderer-supplied geometry is in the page's CSS pixels. WebContentsView +// bounds are in window DIPs: at zoom factor Z, DIP = cssPx * Z. The renderer +// cannot reliably know Z (the native View menu zoom roles change it without +// notifying the page, which is how stale-zoom distortion bugs crept in), so +// the main process owns this conversion and reads the factor at apply time. +function browserZoomFactor() { + const zoom = mainWindow?.webContents?.getZoomFactor?.(); + return Number.isFinite(zoom) && zoom > 0 ? zoom : 1; +} + +function scaleBoundsForZoom(bounds) { + const zoom = browserZoomFactor(); + if (zoom === 1) return bounds; + // Scale each edge, then derive width/height from the rounded edges so the + // far edge has no sub-pixel seam at any zoom level. + const x = Math.round(bounds.x * zoom); + const y = Math.round(bounds.y * zoom); + return { + x, + y, + width: Math.round((bounds.x + bounds.width) * zoom) - x, + height: Math.round((bounds.y + bounds.height) * zoom) - y, + }; +} + +function scalePointForZoom(point) { + const zoom = browserZoomFactor(); + if (zoom === 1 || !point || typeof point !== "object") return point; + return { x: Number(point.x) * zoom, y: Number(point.y) * zoom }; +} + function normalizeMenuOverlayPoint(point) { if (!point || typeof point !== "object") { return { x: 0, y: 0 }; @@ -1051,7 +1082,7 @@ function tabMenuRequest(tab, point) { source: "tab", tabId: tab.tabId, url, - bounds: menuOverlayBounds(normalizeMenuOverlayPoint(point)), + bounds: menuOverlayBounds(normalizeMenuOverlayPoint(scalePointForZoom(point))), items: [ { id: "copy-url", label: "Copy URL", iconName: "copy", disabled: !url }, { id: "open-external", label: "Open in Browser", iconName: "external", disabled: !(url && isHttpUrl(url)) }, @@ -1198,7 +1229,7 @@ function attachActiveBrowserView() { mainWindow.contentView.addChildView(view); } if (lastBrowserBounds && lastBrowserBounds.width > 0 && lastBrowserBounds.height > 0) { - view.setBounds(lastBrowserBounds); + view.setBounds(scaleBoundsForZoom(lastBrowserBounds)); } } @@ -1297,7 +1328,7 @@ function attachBrowserView(bounds, { preloadDefault = false, ensureTab = false } const view = getActiveBrowserView(); attachActiveBrowserView(); if (bounds.width > 0 && bounds.height > 0) { - view?.setBounds(bounds); + view?.setBounds(scaleBoundsForZoom(bounds)); } const url = view?.webContents.getURL(); if (preloadDefault && (!url || url === "about:blank")) { @@ -3241,7 +3272,7 @@ ipcMain.handle("openwork:browser:bounds", (_event, bounds) => { lastBrowserBounds = bounds; const view = getActiveBrowserView(); if (view && browserViewVisible && bounds.width > 0 && bounds.height > 0) { - view.setBounds(bounds); + view.setBounds(scaleBoundsForZoom(bounds)); } }); ipcMain.handle("openwork:browser:state", () => browserStatePayload());