Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 32 additions & 11 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<webview>` 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
Expand All @@ -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);
});

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2609,10 +2631,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
});

/**
* Chromium hands every guest `<webview>` 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 `<webview>` 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());
Expand All @@ -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)),
);
}
}
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
Loading