diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7b76a1b8003a..edb1a759783c 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1540,6 +1540,87 @@ describe("PreviewManager", () => { ), ); + // Guest innerWidth is CSS pixels of the webview. A frame declared in host CSS + // occupies hostCss × windowZoom device pixels; the guest must zoom by that + // same window factor (times the preview-only factor) or preview_resize waits + // forever for 1280 and sees 2019. + effectIt.effect("applies preview zoom times the main window zoom onto the guest", () => + withManager((manager) => + Effect.gen(function* () { + const windowZoom = 1.2 ** 2.5; + const setZoomFactor = vi.fn(); + const hostWebContents = Object.assign(makeTestHostWebContents(), { + getZoomFactor: () => windowZoom, + }); + const wc = Object.assign( + makeTestPreviewWebContents( + async () => ({ + toJPEG: () => Buffer.from("png"), + getSize: () => ({ width: 1280, height: 720 }), + }), + 42, + hostWebContents, + ), + { setZoomFactor }, + ); + fromId.mockReturnValue(wc); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: hostWebContents, + } as never); + yield* manager.createTab("tab_window_zoom"); + yield* manager.registerWebview("tab_window_zoom", 42); + + expect(setZoomFactor).toHaveBeenCalledWith(windowZoom); + + yield* manager.zoomIn("tab_window_zoom"); + expect(setZoomFactor).toHaveBeenLastCalledWith(1.1 * windowZoom); + + setZoomFactor.mockClear(); + yield* manager.reapplyZoom(); + expect(setZoomFactor).toHaveBeenCalledTimes(1); + expect(setZoomFactor).toHaveBeenCalledWith(1.1 * windowZoom); + }), + ), + ); + + effectIt.effect( + "keeps a 1.25 preview zoom at the declared CSS size when the window is zoomed", + () => + withManager((manager) => + Effect.gen(function* () { + const windowZoom = 1.2 ** 2.5; + const tabZoom = 1.25; + const setZoomFactor = vi.fn(); + const hostWebContents = Object.assign(makeTestHostWebContents(), { + getZoomFactor: () => windowZoom, + }); + const wc = Object.assign( + makeTestPreviewWebContents( + async () => ({ + toJPEG: () => Buffer.from("png"), + getSize: () => ({ width: 1280, height: 720 }), + }), + 42, + hostWebContents, + ), + { setZoomFactor }, + ); + fromId.mockReturnValue(wc); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: hostWebContents, + } as never); + yield* manager.createTab("tab_product_zoom", { zoomFactor: tabZoom }); + yield* manager.registerWebview("tab_product_zoom", 42); + + expect(setZoomFactor).toHaveBeenCalledWith(tabZoom * windowZoom); + }), + ), + ); + // did-attach and dom-ready both re-register the guest that is already // attached, and a guest that just inherited the app window's zoom needs its // own back — without that round trip republishing tab state. diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index a2e34fe54736..a056a6c45d70 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -953,6 +953,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); + /** + * Embedder zoom, not display scale. Guest `innerWidth` is in CSS pixels of + * the webview; a `` sized in host CSS pixels occupies + * `hostCss × windowZoom` device pixels. Setting the guest to the preview + * factor alone then reports that product, so a 1280 CSS request never matches. + * Stored `tab.zoomFactor` stays preview-only; the guest gets the product. + */ + const readMainWindowZoomFactor = (): number => { + const mainWindow = currentMainWindow; + if (mainWindow === undefined || mainWindow.isDestroyed()) return DEFAULT_ZOOM_FACTOR; + try { + const factor = mainWindow.webContents.getZoomFactor(); + return Number.isFinite(factor) && factor > 0 ? factor : DEFAULT_ZOOM_FACTOR; + } catch { + return DEFAULT_ZOOM_FACTOR; + } + }; + + const guestZoomFactor = (tabZoomFactor: number): number => + tabZoomFactor * readMainWindowZoomFactor(); + /** * Pushes a tab's zoom factor onto whichever guest it currently owns, reading * both at call time. Anything that applies zoom after an await goes through @@ -965,7 +986,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = webContents.fromId(tab.webContentsId); if (!wc || wc.isDestroyed()) return; yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => - wc.setZoomFactor(tab.zoomFactor), + wc.setZoomFactor(guestZoomFactor(tab.zoomFactor)), ).pipe(Effect.ignore); }); @@ -2187,12 +2208,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Always assert the tab's own zoom rather than reading the guest's: a guest - // attaching while the app UI is zoomed starts at the embedder's inherited - // zoom level, which is not the preview's zoom. Done before the guest is - // published so it never paints a frame at the inherited zoom. + // Always assert the tab's own zoom (times the window zoom) rather than + // reading the guest's: a guest attaching while the app UI is zoomed starts + // at the embedder's inherited zoom level, which is not the preview's zoom. + // Done before the guest is published so it never paints a frame at the + // inherited zoom. yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => - wc.setZoomFactor(currentTab.zoomFactor), + wc.setZoomFactor(guestZoomFactor(currentTab.zoomFactor)), ); // A replacement guest attaches unmuted, so reassert the tab's mute before it // is published rather than letting it emit audio the user already silenced. @@ -2609,10 +2631,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); /** - * Chromium hands every guest `` the embedder's zoom level, so zooming - * the app UI drags the previewed page along with it. The preview browser owns - * its own zoom factor, so re-assert it on each attached guest whenever the main - * window's zoom changes (see DesktopWindow.zoomMain). + * Chromium hands every guest `` the embedder's zoom level. Re-assert + * `previewZoom × windowZoom` on each attached guest whenever the main window + * zooms (see DesktopWindow.zoomMain) so declared CSS viewports stay put. */ const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); @@ -2631,7 +2652,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = webContents.fromId(tab.webContentsId); if (wc && !wc.isDestroyed()) { yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () => - wc.setZoomFactor(next), + wc.setZoomFactor(guestZoomFactor(next)), ); } } diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b3964cb929c7..7d3843e404a7 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1012,9 +1012,9 @@ export const make = Effect.gen(function* () { direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); if (environment.platform === "darwin") syncMacosWindowButtons(window.value); - // Chromium pushes the new level down to embedded guests, which would zoom - // the previewed page along with the app UI. The preview browser keeps its - // own zoom, so put each guest back where the preview left it. + // Chromium pushes the new level down to embedded guests. Re-assert each + // guest at previewZoom × windowZoom so declared CSS viewports stay put + // while the visible frame still tracks the app UI zoom. yield* previewManager.reapplyZoom(); }), syncAppearance: Effect.gen(function* () {