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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-18 22:09:31 +02:00
1 parent ab239b5043
commit 67327bfd29
38 files changed
+705 -31

No files matched your search

@@ -179,6 +179,116 @@ describe('WorkspaceKeyboardShortcutsService', () => {
});
});
describe('zoom shortcuts', () => {
const testWindow = window as unknown as {
electron?: Record<string, unknown>;
};
let adjustZoomLevel: jest.Mock;
function press(
init: KeyboardEventInit,
target: EventTarget = document
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
...init,
});
target.dispatchEvent(event);
return event;
}
beforeEach(() => {
adjustZoomLevel = jest.fn().mockReturnValue(0.5);
testWindow.electron = { adjustZoomLevel, platform: 'linux' };
});
afterEach(() => {
delete testWindow.electron;
});
it('zooms in, out and resets through the bridge with Ctrl on Linux/Windows', () => {
const zoomIn = press({ key: '=', ctrlKey: true });
const zoomOut = press({ key: '-', ctrlKey: true });
const reset = press({ key: '0', ctrlKey: true });
expect(adjustZoomLevel.mock.calls).toEqual([
['in'],
['out'],
['reset'],
]);
expect(zoomIn.defaultPrevented).toBe(true);
expect(zoomOut.defaultPrevented).toBe(true);
expect(reset.defaultPrevented).toBe(true);
expect(dialog.open).not.toHaveBeenCalled();
});
it('takes the numpad keys and Ctrl+Shift+=', () => {
press({ key: '+', code: 'NumpadAdd', ctrlKey: true });
press({ key: '+', ctrlKey: true, shiftKey: true });
expect(adjustZoomLevel.mock.calls).toEqual([['in'], ['in']]);
});
it('follows the bridge platform: Cmd on macOS, and swallows the key so the menu role cannot step twice', () => {
testWindow.electron = { adjustZoomLevel, platform: 'darwin' };
const ctrl = press({ key: '=', ctrlKey: true });
const cmd = press({ key: '=', metaKey: true });
expect(adjustZoomLevel.mock.calls).toEqual([['in']]);
expect(ctrl.defaultPrevented).toBe(false);
expect(cmd.defaultPrevented).toBe(true);
});
it('works while typing in an input, like the browser zoom it replaces', () => {
const input = document.createElement('input');
document.body.appendChild(input);
press({ key: '=', ctrlKey: true }, input);
expect(adjustZoomLevel).toHaveBeenCalledWith('in');
input.remove();
});
it('leaves a key another handler already consumed alone', () => {
const event = new KeyboardEvent('keydown', {
key: '=',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
event.preventDefault();
document.dispatchEvent(event);
expect(adjustZoomLevel).not.toHaveBeenCalled();
});
it('ignores unrelated Ctrl combinations and unmodified keys', () => {
press({ key: 'k', ctrlKey: true });
press({ key: '=' });
press({ key: '=', ctrlKey: true, altKey: true });
expect(adjustZoomLevel).not.toHaveBeenCalled();
});
it('leaves zoom to the browser without a bridge', () => {
delete testWindow.electron;
const event = press({ key: '=', ctrlKey: true });
expect(event.defaultPrevented).toBe(false);
});
it('swallows a bridge failure', () => {
adjustZoomLevel.mockImplementation(() => {
throw new Error('frame gone');
});
expect(() => press({ key: '=', ctrlKey: true })).not.toThrow();
});
});
it('does not open duplicate dialogs while one is active', () => {
service.openShortcutsDialog();
service.openShortcutsDialog();
@@ -5,6 +5,7 @@ import {
getKeyboardShortcutGroups,
isKeyboardShortcutHelpTrigger,
isTypingInInput,
resolveZoomShortcutAction,
} from '@iptvnator/portal/shared/util';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
@@ -78,7 +79,10 @@ export class WorkspaceKeyboardShortcutsService {
}
private handleKeydown(event: KeyboardEvent): void {
if (this.handleWindowFullscreenToggle(event)) {
if (
this.handleWindowFullscreenToggle(event) ||
this.handleZoomShortcut(event)
) {
return;
}
@@ -129,6 +133,44 @@ export class WorkspaceKeyboardShortcutsService {
return true;
}
/**
* Cmd/Ctrl and +/−/0 zoom the app (issue #1109). Like F11 this is not
* gated by `isTypingInInput`: browsers zoom from any focus, and the
* combination is not one a text field consumes. The bridge steps the
* frame-bound zoom level itself, so no native menu is needed — Windows
* and Linux run without one. On macOS the renderer sees the key before
* the application menu's `zoomIn`/`zoomOut`/`resetZoom` roles do, and
* `preventDefault()` stops the role from stepping a second time, so one
* press is one step on every platform. A key another handler already
* consumed is left alone; without a bridge (PWA) the browser keeps its
* own zoom.
*/
private handleZoomShortcut(event: KeyboardEvent): boolean {
if (event.defaultPrevented) {
return false;
}
const bridge = window.electron;
if (typeof bridge?.adjustZoomLevel !== 'function') {
return false;
}
const action = resolveZoomShortcutAction(event, {
isMac: bridge.platform === 'darwin',
});
if (!action) {
return false;
}
event.preventDefault();
try {
bridge.adjustZoomLevel(action);
} catch {
// The frame refused the write; the level on screen is unchanged.
}
return true;
}
private getShortcutPlatform(): 'mac' | 'other' {
if (typeof navigator === 'undefined') {
return 'other';