From 67327bfd293dbf9f27a2aa2cc63b506da3637336 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 18 Sep 2026 22:09:31 +0200 Subject: [PATCH] feat(shell): zoom shortcuts on Windows and Linux (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changes/shell-zoom-level-persistence.md | 8 +- CLAUDE.md | 2 +- .../src/window-zoom-level.e2e.ts | 51 ++++++-- .../src/app/api/main.preload.spec.ts | 18 +++ .../src/app/api/main.preload.ts | 16 ++- .../src/app/api/preload-zoom-level.spec.ts | 48 ++++++- .../src/app/api/preload-zoom-level.ts | 32 +++++ apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/nx-workspace-boundaries.md | 7 +- docs/architecture/workspace-shell.md | 51 ++++++-- .../src/lib/keyboard-shortcut-definitions.ts | 20 +++ .../util/src/lib/keyboard-shortcuts.spec.ts | 123 ++++++++++++++++++ .../shared/util/src/lib/keyboard-shortcuts.ts | 46 +++++++ libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 11 ++ .../src/lib/zoom-level.util.spec.ts | 48 +++++++ .../interfaces/src/lib/zoom-level.util.ts | 59 +++++++++ ...rkspace-keyboard-shortcuts.service.spec.ts | 110 ++++++++++++++++ .../workspace-keyboard-shortcuts.service.ts | 44 ++++++- tsconfig.base.json | 3 + 38 files changed, 705 insertions(+), 31 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/zoom-level.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/zoom-level.util.ts diff --git a/.changes/shell-zoom-level-persistence.md b/.changes/shell-zoom-level-persistence.md index b85a3451c..68047d495 100644 --- a/.changes/shell-zoom-level-persistence.md +++ b/.changes/shell-zoom-level-persistence.md @@ -4,6 +4,8 @@ area: shell issues: [1109] --- -The app now remembers your zoom level (Cmd and +/- on macOS): it holds when -you switch between sections or resize the window, and comes back after a -restart, so a larger font size no longer resets to the default. +The app now remembers your zoom level (Cmd/Ctrl and +/-, Cmd/Ctrl+0 to +reset): it holds when you switch between sections or resize the window, and +comes back after a restart, so a larger font size no longer resets to the +default. Windows and Linux gain the zoom shortcuts, which had only worked +through the macOS menu. diff --git a/CLAUDE.md b/CLAUDE.md index 0168e6922..73effccb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -769,7 +769,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication - Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen -- Persists the app zoom level (issue #1109): the preload restores it with `webFrame.setZoomLevel` (temporary, frame-bound zoom; level answered over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC, applied at `DOMContentLoaded` and acknowledged with `WINDOW:ZOOM_LEVEL_APPLIED` — any earlier `webFrame.setZoomLevel` leaves a hidden Linux/Windows window without `ready-to-show`), never `webContents.setZoomLevel` — under `file://` Chromium keys zoom by the full URL, so the app's path routing would reset it on the next resize after a section change, and dev mode (`http://localhost`) never shows that. `app/services/window-zoom-level.ts` writes the live level to electron-conf `ZOOM_LEVEL` on close, `before-quit` and before every cross-document navigation (a reload drops the temporary level). Contract: `docs/architecture/workspace-shell.md`, "Zoom level" +- Persists the app zoom level (issue #1109): the preload restores it with `webFrame.setZoomLevel` (temporary, frame-bound zoom; level answered over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC, applied at `DOMContentLoaded` and acknowledged with `WINDOW:ZOOM_LEVEL_APPLIED` — any earlier `webFrame.setZoomLevel` leaves a hidden Linux/Windows window without `ready-to-show`), never `webContents.setZoomLevel` — under `file://` Chromium keys zoom by the full URL, so the app's path routing would reset it on the next resize after a section change, and dev mode (`http://localhost`) never shows that. `app/services/window-zoom-level.ts` writes the live level to electron-conf `ZOOM_LEVEL` on close, `before-quit` and before every cross-document navigation (a reload drops the temporary level). The zoom shortcuts (Cmd/Ctrl and +/−/0, numpad included) are a renderer key binding in `WorkspaceKeyboardShortcutsService` — the Windows/Linux window has no menu (`setMenu(null)`) — calling the synchronous preload-local bridge method `adjustZoomLevel`, which steps the same frame-bound level (`stepZoomLevel` in `libs/shared/interfaces`: 0.5 per press like Electron's `zoomIn`/`zoomOut` roles, clamped to levels −4…6); on macOS the renderer's `preventDefault()` keeps the menu role from stepping a second time. Contract: `docs/architecture/workspace-shell.md`, "Zoom level" - Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded. **Database**: diff --git a/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts index e8a27c64e..5b0acf8ff 100644 --- a/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts +++ b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts @@ -25,12 +25,23 @@ async function renderedZoomFactor(app: LaunchedElectronApp): Promise { return contentWidth / innerWidth; } -/** What the macOS menu roles (Cmd +/−) do: `webContents.zoomLevel += 1`. */ -async function zoomInFromMenu(app: LaunchedElectronApp): Promise { - await app.electronApp.evaluate(({ BrowserWindow }) => { - const [win] = BrowserWindow.getAllWindows(); - win.webContents.setZoomLevel(win.webContents.getZoomLevel() + 1); - }); +/** + * 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 { + for (let index = 0; index < times; index += 1) { + await app.mainWindow.keyboard.press(`${ZOOM_MODIFIER}+${key}`); + } } /** @@ -58,11 +69,13 @@ async function resizeWindowBy( }, delta); } -// Zoom level 1 is a 1.2 factor. 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). +// 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, @@ -72,7 +85,23 @@ test('@electron @window keeps the zoom level across sections, resizes and a rest try { expect(await renderedZoomFactor(app)).toBeCloseTo(1, 1); - await zoomInFromMenu(app); + // 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); diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 992b32a75..b634cbbff 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -124,6 +124,24 @@ describe('main preload DB IPC contract', () => { ); }); + it('steps the zoom shortcuts through webFrame without any IPC', () => { + // Same frame-bound temporary zoom as the restore: a main-process + // setZoomLevel would write the per-URL entry the app's file:// + // routing resets (issue #1109). + mockWebFrame.getZoomLevel.mockReturnValue(1); + mockIpcRenderer.invoke.mockClear(); + mockIpcRenderer.send.mockClear(); + + expect(mockExposedApi?.adjustZoomLevel('in')).toBe(1.5); + expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(1.5); + + expect(mockExposedApi?.adjustZoomLevel('reset')).toBe(0); + expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(0); + + expect(mockIpcRenderer.invoke).not.toHaveBeenCalled(); + expect(mockIpcRenderer.send).not.toHaveBeenCalled(); + }); + it('covers every worker-backed DB operation exposed by the preload bridge', () => { const workerChannels = dbPreloadCases .map((contractCase) => contractCase.channel) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 6427003b5..adb8fefb9 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -21,7 +21,10 @@ import { createPreloadPerformanceCapture, toPreloadPerformanceTargetMethod, } from './preload-performance-capture'; -import { applyPersistedZoomLevel } from './preload-zoom-level'; +import { + adjustFrameZoomLevel, + applyPersistedZoomLevel, +} from './preload-zoom-level'; import { createXtreamPreloadPerformanceCapture, isXtreamPreloadPerformanceCaptureEnabled, @@ -277,6 +280,11 @@ function wrapElectronApi(api: T): T { ) as T; } +const frameZoomPorts = { + getZoomLevel: () => webFrame.getZoomLevel(), + setZoomLevel: (level: number) => webFrame.setZoomLevel(level), +}; + const electronApi: ElectronBridgeApi = { // Remote control channel change listener onChannelChange: ( @@ -424,6 +432,9 @@ const electronApi: ElectronBridgeApi = { toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE), toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN), closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE), + // Zoom shortcuts: same frame-bound webFrame route as the restore below; + // synchronous, no IPC — persistence reads the level back in main. + adjustZoomLevel: (action) => adjustFrameZoomLevel(frameZoomPorts, action), getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE), onWindowStateChange: ( callback: (state: ElectronBridgeWindowState) => void @@ -1161,8 +1172,7 @@ const electronApi: ElectronBridgeApi = { // preload-zoom-level.ts for the Linux/Windows ready-to-show trap. applyPersistedZoomLevel({ requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL), - getZoomLevel: () => webFrame.getZoomLevel(), - setZoomLevel: (level) => webFrame.setZoomLevel(level), + ...frameZoomPorts, whenDocumentParsed: (apply) => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', apply, { once: true }); diff --git a/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts b/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts index 3ec787df7..cc72ffa0c 100644 --- a/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts +++ b/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts @@ -1,4 +1,7 @@ -import { applyPersistedZoomLevel } from './preload-zoom-level'; +import { + adjustFrameZoomLevel, + applyPersistedZoomLevel, +} from './preload-zoom-level'; function createPorts(saved: unknown, current = 0) { const parsed: Array<() => void> = []; @@ -70,3 +73,46 @@ describe('applyPersistedZoomLevel', () => { expect(ports.notifyApplied).not.toHaveBeenCalled(); }); }); + +describe('adjustFrameZoomLevel', () => { + function createFrame(current: number) { + return { + getZoomLevel: jest.fn(() => current), + setZoomLevel: jest.fn(), + }; + } + + it('steps the frame level through webFrame and reports the applied level', () => { + const frame = createFrame(0); + + expect(adjustFrameZoomLevel(frame, 'in')).toBe(0.5); + expect(frame.setZoomLevel).toHaveBeenCalledWith(0.5); + + const zoomed = createFrame(0.5); + expect(adjustFrameZoomLevel(zoomed, 'out')).toBe(0); + expect(zoomed.setZoomLevel).toHaveBeenCalledWith(0); + }); + + it('resets to level 0', () => { + const frame = createFrame(2.5); + + expect(adjustFrameZoomLevel(frame, 'reset')).toBe(0); + expect(frame.setZoomLevel).toHaveBeenCalledWith(0); + }); + + it('does not write when the level is already at the limit', () => { + const frame = createFrame(6); + + expect(adjustFrameZoomLevel(frame, 'in')).toBe(6); + expect(frame.setZoomLevel).not.toHaveBeenCalled(); + }); + + it('reports the unchanged level when webFrame refuses the write', () => { + const frame = createFrame(1); + frame.setZoomLevel.mockImplementation(() => { + throw new Error('frame gone'); + }); + + expect(adjustFrameZoomLevel(frame, 'in')).toBe(1); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-zoom-level.ts b/apps/electron-backend/src/app/api/preload-zoom-level.ts index f81511312..b716d9120 100644 --- a/apps/electron-backend/src/app/api/preload-zoom-level.ts +++ b/apps/electron-backend/src/app/api/preload-zoom-level.ts @@ -20,6 +20,11 @@ * the call is harmless, and it still lands before the first Angular paint. */ +import { + stepZoomLevel, + type ZoomLevelAction, +} from '@iptvnator/shared/interfaces/zoom-level'; + export interface PreloadZoomLevelPorts { /** `ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL)` — the stored level or null. */ requestPersistedZoomLevel(): unknown; @@ -61,3 +66,30 @@ export function applyPersistedZoomLevel( return null; } } + +/** + * Bridge half of the zoom shortcuts (`window.electron.adjustZoomLevel`): + * steps the frame's temporary level in place. Same `webFrame` route as the + * restore above, so the result is URL-independent and `getZoomLevel()` in the + * main process reports it for the close/quit/reload persistence. Returns the + * level now applied; a refused write leaves the frame as it was and returns + * its current level, so the caller never learns a level that is not on + * screen. + */ +export function adjustFrameZoomLevel( + ports: Pick, + action: ZoomLevelAction +): number { + const current = ports.getZoomLevel(); + const next = stepZoomLevel(current, action); + + if (next !== current) { + try { + ports.setZoomLevel(next); + } catch { + return current; + } + } + + return next; +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a31140ff7..e924a04c5 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "فتح العناصر المشاهدة مؤخرًا", "SUBMIT_SHELL_SEARCH": "إرسال بحث مساحة العمل", "TOGGLE_WINDOW_FULLSCREEN": "تبديل ملء الشاشة لنافذة التطبيق", + "ZOOM_IN_OUT": "تكبير التطبيق أو تصغيره", + "RESET_ZOOM": "إعادة ضبط تكبير التطبيق", "TOGGLE_SIDEBAR": "تبديل الشريط الجانبي", "M3U_CHANNEL_NUMBER": "اختيار قناة M3U حسب الرقم", "PLAY_PAUSE": "تشغيل أو إيقاف تشغيل Embedded MPV مؤقتًا", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ed44a6ec3..4faf3bf5e 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "حل المتشافين مؤخراً", "SUBMIT_SHELL_SEARCH": "رسل بحث مساحة العمل", "TOGGLE_WINDOW_FULLSCREEN": "بدل ملء الشاشة ديال نافذة التطبيق", + "ZOOM_IN_OUT": "كبر ولا صغر التطبيق", + "RESET_ZOOM": "رجع تكبير التطبيق للأصل", "TOGGLE_SIDEBAR": "بدل عرض الشريط الجانبي", "M3U_CHANNEL_NUMBER": "اختار قناة M3U بالرقم", "PLAY_PAUSE": "شغل ولا وقف مؤقتاً تشغيل MPV المدمج", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index f3405d246..84ff88fa7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Адкрыць нядаўна прагледжанае", "SUBMIT_SHELL_SEARCH": "Выканаць пошук у працоўнай вобласці", "TOGGLE_WINDOW_FULLSCREEN": "Пераключыць поўнаэкранны рэжым акна праграмы", + "ZOOM_IN_OUT": "Павялічыць або паменшыць маштаб праграмы", + "RESET_ZOOM": "Скінуць маштаб праграмы", "TOGGLE_SIDEBAR": "Пераключыць бакавую панэль", "M3U_CHANNEL_NUMBER": "Выбраць M3U-канал па нумары", "PLAY_PAUSE": "Прайграць або прыпыніць Embedded MPV", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 49d141305..773cf630c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Kürzlich angesehene Elemente öffnen", "SUBMIT_SHELL_SEARCH": "Arbeitsbereichssuche absenden", "TOGGLE_WINDOW_FULLSCREEN": "Vollbild für das App-Fenster umschalten", + "ZOOM_IN_OUT": "App vergrößern oder verkleinern", + "RESET_ZOOM": "App-Zoom zurücksetzen", "TOGGLE_SIDEBAR": "Seitenleiste umschalten", "M3U_CHANNEL_NUMBER": "M3U-Kanal per Nummer auswählen", "PLAY_PAUSE": "Eingebettete MPV-Wiedergabe starten oder pausieren", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 1ede1b581..02540dc4a 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Άνοιγμα πρόσφατων προβολών", "SUBMIT_SHELL_SEARCH": "Υποβολή αναζήτησης χώρου εργασίας", "TOGGLE_WINDOW_FULLSCREEN": "Εναλλαγή πλήρους οθόνης παραθύρου εφαρμογής", + "ZOOM_IN_OUT": "Μεγέθυνση ή σμίκρυνση της εφαρμογής", + "RESET_ZOOM": "Επαναφορά ζουμ εφαρμογής", "TOGGLE_SIDEBAR": "Εναλλαγή πλαϊνής μπάρας", "M3U_CHANNEL_NUMBER": "Επιλογή καναλιού M3U με αριθμό", "PLAY_PAUSE": "Αναπαραγωγή ή παύση ενσωματωμένου MPV", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 33bad79c4..b5ff5a57b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Open recently viewed", "SUBMIT_SHELL_SEARCH": "Submit workspace search", "TOGGLE_WINDOW_FULLSCREEN": "Toggle app window fullscreen", + "ZOOM_IN_OUT": "Zoom the app in or out", + "RESET_ZOOM": "Reset app zoom", "TOGGLE_SIDEBAR": "Toggle sidebar", "M3U_CHANNEL_NUMBER": "Select M3U channel by number", "PLAY_PAUSE": "Play or pause embedded MPV playback", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index d8ab65866..43d6a11e9 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Abrir vistos recientemente", "SUBMIT_SHELL_SEARCH": "Enviar búsqueda del espacio de trabajo", "TOGGLE_WINDOW_FULLSCREEN": "Alternar pantalla completa de la ventana de la aplicación", + "ZOOM_IN_OUT": "Acercar o alejar la aplicación", + "RESET_ZOOM": "Restablecer el zoom de la aplicación", "TOGGLE_SIDEBAR": "Mostrar u ocultar barra lateral", "M3U_CHANNEL_NUMBER": "Seleccionar canal M3U por número", "PLAY_PAUSE": "Reproducir o pausar MPV integrado", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 33031f37f..059cf29cc 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Ouvrir les éléments récemment consultés", "SUBMIT_SHELL_SEARCH": "Lancer la recherche dans l’espace de travail", "TOGGLE_WINDOW_FULLSCREEN": "Activer ou quitter le plein écran de la fenêtre", + "ZOOM_IN_OUT": "Zoom avant ou arrière sur l'application", + "RESET_ZOOM": "Réinitialiser le zoom de l'application", "TOGGLE_SIDEBAR": "Afficher ou masquer la barre latérale", "M3U_CHANNEL_NUMBER": "Sélectionner une chaîne M3U par numéro", "PLAY_PAUSE": "Lire ou mettre en pause la lecture MPV intégrée", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index a98066e29..d6124ddd3 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Legutóbbi megtekintések megnyitása", "SUBMIT_SHELL_SEARCH": "Munkaterületi keresés indítása", "TOGGLE_WINDOW_FULLSCREEN": "Az alkalmazásablak teljes képernyős módjának be- vagy kikapcsolása", + "ZOOM_IN_OUT": "Az alkalmazás nagyítása vagy kicsinyítése", + "RESET_ZOOM": "Az alkalmazás nagyításának visszaállítása", "TOGGLE_SIDEBAR": "Oldalsáv megjelenítése vagy elrejtése", "M3U_CHANNEL_NUMBER": "M3U-csatorna kiválasztása a számának beírásával", "PLAY_PAUSE": "A beágyazott MPV lejátszásának indítása vagy szüneteltetése", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e1948ab9a..976571c3e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Apri visti di recente", "SUBMIT_SHELL_SEARCH": "Invia ricerca nell’area di lavoro", "TOGGLE_WINDOW_FULLSCREEN": "Attiva/disattiva schermo intero della finestra", + "ZOOM_IN_OUT": "Ingrandisci o riduci l'applicazione", + "RESET_ZOOM": "Reimposta lo zoom dell'applicazione", "TOGGLE_SIDEBAR": "Mostra/nascondi barra laterale", "M3U_CHANNEL_NUMBER": "Seleziona canale M3U per numero", "PLAY_PAUSE": "Riproduci o metti in pausa MPV integrato", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e096d3631..e0b73686c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "最近視聴した項目を開く", "SUBMIT_SHELL_SEARCH": "ワークスペース検索を実行", "TOGGLE_WINDOW_FULLSCREEN": "アプリウィンドウの全画面表示を切り替え", + "ZOOM_IN_OUT": "アプリを拡大または縮小", + "RESET_ZOOM": "アプリのズームをリセット", "TOGGLE_SIDEBAR": "サイドバーを切り替え", "M3U_CHANNEL_NUMBER": "番号で M3U チャンネルを選択", "PLAY_PAUSE": "Embedded MPV の再生/一時停止", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0730dc3c5..d17299be3 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "최근 시청 항목 열기", "SUBMIT_SHELL_SEARCH": "워크스페이스 검색 실행", "TOGGLE_WINDOW_FULLSCREEN": "앱 창 전체 화면 전환", + "ZOOM_IN_OUT": "앱 확대 또는 축소", + "RESET_ZOOM": "앱 확대/축소 초기화", "TOGGLE_SIDEBAR": "사이드바 전환", "M3U_CHANNEL_NUMBER": "번호로 M3U 채널 선택", "PLAY_PAUSE": "Embedded MPV 재생 또는 일시정지", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 7c3150fa2..d710f6689 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Recent bekeken openen", "SUBMIT_SHELL_SEARCH": "Werkruimtezoekopdracht uitvoeren", "TOGGLE_WINDOW_FULLSCREEN": "Volledig scherm voor het app-venster in- of uitschakelen", + "ZOOM_IN_OUT": "App in- of uitzoomen", + "RESET_ZOOM": "App-zoom herstellen", "TOGGLE_SIDEBAR": "Zijbalk in- of uitschakelen", "M3U_CHANNEL_NUMBER": "M3U-kanaal selecteren op nummer", "PLAY_PAUSE": "Ingebedde MPV-weergave afspelen of pauzeren", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index afab20651..ece0dbe5b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Otwórz ostatnio oglądane", "SUBMIT_SHELL_SEARCH": "Uruchom wyszukiwanie w obszarze roboczym", "TOGGLE_WINDOW_FULLSCREEN": "Przełącz pełny ekran okna aplikacji", + "ZOOM_IN_OUT": "Powiększ lub pomniejsz aplikację", + "RESET_ZOOM": "Zresetuj powiększenie aplikacji", "TOGGLE_SIDEBAR": "Przełącz pasek boczny", "M3U_CHANNEL_NUMBER": "Wybierz kanał M3U według numeru", "PLAY_PAUSE": "Odtwórz lub wstrzymaj osadzony MPV", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 4f43ec9c3..e869de97f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Abrir assistidos recentemente", "SUBMIT_SHELL_SEARCH": "Enviar pesquisa da área de trabalho", "TOGGLE_WINDOW_FULLSCREEN": "Alternar tela cheia da janela do aplicativo", + "ZOOM_IN_OUT": "Aumentar ou diminuir o zoom do aplicativo", + "RESET_ZOOM": "Redefinir o zoom do aplicativo", "TOGGLE_SIDEBAR": "Alternar barra lateral", "M3U_CHANNEL_NUMBER": "Selecionar canal M3U por número", "PLAY_PAUSE": "Reproduzir ou pausar reprodução do Embedded MPV", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index a3219897f..0448478c4 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Открыть недавно просмотренное", "SUBMIT_SHELL_SEARCH": "Отправить поиск в рабочей области", "TOGGLE_WINDOW_FULLSCREEN": "Переключить полноэкранный режим окна приложения", + "ZOOM_IN_OUT": "Увеличить или уменьшить масштаб приложения", + "RESET_ZOOM": "Сбросить масштаб приложения", "TOGGLE_SIDEBAR": "Показать или скрыть боковую панель", "M3U_CHANNEL_NUMBER": "Выбрать канал M3U по номеру", "PLAY_PAUSE": "Запустить или приостановить воспроизведение во встроенном MPV", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 9557658ea..e0d4e21fe 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Son görüntülenenleri aç", "SUBMIT_SHELL_SEARCH": "Çalışma alanı aramasını gönder", "TOGGLE_WINDOW_FULLSCREEN": "Uygulama penceresi tam ekranını aç/kapat", + "ZOOM_IN_OUT": "Uygulamayı yakınlaştır veya uzaklaştır", + "RESET_ZOOM": "Uygulama yakınlaştırmasını sıfırla", "TOGGLE_SIDEBAR": "Kenar çubuğunu aç/kapat", "M3U_CHANNEL_NUMBER": "M3U kanalını numarayla seç", "PLAY_PAUSE": "Gömülü MPV oynatmasını oynat veya duraklat", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index fb9a1af3a..0b6834f23 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "打开最近观看", "SUBMIT_SHELL_SEARCH": "提交工作区搜索", "TOGGLE_WINDOW_FULLSCREEN": "切换应用窗口全屏", + "ZOOM_IN_OUT": "放大或缩小应用", + "RESET_ZOOM": "重置应用缩放", "TOGGLE_SIDEBAR": "切换侧边栏", "M3U_CHANNEL_NUMBER": "按数字选择 M3U 频道", "PLAY_PAUSE": "播放或暂停嵌入式 MPV 播放", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 241071599..6fd3b0e06 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "開啟最近觀看", "SUBMIT_SHELL_SEARCH": "提交工作區搜尋", "TOGGLE_WINDOW_FULLSCREEN": "切換應用程式視窗全螢幕", + "ZOOM_IN_OUT": "放大或縮小應用程式", + "RESET_ZOOM": "重設應用程式縮放", "TOGGLE_SIDEBAR": "切換側邊欄", "M3U_CHANNEL_NUMBER": "按數字選擇 M3U 頻道", "PLAY_PAUSE": "播放或暫停嵌入式 MPV 播放", diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 0ca059aa1..2e7a0c2d7 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -210,7 +210,12 @@ the lazy workspace shell feature into the initial bundle. Use scoped aliases from `tsconfig.base.json` and expose public imports through a library's `src/index.ts`. Do not introduce legacy bare aliases such as `services`, `components`, `shared-interfaces`, or `database`, and avoid deep -imports unless a sub-entrypoint is explicitly configured. +imports unless a sub-entrypoint is explicitly configured. The configured +sub-entrypoints are `@iptvnator/shared/interfaces/ipc-commands` and +`@iptvnator/shared/interfaces/zoom-level`, both for the Electron preload: the +`@iptvnator/shared/interfaces` barrel pulls in `ngx-indexed-db`, which the +preload bundle must not carry, so the preload only type-imports the barrel +and value-imports those two dependency-free modules directly. For a buildable library that has a local `package.json`, its `name` must match the scoped alias. Nx uses that package name when rewriting buildable dependency diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 731e5cb6f..aa8cbaee6 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -407,7 +407,7 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455): `window.events.spec.ts`, and the startup-window-mode cases in `settings.e2e.ts`. -Zoom level (Cmd/Ctrl and +/−, issue #1109): +Zoom level (Cmd/Ctrl and +/−/0, issue #1109): 1. The packaged renderer runs under `file://` with path routing. Chromium keys per-host zoom by the FULL URL when a URL has no host, so every @@ -424,8 +424,8 @@ Zoom level (Cmd/Ctrl and +/−, issue #1109): stored level over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC at preload start and applies it with `webFrame.setZoomLevel`, which installs a TEMPORARY, frame-bound zoom level. It survives in-page navigation and - resizes, the macOS menu roles (`zoomIn`/`zoomOut`/`resetZoom`) increment - it, and `getZoomLevel()` reports it regardless of the route. + resizes, the zoom shortcuts (point 4) step it through the same call, and + `getZoomLevel()` reports it regardless of the route. `webContents.setZoomLevel` from the main process would write the per-URL entry and re-create the bug. When nothing is stored the preload re-applies the current level for the same reason: entering temporary mode makes the @@ -453,13 +453,44 @@ Zoom level (Cmd/Ctrl and +/−, issue #1109): document's preload has already read whatever was stored. That navigation also releases ownership until the next preload answers, so a close mid-reload cannot save the per-URL default over the user's level. -4. Windows/Linux have no zoom shortcuts today: the window calls - `setMenu(null)` and nothing handles `zoom-changed`, so only macOS' default - application menu zooms. Persistence is platform-neutral and would pick - up any future shortcut that goes through `webContents.setZoomLevel`. - Regression coverage lives in `window-zoom-level.e2e.ts`, which measures - the rendered factor (content width ÷ `window.innerWidth`) across a - section change, a resize, a reload and a restart. +4. The shortcuts are a renderer key binding, not a native menu: the + Windows/Linux window calls `setMenu(null)`, so no accelerator could reach + it there. `WorkspaceKeyboardShortcutsService` (`libs/workspace/shell`) + listens on the document like it does for F11 and resolves the chord with + `resolveZoomShortcutAction` (`libs/portal/shared/util`): Cmd on macOS, + Ctrl elsewhere, never Alt; `+`/`=` (so `Ctrl+=` and `Ctrl+Shift+=` both + zoom in), `-`/`_`, `0`, and the numpad `+`/`-`/`0` (by `code`, since a + NumLock-off `0` reports `Insert`). Keys are matched by `event.key`, so + non-US layouts zoom with their own `+`/`-` keys. Like F11 it is not gated + by the typing-target check — browsers zoom from any focus — and a key + another handler already `preventDefault`ed is left alone. The binding + calls the synchronous, preload-local `window.electron.adjustZoomLevel` + (`adjustFrameZoomLevel` in `api/preload-zoom-level.ts`), which steps the + frame's temporary level through the same `webFrame.setZoomLevel` as the + restore and returns the level applied — never a main-process + `webContents.setZoomLevel`, which would re-create the per-URL bug. The + step and limits live in `libs/shared/interfaces/src/lib/zoom-level.util.ts` + (`stepZoomLevel`): 0.5 per press, Electron's own `zoomIn`/`zoomOut` role + step (≈10 %), clamped to levels −4…6 (≈48 %…299 %, inside Chromium's + 25–500 %), off-grid levels snapping to the next grid point in the pressed + direction; `Ctrl/Cmd+0` returns to level 0. Persistence needs nothing + extra: the main process reads the live level back (point 3). On macOS the + default application menu still carries the `zoomIn`/`zoomOut`/`resetZoom` + roles, but Chromium hands a key equivalent to the web contents first and + Electron performs the menu equivalent only in + `WebContents::PlatformHandleKeyboardEvent` + (`shell/browser/api/electron_api_web_contents_mac.mm`, Electron 43.3.0), + the unhandled-keyboard-event hook — a `preventDefault`ed keydown never + gets there, so the binding keeps one press at one step. CDP-dispatched + keys (the E2E) never reach the menu at all. + Without a bridge (PWA) the browser keeps its own zoom, and the help + dialog lists the chords as Electron-only. Regression coverage: + `window-zoom-level.e2e.ts` presses the real shortcuts (in, out, numpad, + reset) and measures the rendered factor (content width ÷ + `window.innerWidth`) across a section change, a resize, a reload and a + restart; key resolution and the bridge step are unit-tested in + `keyboard-shortcuts.spec.ts`, `workspace-keyboard-shortcuts.service.spec.ts` + and `preload-zoom-level.spec.ts`. Layout integration: diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts index 5a1bab2cc..972d85fd6 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts @@ -111,6 +111,26 @@ export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [ order: 50, electronOnly: true, }, + { + // Frame-bound app zoom (issue #1109); the PWA leaves these keys to + // the browser's own zoom. + id: 'zoom-in-out', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.ZOOM_IN_OUT', + icon: 'zoom_in', + keys: [commandChord('Plus'), commandChord('Minus')], + order: 60, + electronOnly: true, + }, + { + id: 'reset-zoom', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.RESET_ZOOM', + icon: 'youtube_searched_for', + keys: [commandChord('0')], + order: 70, + electronOnly: true, + }, { id: 'toggle-sidebar', group: 'navigation', diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts index d3e99c003..f5b2fc3bb 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts @@ -2,6 +2,7 @@ import { APP_KEYBOARD_SHORTCUTS, getKeyboardShortcutGroups, isKeyboardShortcutHelpTrigger, + resolveZoomShortcutAction, } from './keyboard-shortcuts'; describe('keyboard shortcuts registry', () => { @@ -25,6 +26,9 @@ describe('keyboard shortcuts registry', () => { expect(ids).not.toContain('close-player-popovers'); // The browser owns F11 in the PWA. expect(ids).not.toContain('toggle-window-fullscreen'); + // ...and its own zoom keys. + expect(ids).not.toContain('zoom-in-out'); + expect(ids).not.toContain('reset-zoom'); expect(ids).toContain('open-command-palette'); // Playback shortcuts run in every runtime: the built-in web players // attach them through the legacy shortcut wiring in the PWA too. @@ -50,6 +54,125 @@ describe('keyboard shortcuts registry', () => { ]); }); + it('lists the zoom shortcuts in the global group for Electron with platform modifiers', () => { + const linuxGroups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: true, + }); + const macGroups = getKeyboardShortcutGroups({ + isMac: true, + isElectron: true, + }); + + expect( + linuxGroups + .find((group) => group.id === 'global') + ?.items.map((item) => item.id) + ).toEqual(expect.arrayContaining(['zoom-in-out', 'reset-zoom'])); + expect(findChordLabels(linuxGroups, 'zoom-in-out')).toEqual([ + ['Ctrl', '+'], + ['Ctrl', '−'], + ]); + expect(findChordLabels(linuxGroups, 'reset-zoom')).toEqual([ + ['Ctrl', '0'], + ]); + expect(findChordLabels(macGroups, 'zoom-in-out')).toEqual([ + ['Cmd', '+'], + ['Cmd', '−'], + ]); + }); + + describe('resolveZoomShortcutAction', () => { + const press = (init: KeyboardEventInit) => + new KeyboardEvent('keydown', init); + const other = { isMac: false }; + const mac = { isMac: true }; + + it('maps Ctrl and +/=/-/_/0 on Windows and Linux', () => { + expect( + resolveZoomShortcutAction(press({ key: '=', ctrlKey: true }), other) + ).toBe('in'); + // Ctrl+Shift+= reports the shifted character. + expect( + resolveZoomShortcutAction( + press({ key: '+', ctrlKey: true, shiftKey: true }), + other + ) + ).toBe('in'); + expect( + resolveZoomShortcutAction(press({ key: '-', ctrlKey: true }), other) + ).toBe('out'); + expect( + resolveZoomShortcutAction( + press({ key: '_', ctrlKey: true, shiftKey: true }), + other + ) + ).toBe('out'); + expect( + resolveZoomShortcutAction(press({ key: '0', ctrlKey: true }), other) + ).toBe('reset'); + }); + + it('accepts the numpad keys, including NumLock-off 0', () => { + expect( + resolveZoomShortcutAction( + press({ key: '+', code: 'NumpadAdd', ctrlKey: true }), + other + ) + ).toBe('in'); + expect( + resolveZoomShortcutAction( + press({ key: '-', code: 'NumpadSubtract', ctrlKey: true }), + other + ) + ).toBe('out'); + expect( + resolveZoomShortcutAction( + press({ key: 'Insert', code: 'Numpad0', ctrlKey: true }), + other + ) + ).toBe('reset'); + }); + + it('uses Cmd on macOS and Ctrl elsewhere, never Alt', () => { + expect( + resolveZoomShortcutAction(press({ key: '=', metaKey: true }), mac) + ).toBe('in'); + expect( + resolveZoomShortcutAction(press({ key: '=', ctrlKey: true }), mac) + ).toBeNull(); + expect( + resolveZoomShortcutAction(press({ key: '=', metaKey: true }), other) + ).toBeNull(); + expect( + resolveZoomShortcutAction( + press({ key: '=', ctrlKey: true, altKey: true }), + other + ) + ).toBeNull(); + expect( + resolveZoomShortcutAction( + press({ key: '=', ctrlKey: true, metaKey: true }), + other + ) + ).toBeNull(); + }); + + it('ignores unmodified keys and unrelated combinations', () => { + expect(resolveZoomShortcutAction(press({ key: '=' }), other)).toBeNull(); + expect( + resolveZoomShortcutAction(press({ key: 'k', ctrlKey: true }), other) + ).toBeNull(); + // Digit0 with Shift is ")" on US layouts — not a reset. + expect( + resolveZoomShortcutAction( + press({ key: ')', code: 'Digit0', ctrlKey: true, shiftKey: true }), + other + ) + ).toBeNull(); + }); + }); + it('uses platform-specific modifier labels', () => { const macGroups = getKeyboardShortcutGroups({ isMac: true, diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts index 7a2ebbd8f..4e271074c 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts @@ -1,3 +1,4 @@ +import type { ZoomLevelAction } from '@iptvnator/shared/interfaces'; import { APP_KEYBOARD_SHORTCUTS, KEYBOARD_SHORTCUT_GROUPS, @@ -43,6 +44,8 @@ const KEY_LABELS = new Map([ ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['Escape', 'Esc'], + ['Plus', '+'], + ['Minus', '−'], ]); const KEY_ARIA_LABELS = new Map([ @@ -88,6 +91,49 @@ export function isKeyboardShortcutHelpTrigger(event: KeyboardEvent): boolean { return event.key === '?' || (event.key === '/' && event.shiftKey); } +/** + * The app zoom shortcut `event` carries, or null. Follows the browser + * convention on each platform — Cmd on macOS, Ctrl elsewhere, Alt never: + * `+`/`=` (so `Ctrl+=` and `Ctrl+Shift+=` both zoom in), `-`/`_`, `0`, and + * the numpad `+`/`-`/`0` keys (matched by `code`, since NumLock-off `0` + * reports `Insert`). Keys are matched by `event.key` so non-US layouts zoom + * with their own `+`/`-` keys rather than with whatever sits on `Equal`. + */ +export function resolveZoomShortcutAction( + event: KeyboardEvent, + options: { isMac: boolean } +): ZoomLevelAction | null { + const commandModifier = options.isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + + if (!commandModifier || event.altKey) { + return null; + } + + switch (event.key) { + case '+': + case '=': + return 'in'; + case '-': + case '_': + return 'out'; + case '0': + return 'reset'; + } + + switch (event.code) { + case 'NumpadAdd': + return 'in'; + case 'NumpadSubtract': + return 'out'; + case 'Numpad0': + return 'reset'; + } + + return null; +} + function resolveShortcutChord( key: KeyboardShortcutChord, isMac: boolean diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 470e6ed23..f902d8bf9 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/dev-logger.util'; export * from './lib/download-metadata.interface'; export * from './lib/embedded-mpv-session.interface'; export * from './lib/subtitle-style.util'; +export * from './lib/zoom-level.util'; export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 8a4a29912..6091b597b 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -1,6 +1,7 @@ import type { AppUpdateChannel } from './app-update-channel.util'; import type { SourceProbeContext, SourceHealthResult } from './source-health'; import type { XtreamConnectionFailure } from './xtream-connection-test'; +import type { ZoomLevelAction } from './zoom-level.util'; import type { CatchupDownloadMetadata, DownloadRecoveryResult, @@ -718,6 +719,16 @@ export interface ElectronBridgeApi { */ toggleFullScreenWindow: () => Promise; closeWindow: () => Promise; + /** + * Zoom shortcuts (Cmd/Ctrl and +/−/0). Synchronous and preload-local: + * steps the FRAME-BOUND temporary zoom level through `webFrame` (never a + * main-process `webContents.setZoomLevel`, whose per-URL entry the app's + * `file://` path routing resets — issue #1109), clamped to + * `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`, and returns the level now applied. + * Persistence needs nothing from the caller: the main process reads the + * live level back on close, quit and reload. + */ + adjustZoomLevel: (action: ZoomLevelAction) => number; getWindowState: () => Promise; onWindowStateChange: ( callback: (state: ElectronBridgeWindowState) => void diff --git a/libs/shared/interfaces/src/lib/zoom-level.util.spec.ts b/libs/shared/interfaces/src/lib/zoom-level.util.spec.ts new file mode 100644 index 000000000..a565d4861 --- /dev/null +++ b/libs/shared/interfaces/src/lib/zoom-level.util.spec.ts @@ -0,0 +1,48 @@ +import { + clampZoomLevel, + stepZoomLevel, + ZOOM_LEVEL_MAX, + ZOOM_LEVEL_MIN, + ZOOM_LEVEL_STEP, +} from './zoom-level.util'; + +describe('zoom level shortcuts contract', () => { + it('steps by the Electron menu-role step in both directions', () => { + expect(stepZoomLevel(0, 'in')).toBe(ZOOM_LEVEL_STEP); + expect(stepZoomLevel(0, 'out')).toBe(-ZOOM_LEVEL_STEP); + expect(stepZoomLevel(1, 'in')).toBe(1.5); + expect(stepZoomLevel(-1.5, 'out')).toBe(-2); + }); + + it('resets to the default level from anywhere', () => { + expect(stepZoomLevel(3, 'reset')).toBe(0); + expect(stepZoomLevel(-2.5, 'reset')).toBe(0); + expect(stepZoomLevel(Number.NaN, 'reset')).toBe(0); + }); + + it('clamps at the app limits, not at Chromium limits', () => { + expect(stepZoomLevel(ZOOM_LEVEL_MAX, 'in')).toBe(ZOOM_LEVEL_MAX); + expect(stepZoomLevel(ZOOM_LEVEL_MIN, 'out')).toBe(ZOOM_LEVEL_MIN); + expect(stepZoomLevel(ZOOM_LEVEL_MAX + 3, 'in')).toBe(ZOOM_LEVEL_MAX); + expect(stepZoomLevel(ZOOM_LEVEL_MAX + 3, 'out')).toBe( + ZOOM_LEVEL_MAX - ZOOM_LEVEL_STEP + ); + expect(clampZoomLevel(ZOOM_LEVEL_MIN - 1)).toBe(ZOOM_LEVEL_MIN); + }); + + it('snaps an off-grid level to the next grid point in the requested direction', () => { + // A level the macOS menu roles or an older store left between steps. + expect(stepZoomLevel(0.3, 'in')).toBe(0.5); + expect(stepZoomLevel(0.3, 'out')).toBe(0); + expect(stepZoomLevel(-0.7, 'out')).toBe(-1); + expect(stepZoomLevel(-0.7, 'in')).toBe(-0.5); + }); + + it('treats a non-finite level as the default', () => { + expect(stepZoomLevel(Number.NaN, 'in')).toBe(ZOOM_LEVEL_STEP); + expect(stepZoomLevel(Number.POSITIVE_INFINITY, 'out')).toBe( + -ZOOM_LEVEL_STEP + ); + expect(clampZoomLevel(Number.NaN)).toBe(0); + }); +}); diff --git a/libs/shared/interfaces/src/lib/zoom-level.util.ts b/libs/shared/interfaces/src/lib/zoom-level.util.ts new file mode 100644 index 000000000..6846e86e8 --- /dev/null +++ b/libs/shared/interfaces/src/lib/zoom-level.util.ts @@ -0,0 +1,59 @@ +/** + * App zoom shortcut contract (Cmd/Ctrl and +/−/0), shared by the renderer + * key binding (`WorkspaceKeyboardShortcutsService`) and the preload, which + * applies the result through `webFrame.setZoomLevel` (issue #1109: the level + * must stay a frame-bound temporary zoom; see + * `docs/architecture/workspace-shell.md`, "Zoom level"). + * + * A Chromium zoom level is the exponent of a 1.2 factor: level 1 renders at + * 120 %, level −1 at ~83 %. The step matches Electron's `zoomIn`/`zoomOut` + * menu roles (0.5, about 10 % per press), so the level the macOS menu used + * to produce and the level the shortcuts produce are the same series. + */ + +export type ZoomLevelAction = 'in' | 'out' | 'reset'; + +export const ZOOM_LEVEL_DEFAULT = 0; +/** Electron's `zoomIn`/`zoomOut` role step. */ +export const ZOOM_LEVEL_STEP = 0.5; +/** ≈48 % — below this the workspace chrome is unreadable. */ +export const ZOOM_LEVEL_MIN = -4; +/** ≈299 % — well inside Chromium's own 25–500 % limits. */ +export const ZOOM_LEVEL_MAX = 6; + +export function clampZoomLevel(level: number): number { + if (!Number.isFinite(level)) { + return ZOOM_LEVEL_DEFAULT; + } + + return Math.min(ZOOM_LEVEL_MAX, Math.max(ZOOM_LEVEL_MIN, level)); +} + +/** + * The level `action` produces from `current`. Steps are snapped to the step + * grid so a level restored from an older store (or set by the macOS menu + * before the shortcuts existed) does not carry a fractional offset forever. + * A non-finite `current` is treated as the default. + */ +export function stepZoomLevel( + current: number, + action: ZoomLevelAction +): number { + if (action === 'reset') { + return ZOOM_LEVEL_DEFAULT; + } + + const base = clampZoomLevel(current); + const direction = action === 'in' ? 1 : -1; + const steps = base / ZOOM_LEVEL_STEP; + // A level between grid points steps to the next grid point in the + // requested direction instead of skipping past it. + const nextSteps = Number.isInteger(steps) + ? steps + direction + : direction > 0 + ? Math.ceil(steps) + : Math.floor(steps); + const next = nextSteps * ZOOM_LEVEL_STEP; + + return clampZoomLevel(next); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts index 371c43616..5aaeaba65 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts @@ -179,6 +179,116 @@ describe('WorkspaceKeyboardShortcutsService', () => { }); }); + describe('zoom shortcuts', () => { + const testWindow = window as unknown as { + electron?: Record; + }; + 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(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts index 31007a6df..1fe63f29e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts @@ -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'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 79394d16e..e3c20cee1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -93,6 +93,9 @@ "@iptvnator/shared/interfaces/ipc-commands": [ "libs/shared/interfaces/src/lib/ipc-commands.ts" ], + "@iptvnator/shared/interfaces/zoom-level": [ + "libs/shared/interfaces/src/lib/zoom-level.util.ts" + ], "@iptvnator/ui/shared-portals": [ "libs/ui/shared-portals/src/index.ts" ],