mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
ab239b5043
commit
67327bfd29
38 files changed
+705
-31
No files matched your search
@@ -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.
|
||||
@@ -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**:
|
||||
|
||||
@@ -25,12 +25,23 @@ async function renderedZoomFactor(app: LaunchedElectronApp): Promise<number> {
|
||||
return contentWidth / innerWidth;
|
||||
}
|
||||
|
||||
/** What the macOS menu roles (Cmd +/−) do: `webContents.zoomLevel += 1`. */
|
||||
async function zoomInFromMenu(app: LaunchedElectronApp): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<T extends object>(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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<PreloadZoomLevelPorts, 'getZoomLevel' | 'setZoomLevel'>,
|
||||
action: ZoomLevelAction
|
||||
): number {
|
||||
const current = ports.getZoomLevel();
|
||||
const next = stepZoomLevel(current, action);
|
||||
|
||||
if (next !== current) {
|
||||
try {
|
||||
ports.setZoomLevel(next);
|
||||
} catch {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -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 مؤقتًا",
|
||||
|
||||
@@ -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 المدمج",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 の再生/一時停止",
|
||||
|
||||
@@ -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 재생 또는 일시정지",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 播放",
|
||||
|
||||
@@ -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 播放",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>([
|
||||
['ArrowUp', '↑'],
|
||||
['ArrowDown', '↓'],
|
||||
['Escape', 'Esc'],
|
||||
['Plus', '+'],
|
||||
['Minus', '−'],
|
||||
]);
|
||||
|
||||
const KEY_ARIA_LABELS = new Map<string, string>([
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ElectronBridgeWindowState>;
|
||||
closeWindow: () => Promise<void>;
|
||||
/**
|
||||
* 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<ElectronBridgeWindowState>;
|
||||
onWindowStateChange: (
|
||||
callback: (state: ElectronBridgeWindowState) => void
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
+110
@@ -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();
|
||||
|
||||
+43
-1
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user