Files
iptvnator/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts
4grayandClaude Fable 5.1 c016c73f86 feat(shell): zoom shortcuts on Windows and Linux (#1109) (#1623)
* feat(shell): zoom shortcuts on Windows and Linux (#1109)

Cmd/Ctrl and +/−/0 (numpad included) now zoom the app on every platform.
Windows/Linux run without a menu (`setMenu(null)`), so the shortcuts are a
renderer key binding in `WorkspaceKeyboardShortcutsService` calling a new
synchronous, preload-local bridge method `adjustZoomLevel`, which steps the
frame-bound temporary level through `webFrame.setZoomLevel` — never a
main-process `webContents.setZoomLevel`, whose per-URL entry the app's
`file://` path routing resets. Step and limits live in
`libs/shared/interfaces` (`stepZoomLevel`: 0.5 per press like Electron's
zoomIn/zoomOut roles, clamped to levels −4…6). On macOS the renderer sees the
key before the application menu, and `preventDefault()` keeps the menu role
from stepping a second time (Electron only performs the menu key equivalent
in its unhandled-keyboard-event hook).

Persistence is unchanged: the main process still reads the live level back
on close, quit and reload. The zoom E2E now drives the real shortcuts (in,
out, numpad, reset). Help dialog entries added and translated for all
locales; contract updated in docs/architecture/workspace-shell.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(shell): never step a stored out-of-range zoom level against the request

A level persisted before the shortcuts existed (the macOS menu roles never
clamped, and the store restores any finite level) was clamped BEFORE the
step, so the first zoom-in from level 7 rendered smaller. Step from the raw
level instead: a press further out leaves an out-of-range level where it is,
a press back in lands on the limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(shell): state the zoom bridge's return contract precisely

`adjustZoomLevel` steps by `stepZoomLevel`'s rules; a stored out-of-range
level is never moved against the request, so the returned level is not
itself guaranteed to be within `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(release): add a release note for the zoom shortcuts

The Release note gate requires an added `.changes/*.md` for runtime changes;
the shortcuts are a user-visible feature of their own, so they get their own
note and the persistence note stays about persistence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:47:32 +02:00

147 lines
5.4 KiB
TypeScript

import {
closeElectronApp,
expect,
launchElectronApp,
LaunchedElectronApp,
openSources,
restartElectronApp,
test,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
/**
* Chromium's zoom factor as the renderer actually renders it: the window's
* content width in device-independent pixels over the CSS pixels the page
* sees. Independent of `getZoomLevel()`, which under file:// reports the
* per-URL entry and can disagree with what is on screen.
*/
async function renderedZoomFactor(app: LaunchedElectronApp): Promise<number> {
const contentWidth = await app.electronApp.evaluate(({ BrowserWindow }) => {
const [win] = BrowserWindow.getAllWindows();
return win.getContentSize()[0];
});
const innerWidth = await app.mainWindow.evaluate(() => window.innerWidth);
return contentWidth / innerWidth;
}
/**
* The app zoom shortcuts as the renderer receives them: Cmd on macOS, Ctrl
* elsewhere. Dispatched through CDP they reach the renderer's keydown
* binding directly (`WorkspaceKeyboardShortcutsService`), which steps the
* frame-bound level through the preload bridge; the macOS application menu
* is not in this path.
*/
const ZOOM_MODIFIER = process.platform === 'darwin' ? 'Meta' : 'Control';
async function pressZoomShortcut(
app: LaunchedElectronApp,
key: 'Equal' | 'Minus' | 'Digit0' | 'NumpadAdd' | 'NumpadSubtract',
times = 1
): Promise<void> {
for (let index = 0; index < times; index += 1) {
await app.mainWindow.keyboard.press(`${ZOOM_MODIFIER}+${key}`);
}
}
/**
* A cross-document navigation of the renderer (what a reload is for zoom:
* Chromium drops the temporary level and the new document's preload must
* restore it). A real reload of the routed `file://` URL: the main process
* recovers the missing file by re-loading the index on the same route
* (`renderer-reload.e2e.ts`).
*/
async function reloadRenderer(app: LaunchedElectronApp): Promise<void> {
await reloadFromMainProcess(app);
await expectRendererReloadedOnRoute(
app.mainWindow,
/\/workspace\/sources$/
);
}
async function resizeWindowBy(
app: LaunchedElectronApp,
delta: number
): Promise<void> {
await app.electronApp.evaluate(({ BrowserWindow }, step) => {
const [win] = BrowserWindow.getAllWindows();
const [width, height] = win.getSize();
win.setSize(width + step, height + step);
}, delta);
}
// Zoom level 1 is a 1.2 factor; each shortcut press steps the level by 0.5
// (Electron's zoomIn/zoomOut role step). The packaged renderer runs under
// file:// with path routing, where Chromium keys zoom by full URL: without
// frame-bound (temporary) zoom the level "holds" only until the next resize
// after a section change, and a restart brings back the default (issue #1109).
const ZOOMED_FACTOR = 1.2;
const HALF_STEP_FACTOR = Math.sqrt(ZOOMED_FACTOR);
test('@electron @window keeps the zoom level across sections, resizes and a restart', async ({
dataDir,
}) => {
let app = await launchElectronApp(dataDir);
try {
expect(await renderedZoomFactor(app)).toBeCloseTo(1, 1);
// In three times, out once (main keys and numpad): level 1.
await pressZoomShortcut(app, 'Equal', 2);
await pressZoomShortcut(app, 'NumpadAdd');
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR * HALF_STEP_FACTOR, 1);
await pressZoomShortcut(app, 'Minus');
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
// Reset returns to level 0, then zoom back in (three in, one out on
// the numpad) for the persistence checks below.
await pressZoomShortcut(app, 'Digit0');
await expect.poll(() => renderedZoomFactor(app)).toBeCloseTo(1, 1);
await pressZoomShortcut(app, 'Equal', 3);
await pressZoomShortcut(app, 'NumpadSubtract');
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
// A route change (new file:// URL) followed by the visual-properties
// sync a resize forces: per-URL zoom snaps back to 1 here.
await openSources(app.mainWindow);
await resizeWindowBy(app, 24);
await app.mainWindow.waitForTimeout(500);
expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1);
// A reload rebuilds the document; the level must be saved before it
// and re-applied by the new document's preload.
await reloadRenderer(app);
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await expect
.poll(() => renderedZoomFactor(app))
.toBeCloseTo(ZOOMED_FACTOR, 1);
expect(
await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0].webContents.getZoomLevel()
)
).toBe(1);
await openSources(app.mainWindow);
await resizeWindowBy(app, -24);
await app.mainWindow.waitForTimeout(500);
expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1);
} finally {
await closeElectronApp(app);
}
});