mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(workspace): route platform Back gestures to the header Back (#1943)
* feat(workspace): route platform Back gestures to the header Back The mouse Back button, Alt+Left (Cmd+[ on macOS) and, in Electron, the window's app-command browser-backward (Windows/Linux) and trackpad swipe (macOS) now run the workspace header's Back, one step each. - WorkspaceBackGesturesService (shell-provided) dispatches every gesture through WorkspaceBackNavigationService.goBack(). A gesture is swallowed while a dialog, menu, the phone drawer or a fullscreen player is up, and left to the browser only where the header has no Back. - The chord is a navigate-back entry in the shortcuts dialog, handled in a capture-phase listener of WorkspaceKeyboardShortcutsService; ignored while typing, repeats swallowed. - Electron forwards app-command and swipe over the push-only channel WINDOW:BACK_REQUESTED (preload onWindowBackRequested), gated in the renderer on RuntimeCapabilitiesService.supportsWindowBackGestures. - A mouse press that also arrives as app-command goes back once, however long the button is held; Electron's own Chromium history step on the mouse button is always prevented. - No Forward binding. Docs: workspace-shell (Platform gestures), portal-detail-navigation, electron-security, UI guidelines, README shortcuts table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(workspace): drop NgZone from the Back gestures service Master runs zoneless change detection and no longer uses NgZone; the bridge callback calls goBack() directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): keep Back gestures where the Fullscreen API is missing iPhone Safari has no unprefixed document.fullscreenElement; the undefined value read as "a player is fullscreen" and blocked every gesture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): keep a held Back chord's repeats swallowed after Back Repeats asked for the current result, so once the first press reached a page with no Back left they were released to the browser, which could take another history step. Repeats now follow the chord's first press until a key is released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): one Back per press of the Linux Browser Back key On Linux Electron raises app-command browser-backward for every unhandled event of the Browser Back key (NativeWindowViews:: HandleKeyboardEvent), keydown and keyup alike, so one press went back twice. The main process now consumes that key in before-input-event and requests Back once per press; mouse Back buttons still arrive as app-command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
44 files changed
+1758
-8
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: workspace
|
||||
---
|
||||
|
||||
The mouse Back button, Alt+← (Cmd+[ on macOS) and, in the desktop app, the macOS trackpad swipe now do the same as the Back button in the top bar. Each goes back one page, and the keyboard shortcut is listed in the shortcuts dialog.
|
||||
@@ -81,6 +81,7 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
|
||||
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
|
||||
| Global | `Enter` in workspace search | Submit the search; on Settings, open the best match |
|
||||
| Global | `F11` | Toggle app window fullscreen in the desktop app |
|
||||
| Navigation | `Alt+←` / `Cmd+[` (macOS) | Go back, like the top bar's Back and the mouse Back button |
|
||||
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
|
||||
| Navigation | `0-9` | Select an M3U channel by number |
|
||||
| Playback | `Space` / `K` | Play or pause playback |
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform Back gestures run the workspace header's Back: the mouse Back
|
||||
// button, Alt+← (Cmd+[ on macOS), and the window's `app-command`
|
||||
// (`browser-backward`, Windows/Linux) and trackpad `swipe` (macOS), which the
|
||||
// main process forwards to the renderer. Each goes back exactly one step. A
|
||||
// mouse Back press that reaches the app both as a renderer `mouseup` and as
|
||||
// an `app-command` (Windows and Linux) still goes back once. There is no
|
||||
// Forward. Gestures are ignored while typing (the chord) and while a dialog
|
||||
// is open.
|
||||
//
|
||||
// The window events are emitted on the main process's BrowserWindow, so the
|
||||
// specs run on every platform even though Electron raises each event only
|
||||
// on its own.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/;
|
||||
const backChord =
|
||||
process.platform === 'darwin' ? 'Meta+BracketLeft' : 'Alt+ArrowLeft';
|
||||
|
||||
type WindowGesture =
|
||||
| { event: 'app-command'; value: 'browser-backward' | 'browser-forward' }
|
||||
| { event: 'swipe'; value: 'left' | 'right' };
|
||||
|
||||
async function emitWindowGesture(
|
||||
app: LaunchedElectronApp,
|
||||
gesture: WindowGesture
|
||||
): Promise<void> {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }, { event, value }) => {
|
||||
const [win] = BrowserWindow.getAllWindows();
|
||||
win.emit(event, {}, value);
|
||||
}, gesture);
|
||||
}
|
||||
|
||||
/** Half of a mouse Back press through Chromium's input pipeline. */
|
||||
async function mouseBack(
|
||||
cdp: CDPSession,
|
||||
page: Page,
|
||||
type: 'mousePressed' | 'mouseReleased'
|
||||
): Promise<void> {
|
||||
const box = await page.locator('main').first().boundingBox();
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type,
|
||||
x: Math.round((box?.x ?? 0) + (box?.width ?? 400) / 2),
|
||||
y: Math.round((box?.y ?? 0) + (box?.height ?? 400) / 2),
|
||||
button: 'back',
|
||||
buttons: type === 'mousePressed' ? 8 : 0,
|
||||
clickCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function pressMouseBack(cdp: CDPSession, page: Page): Promise<void> {
|
||||
await mouseBack(cdp, page, 'mousePressed');
|
||||
await mouseBack(cdp, page, 'mouseReleased');
|
||||
}
|
||||
|
||||
async function openFirstSeries(page: Page): Promise<string> {
|
||||
await clickFirstGridListCard(page);
|
||||
await page.waitForURL(detailUrlPattern);
|
||||
await expect(page.locator('app-portal-detail-shell')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
return page.url();
|
||||
}
|
||||
|
||||
/** The history index Back gestures must land on, and nothing below it. */
|
||||
function historyIndex(page: Page): Promise<number> {
|
||||
return page.evaluate(() => window.navigation?.currentEntry?.index ?? -1);
|
||||
}
|
||||
|
||||
/** Time for a late second step (a duplicated press) to surface. */
|
||||
function settle(page: Page): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 400))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a gesture to return to the list, then for any late second step
|
||||
* to surface before checking the history index.
|
||||
*/
|
||||
async function expectBackToList(
|
||||
page: Page,
|
||||
listUrl: string,
|
||||
listIndex: number
|
||||
): Promise<void> {
|
||||
await expect(page).toHaveURL(listUrl);
|
||||
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
|
||||
await settle(page);
|
||||
await expect(page).toHaveURL(listUrl);
|
||||
expect(await historyIndex(page)).toBe(listIndex);
|
||||
}
|
||||
|
||||
/** The Xtream series list, reached by navigation from the start page. */
|
||||
async function openSeriesList(
|
||||
page: Page
|
||||
): Promise<{ listUrl: string; listIndex: number }> {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page.getByRole('link', { name: 'Series', exact: true }).click();
|
||||
await page.waitForURL(/\/series$/);
|
||||
await expect(
|
||||
page.locator('.category-content-layout mat-card').first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
return { listUrl: page.url(), listIndex: await historyIndex(page) };
|
||||
}
|
||||
|
||||
test.describe('Platform Back gestures', () => {
|
||||
test('@xtream @electron mouse Back, the Back chord and the window gestures each go back one step', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const cdp = await app.electronApp.context().newCDPSession(page);
|
||||
const { listUrl, listIndex } = await openSeriesList(page);
|
||||
|
||||
await openFirstSeries(page);
|
||||
await pressMouseBack(cdp, page);
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
|
||||
await openFirstSeries(page);
|
||||
await page.keyboard.press(backChord);
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
|
||||
await openFirstSeries(page);
|
||||
await emitWindowGesture(app, {
|
||||
event: 'app-command',
|
||||
value: 'browser-backward',
|
||||
});
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
|
||||
await openFirstSeries(page);
|
||||
await emitWindowGesture(app, { event: 'swipe', value: 'left' });
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
|
||||
// No Forward: Stalker inline details are not history entries.
|
||||
await emitWindowGesture(app, {
|
||||
event: 'app-command',
|
||||
value: 'browser-forward',
|
||||
});
|
||||
await emitWindowGesture(app, { event: 'swipe', value: 'right' });
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron a mouse Back press that also arrives as an app command goes back once', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const cdp = await app.electronApp.context().newCDPSession(page);
|
||||
const { listUrl, listIndex } = await openSeriesList(page);
|
||||
|
||||
await openFirstSeries(page);
|
||||
await Promise.all([
|
||||
pressMouseBack(cdp, page),
|
||||
emitWindowGesture(app, {
|
||||
event: 'app-command',
|
||||
value: 'browser-backward',
|
||||
}),
|
||||
]);
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
|
||||
// The pairing is per press: the next one goes back again.
|
||||
await openFirstSeries(page);
|
||||
await emitWindowGesture(app, {
|
||||
event: 'app-command',
|
||||
value: 'browser-backward',
|
||||
});
|
||||
await pressMouseBack(cdp, page);
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
|
||||
// Linux sends the command on press: a long hold is one press.
|
||||
await openFirstSeries(page);
|
||||
await mouseBack(cdp, page, 'mousePressed');
|
||||
await emitWindowGesture(app, {
|
||||
event: 'app-command',
|
||||
value: 'browser-backward',
|
||||
});
|
||||
await expect(page).toHaveURL(listUrl);
|
||||
await settle(page);
|
||||
await settle(page);
|
||||
await mouseBack(cdp, page, 'mouseReleased');
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron gestures wait while typing or while a dialog is open', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const cdp = await app.electronApp.context().newCDPSession(page);
|
||||
const { listUrl, listIndex } = await openSeriesList(page);
|
||||
|
||||
// Alt/Option arrows edit text in a field. The list has the
|
||||
// history fallback, so the chord would otherwise go back.
|
||||
const search = page.locator(
|
||||
'app-workspace-shell-header input[type="search"]'
|
||||
);
|
||||
await expect(search).toBeEnabled();
|
||||
await search.focus();
|
||||
await page.keyboard.press(backChord);
|
||||
await settle(page);
|
||||
await expect(page).toHaveURL(listUrl);
|
||||
await search.blur();
|
||||
|
||||
const detailUrl = await openFirstSeries(page);
|
||||
|
||||
// One at a time: a gesture that slipped through would navigate,
|
||||
// and the dialog would close with the navigation.
|
||||
await page.locator('app-portal-detail-shell').focus();
|
||||
await page.keyboard.press('?');
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
for (const gesture of [
|
||||
() => page.keyboard.press(backChord),
|
||||
// Electron's Chromium would step back on its own.
|
||||
() => pressMouseBack(cdp, page),
|
||||
() => emitWindowGesture(app, { event: 'swipe', value: 'left' }),
|
||||
]) {
|
||||
await gesture();
|
||||
await settle(page);
|
||||
await expect(page).toHaveURL(detailUrl);
|
||||
await expect(dialog).toBeVisible();
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await page.keyboard.press(backChord);
|
||||
await expectBackToList(page, listUrl, listIndex);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST,
|
||||
ANNOUNCE_PLAYLIST_OPEN_LISTENER,
|
||||
OPEN_FILE,
|
||||
WINDOW_BACK_REQUESTED,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
DownloadMetadataSnapshot,
|
||||
@@ -309,6 +310,30 @@ describe('main preload DB IPC contract', () => {
|
||||
expect(mockIpcRenderer.off).toHaveBeenCalledWith(OPEN_FILE, handler);
|
||||
});
|
||||
|
||||
it('forwards only the known window Back gesture sources', () => {
|
||||
const api = getExposedApi();
|
||||
const callback = jest.fn();
|
||||
|
||||
const unsubscribe = api.onWindowBackRequested(callback);
|
||||
const handler = mockIpcRenderer.on.mock.calls.at(-1)?.[1];
|
||||
|
||||
handler({}, 'app-command');
|
||||
handler({}, 'swipe');
|
||||
handler({}, 'browser-forward');
|
||||
handler({}, { source: 'swipe' });
|
||||
unsubscribe();
|
||||
|
||||
expect(mockIpcRenderer.on).toHaveBeenCalledWith(
|
||||
WINDOW_BACK_REQUESTED,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(callback.mock.calls).toEqual([['app-command'], ['swipe']]);
|
||||
expect(mockIpcRenderer.off).toHaveBeenCalledWith(
|
||||
WINDOW_BACK_REQUESTED,
|
||||
handler
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards request-scoped DB operation events and unregisters the listener', () => {
|
||||
const api = getExposedApi();
|
||||
const callback = jest.fn();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST,
|
||||
ANNOUNCE_PLAYLIST_OPEN_LISTENER,
|
||||
OPEN_FILE,
|
||||
WINDOW_BACK_REQUESTED,
|
||||
WINDOW_GET_ZOOM_LEVEL,
|
||||
WINDOW_ZOOM_LEVEL_APPLIED,
|
||||
} from '@iptvnator/shared/interfaces/ipc-commands';
|
||||
@@ -45,6 +46,7 @@ import type {
|
||||
EmbeddedMpvSupport,
|
||||
ElectronBridgeApi,
|
||||
ElectronBridgeAppUpdateReleaseNotesRequest,
|
||||
ElectronBridgeBackGestureSource,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
ElectronBridgeDbOperationEvent,
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
@@ -447,6 +449,21 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.on(WINDOW_STATE_CHANGED, handler);
|
||||
return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler);
|
||||
},
|
||||
onWindowBackRequested: (
|
||||
callback: (source: ElectronBridgeBackGestureSource) => void
|
||||
) => {
|
||||
// Only the two known sources cross the bridge.
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
source: unknown
|
||||
) => {
|
||||
if (source === 'app-command' || source === 'swipe') {
|
||||
callback(source);
|
||||
}
|
||||
};
|
||||
ipcRenderer.on(WINDOW_BACK_REQUESTED, handler);
|
||||
return () => ipcRenderer.off(WINDOW_BACK_REQUESTED, handler);
|
||||
},
|
||||
setWindowCloseGuard: (active: boolean) =>
|
||||
ipcRenderer.invoke(WINDOW_SET_CLOSE_GUARD, active),
|
||||
confirmWindowClose: () => ipcRenderer.invoke(WINDOW_CONFIRM_CLOSE),
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
requestFullScreen,
|
||||
trackNativeFullScreen,
|
||||
} from './services/native-fullscreen-transitions';
|
||||
import { attachWindowBackGestures } from './services/window-back-gestures';
|
||||
|
||||
const externalBrowserProtocols = new Set(['http:', 'https:']);
|
||||
const trustedDevRendererHosts = new Set([
|
||||
@@ -543,6 +544,9 @@ export default class App {
|
||||
);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
App.attachWindowStateEvents(App.mainWindow);
|
||||
// Mouse Back buttons and Browser Back (Windows/Linux) and the
|
||||
// trackpad swipe (macOS) run the workspace header's Back.
|
||||
attachWindowBackGestures(App.mainWindow);
|
||||
// Seeds the F11 tracker's fullscreen state now, while no transition
|
||||
// can be in flight; from here on it follows the window's events.
|
||||
trackNativeFullScreen(App.mainWindow);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { WINDOW_BACK_REQUESTED } from '@iptvnator/shared/interfaces';
|
||||
import { attachWindowBackGestures } from './window-back-gestures';
|
||||
|
||||
type GestureListener = (event: unknown, value: string) => void;
|
||||
type InputListener = (
|
||||
event: { preventDefault: () => void },
|
||||
input: Partial<Electron.Input>
|
||||
) => void;
|
||||
|
||||
function createWindow(platform: NodeJS.Platform = 'win32') {
|
||||
const listeners = new Map<string, GestureListener>();
|
||||
const contentsListeners = new Map<string, InputListener>();
|
||||
const webContents = {
|
||||
isDestroyed: jest.fn(() => false),
|
||||
send: jest.fn(),
|
||||
on: jest.fn((event: string, listener: InputListener) => {
|
||||
contentsListeners.set(event, listener);
|
||||
}),
|
||||
};
|
||||
const win = {
|
||||
webContents,
|
||||
isDestroyed: jest.fn(() => false),
|
||||
on: jest.fn((event: string, listener: GestureListener) => {
|
||||
listeners.set(event, listener);
|
||||
}),
|
||||
};
|
||||
attachWindowBackGestures(
|
||||
win as unknown as Electron.BrowserWindow,
|
||||
platform
|
||||
);
|
||||
|
||||
return {
|
||||
win,
|
||||
webContents,
|
||||
contentsListeners,
|
||||
emit(event: string, value: string): void {
|
||||
const listener = listeners.get(event);
|
||||
expect(listener).toBeDefined();
|
||||
listener?.({}, value);
|
||||
},
|
||||
/** One key event before it reaches the page; true when consumed. */
|
||||
key(input: Partial<Electron.Input>): boolean {
|
||||
const listener = contentsListeners.get('before-input-event');
|
||||
expect(listener).toBeDefined();
|
||||
const preventDefault = jest.fn();
|
||||
listener?.({ preventDefault }, input);
|
||||
return preventDefault.mock.calls.length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('attachWindowBackGestures', () => {
|
||||
it('forwards the Windows and Linux browser-backward app command', () => {
|
||||
const { emit, webContents } = createWindow();
|
||||
|
||||
emit('app-command', 'browser-backward');
|
||||
|
||||
expect(webContents.send).toHaveBeenCalledTimes(1);
|
||||
expect(webContents.send).toHaveBeenCalledWith(
|
||||
WINDOW_BACK_REQUESTED,
|
||||
'app-command'
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the macOS swipe that means Back', () => {
|
||||
const { emit, webContents } = createWindow('darwin');
|
||||
|
||||
emit('swipe', 'left');
|
||||
|
||||
expect(webContents.send).toHaveBeenCalledTimes(1);
|
||||
expect(webContents.send).toHaveBeenCalledWith(
|
||||
WINDOW_BACK_REQUESTED,
|
||||
'swipe'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['app-command', 'browser-forward'],
|
||||
['app-command', 'media-play-pause'],
|
||||
['swipe', 'right'],
|
||||
['swipe', 'up'],
|
||||
['swipe', 'down'],
|
||||
])('ignores %s %s: there is no Forward', (event, value) => {
|
||||
const { emit, webContents } = createWindow();
|
||||
|
||||
emit(event, value);
|
||||
|
||||
expect(webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends nothing once the window or its contents are destroyed', () => {
|
||||
const { emit, win, webContents } = createWindow();
|
||||
|
||||
webContents.isDestroyed.mockReturnValue(true);
|
||||
emit('app-command', 'browser-backward');
|
||||
win.isDestroyed.mockReturnValue(true);
|
||||
emit('swipe', 'left');
|
||||
|
||||
expect(webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Linux Browser Back key', () => {
|
||||
it('goes back once per press and keeps every event of the key from the page', () => {
|
||||
// Electron would raise `browser-backward` for the keydown and the
|
||||
// keyup of an unconsumed key.
|
||||
const { key, webContents } = createWindow('linux');
|
||||
|
||||
const consumed = [
|
||||
key({ type: 'keyDown', key: 'BrowserBack' }),
|
||||
key({
|
||||
type: 'keyDown',
|
||||
key: 'BrowserBack',
|
||||
isAutoRepeat: true,
|
||||
}),
|
||||
key({ type: 'keyUp', key: 'BrowserBack' }),
|
||||
];
|
||||
|
||||
expect(consumed).toEqual([true, true, true]);
|
||||
expect(webContents.send).toHaveBeenCalledTimes(1);
|
||||
expect(webContents.send).toHaveBeenCalledWith(
|
||||
WINDOW_BACK_REQUESTED,
|
||||
'app-command'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves other keys to the page', () => {
|
||||
const { key, webContents } = createWindow('linux');
|
||||
|
||||
expect(key({ type: 'keyDown', key: 'ArrowLeft', alt: true })).toBe(
|
||||
false
|
||||
);
|
||||
expect(key({ type: 'keyDown', key: 'BrowserForward' })).toBe(false);
|
||||
expect(webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['win32', 'darwin'] as const)(
|
||||
'is not taken on %s, where the key raises one command',
|
||||
(platform) => {
|
||||
const { contentsListeners } = createWindow(platform);
|
||||
|
||||
expect(contentsListeners.has('before-input-event')).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
ElectronBridgeBackGestureSource,
|
||||
WINDOW_BACK_REQUESTED,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** `app-command` for mouse Back buttons and the Browser Back key. */
|
||||
const APP_COMMAND_BACK = 'browser-backward';
|
||||
|
||||
/** DOM `key` of the keyboard Browser Back key. */
|
||||
const BROWSER_BACK_KEY = 'BrowserBack';
|
||||
|
||||
/**
|
||||
* The `swipe` direction that means Back: NSEvent `deltaX > 0`, which WebKit
|
||||
* and VS Code map to Back as well.
|
||||
*/
|
||||
const SWIPE_BACK = 'left';
|
||||
|
||||
/**
|
||||
* Forwards the window's platform Back gestures to the renderer, which runs
|
||||
* the workspace header's Back (`WorkspaceBackGesturesService`) or ignores
|
||||
* the request. Both listeners are attached on every platform: Electron
|
||||
* emits `app-command` only on Windows and Linux and `swipe` only on macOS.
|
||||
* Forward (`browser-forward`, `swipe` right) is deliberately not forwarded:
|
||||
* Stalker inline details are not history entries, so Forward would skip
|
||||
* them.
|
||||
*/
|
||||
export function attachWindowBackGestures(
|
||||
win: Electron.BrowserWindow,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): void {
|
||||
const requestBack = (source: ElectronBridgeBackGestureSource): void => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
win.webContents.send(WINDOW_BACK_REQUESTED, source);
|
||||
};
|
||||
|
||||
win.on('app-command', (_event, command) => {
|
||||
if (command === APP_COMMAND_BACK) requestBack('app-command');
|
||||
});
|
||||
win.on('swipe', (_event, direction) => {
|
||||
if (direction === SWIPE_BACK) requestBack('swipe');
|
||||
});
|
||||
|
||||
if (platform === 'linux') {
|
||||
// On Linux Electron raises `app-command` for every unhandled event
|
||||
// of the Browser Back key, keydown and keyup alike
|
||||
// (`NativeWindowViews::HandleKeyboardEvent`), so one press would go
|
||||
// back twice. The key is taken here instead: Back once per press,
|
||||
// and a consumed key never reaches that handler. Mouse Back buttons
|
||||
// still arrive as `app-command`.
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key !== BROWSER_BACK_KEY) return;
|
||||
event.preventDefault();
|
||||
if (input.type === 'keyDown' && !input.isAutoRepeat) {
|
||||
requestBack('app-command');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
@@ -49,6 +49,41 @@ async function openAsFirstEntry(page: Page, url: string): Promise<Page> {
|
||||
return firstEntryPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Back chord for the platform the page reports (the web project emulates
|
||||
* Windows): Cmd+[ on macOS, Alt+← elsewhere.
|
||||
*/
|
||||
async function backChord(page: Page): Promise<string> {
|
||||
const isMac = await page.evaluate(() =>
|
||||
/Mac|iPhone|iPad|iPod/i.test(
|
||||
(navigator as Navigator & { userAgentData?: { platform?: string } })
|
||||
.userAgentData?.platform || navigator.userAgent
|
||||
)
|
||||
);
|
||||
return isMac ? 'Meta+BracketLeft' : 'Alt+ArrowLeft';
|
||||
}
|
||||
|
||||
/** One mouse Back press through Chromium's input pipeline. */
|
||||
async function pressMouseBack(cdp: CDPSession, page: Page): Promise<void> {
|
||||
const box = await page.locator('main').first().boundingBox();
|
||||
const x = Math.round((box?.x ?? 0) + (box?.width ?? 400) / 2);
|
||||
const y = Math.round((box?.y ?? 0) + (box?.height ?? 400) / 2);
|
||||
for (const type of ['mousePressed', 'mouseReleased'] as const) {
|
||||
await cdp.send('Input.dispatchMouseEvent', {
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
button: 'back',
|
||||
buttons: type === 'mousePressed' ? 8 : 0,
|
||||
clickCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function historyIndex(page: Page): Promise<number> {
|
||||
return page.evaluate(() => window.navigation?.currentEntry?.index ?? -1);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
await page.goto('/');
|
||||
@@ -82,6 +117,35 @@ test('@web @xtream the in-portal search page reached in the app goes back in his
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
});
|
||||
|
||||
test('@web @xtream the mouse Back button and the Back chord go back one step', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole('link', { name: 'Series', exact: true }).click();
|
||||
await page.waitForURL(/\/series$/);
|
||||
const listUrl = page.url();
|
||||
const listIndex = await historyIndex(page);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
|
||||
// The workspace runs Back and keeps the browser from its own step, which
|
||||
// would go back a second time.
|
||||
for (const gesture of [
|
||||
() => pressMouseBack(cdp, page),
|
||||
async () => page.keyboard.press(await backChord(page)),
|
||||
]) {
|
||||
await page.locator('.category-content-layout mat-card').first().click();
|
||||
await page.waitForURL(/\/series\/[^/]+\/[^/]+$/);
|
||||
await expect(page.locator('app-portal-detail-shell')).toBeVisible();
|
||||
|
||||
await gesture();
|
||||
await page.waitForURL(listUrl);
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 400))
|
||||
);
|
||||
expect(page.url()).toBe(listUrl);
|
||||
expect(await historyIndex(page)).toBe(listIndex);
|
||||
}
|
||||
});
|
||||
|
||||
for (const { name, path, selector, parent } of [
|
||||
{
|
||||
name: 'movie Discover',
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "تبديل ملء الشاشة لنافذة التطبيق",
|
||||
"ZOOM_IN_OUT": "تكبير التطبيق أو تصغيره",
|
||||
"RESET_ZOOM": "إعادة ضبط تكبير التطبيق",
|
||||
"GO_BACK": "العودة إلى الصفحة السابقة",
|
||||
"TOGGLE_SIDEBAR": "تبديل الشريط الجانبي",
|
||||
"M3U_CHANNEL_NUMBER": "اختيار قناة M3U حسب الرقم",
|
||||
"PLAY_PAUSE": "تشغيل أو إيقاف تشغيل Embedded MPV مؤقتًا",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "بدل ملء الشاشة ديال نافذة التطبيق",
|
||||
"ZOOM_IN_OUT": "كبر ولا صغر التطبيق",
|
||||
"RESET_ZOOM": "رجع تكبير التطبيق للأصل",
|
||||
"GO_BACK": "رجع للصفحة اللي قبل",
|
||||
"TOGGLE_SIDEBAR": "بدل عرض الشريط الجانبي",
|
||||
"M3U_CHANNEL_NUMBER": "اختار قناة M3U بالرقم",
|
||||
"PLAY_PAUSE": "شغل ولا وقف مؤقتاً تشغيل MPV المدمج",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Пераключыць поўнаэкранны рэжым акна праграмы",
|
||||
"ZOOM_IN_OUT": "Павялічыць або паменшыць маштаб праграмы",
|
||||
"RESET_ZOOM": "Скінуць маштаб праграмы",
|
||||
"GO_BACK": "Вярнуцца на папярэднюю старонку",
|
||||
"TOGGLE_SIDEBAR": "Пераключыць бакавую панэль",
|
||||
"M3U_CHANNEL_NUMBER": "Выбраць M3U-канал па нумары",
|
||||
"PLAY_PAUSE": "Прайграць або прыпыніць Embedded MPV",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Vollbild für das App-Fenster umschalten",
|
||||
"ZOOM_IN_OUT": "App vergrößern oder verkleinern",
|
||||
"RESET_ZOOM": "App-Zoom zurücksetzen",
|
||||
"GO_BACK": "Zur vorherigen Seite zurückkehren",
|
||||
"TOGGLE_SIDEBAR": "Seitenleiste umschalten",
|
||||
"M3U_CHANNEL_NUMBER": "M3U-Kanal per Nummer auswählen",
|
||||
"PLAY_PAUSE": "Eingebettete MPV-Wiedergabe starten oder pausieren",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Εναλλαγή πλήρους οθόνης παραθύρου εφαρμογής",
|
||||
"ZOOM_IN_OUT": "Μεγέθυνση ή σμίκρυνση της εφαρμογής",
|
||||
"RESET_ZOOM": "Επαναφορά ζουμ εφαρμογής",
|
||||
"GO_BACK": "Επιστροφή στην προηγούμενη σελίδα",
|
||||
"TOGGLE_SIDEBAR": "Εναλλαγή πλαϊνής μπάρας",
|
||||
"M3U_CHANNEL_NUMBER": "Επιλογή καναλιού M3U με αριθμό",
|
||||
"PLAY_PAUSE": "Αναπαραγωγή ή παύση ενσωματωμένου MPV",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Toggle app window fullscreen",
|
||||
"ZOOM_IN_OUT": "Zoom the app in or out",
|
||||
"RESET_ZOOM": "Reset app zoom",
|
||||
"GO_BACK": "Go back to the previous page",
|
||||
"TOGGLE_SIDEBAR": "Toggle sidebar",
|
||||
"M3U_CHANNEL_NUMBER": "Select M3U channel by number",
|
||||
"PLAY_PAUSE": "Play or pause embedded MPV playback",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"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",
|
||||
"GO_BACK": "Volver a la página anterior",
|
||||
"TOGGLE_SIDEBAR": "Mostrar u ocultar barra lateral",
|
||||
"M3U_CHANNEL_NUMBER": "Seleccionar canal M3U por número",
|
||||
"PLAY_PAUSE": "Reproducir o pausar MPV integrado",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"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",
|
||||
"GO_BACK": "Revenir à la page précédente",
|
||||
"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",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"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",
|
||||
"GO_BACK": "Vissza az előző oldalra",
|
||||
"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",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Attiva/disattiva schermo intero della finestra",
|
||||
"ZOOM_IN_OUT": "Ingrandisci o riduci l'applicazione",
|
||||
"RESET_ZOOM": "Reimposta lo zoom dell'applicazione",
|
||||
"GO_BACK": "Torna alla pagina precedente",
|
||||
"TOGGLE_SIDEBAR": "Mostra/nascondi barra laterale",
|
||||
"M3U_CHANNEL_NUMBER": "Seleziona canale M3U per numero",
|
||||
"PLAY_PAUSE": "Riproduci o metti in pausa MPV integrato",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "アプリウィンドウの全画面表示を切り替え",
|
||||
"ZOOM_IN_OUT": "アプリを拡大または縮小",
|
||||
"RESET_ZOOM": "アプリのズームをリセット",
|
||||
"GO_BACK": "前のページに戻る",
|
||||
"TOGGLE_SIDEBAR": "サイドバーを切り替え",
|
||||
"M3U_CHANNEL_NUMBER": "番号で M3U チャンネルを選択",
|
||||
"PLAY_PAUSE": "Embedded MPV の再生/一時停止",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "앱 창 전체 화면 전환",
|
||||
"ZOOM_IN_OUT": "앱 확대 또는 축소",
|
||||
"RESET_ZOOM": "앱 확대/축소 초기화",
|
||||
"GO_BACK": "이전 페이지로 돌아가기",
|
||||
"TOGGLE_SIDEBAR": "사이드바 전환",
|
||||
"M3U_CHANNEL_NUMBER": "번호로 M3U 채널 선택",
|
||||
"PLAY_PAUSE": "Embedded MPV 재생 또는 일시정지",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Volledig scherm voor het app-venster in- of uitschakelen",
|
||||
"ZOOM_IN_OUT": "App in- of uitzoomen",
|
||||
"RESET_ZOOM": "App-zoom herstellen",
|
||||
"GO_BACK": "Terug naar de vorige pagina",
|
||||
"TOGGLE_SIDEBAR": "Zijbalk in- of uitschakelen",
|
||||
"M3U_CHANNEL_NUMBER": "M3U-kanaal selecteren op nummer",
|
||||
"PLAY_PAUSE": "Ingebedde MPV-weergave afspelen of pauzeren",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Przełącz pełny ekran okna aplikacji",
|
||||
"ZOOM_IN_OUT": "Powiększ lub pomniejsz aplikację",
|
||||
"RESET_ZOOM": "Zresetuj powiększenie aplikacji",
|
||||
"GO_BACK": "Wróć do poprzedniej strony",
|
||||
"TOGGLE_SIDEBAR": "Przełącz pasek boczny",
|
||||
"M3U_CHANNEL_NUMBER": "Wybierz kanał M3U według numeru",
|
||||
"PLAY_PAUSE": "Odtwórz lub wstrzymaj osadzony MPV",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"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",
|
||||
"GO_BACK": "Voltar à página anterior",
|
||||
"TOGGLE_SIDEBAR": "Alternar barra lateral",
|
||||
"M3U_CHANNEL_NUMBER": "Selecionar canal M3U por número",
|
||||
"PLAY_PAUSE": "Reproduzir ou pausar reprodução do Embedded MPV",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "Переключить полноэкранный режим окна приложения",
|
||||
"ZOOM_IN_OUT": "Увеличить или уменьшить масштаб приложения",
|
||||
"RESET_ZOOM": "Сбросить масштаб приложения",
|
||||
"GO_BACK": "Вернуться на предыдущую страницу",
|
||||
"TOGGLE_SIDEBAR": "Показать или скрыть боковую панель",
|
||||
"M3U_CHANNEL_NUMBER": "Выбрать канал M3U по номеру",
|
||||
"PLAY_PAUSE": "Запустить или приостановить воспроизведение во встроенном MPV",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"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",
|
||||
"GO_BACK": "Önceki sayfaya dön",
|
||||
"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",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "切换应用窗口全屏",
|
||||
"ZOOM_IN_OUT": "放大或缩小应用",
|
||||
"RESET_ZOOM": "重置应用缩放",
|
||||
"GO_BACK": "返回上一页",
|
||||
"TOGGLE_SIDEBAR": "切换侧边栏",
|
||||
"M3U_CHANNEL_NUMBER": "按数字选择 M3U 频道",
|
||||
"PLAY_PAUSE": "播放或暂停嵌入式 MPV 播放",
|
||||
|
||||
@@ -2036,6 +2036,7 @@
|
||||
"TOGGLE_WINDOW_FULLSCREEN": "切換應用程式視窗全螢幕",
|
||||
"ZOOM_IN_OUT": "放大或縮小應用程式",
|
||||
"RESET_ZOOM": "重設應用程式縮放",
|
||||
"GO_BACK": "返回上一頁",
|
||||
"TOGGLE_SIDEBAR": "切換側邊欄",
|
||||
"M3U_CHANNEL_NUMBER": "按數字選擇 M3U 頻道",
|
||||
"PLAY_PAUSE": "播放或暫停嵌入式 MPV 播放",
|
||||
|
||||
@@ -60,6 +60,27 @@ Do not add broad protocol allow-lists for renderer navigation. If a new
|
||||
desktop-only flow needs to open a URL outside IPTVnator, route it through the
|
||||
default browser unless the app window is deliberately meant to host that URL.
|
||||
|
||||
## Window Back Gestures
|
||||
|
||||
`apps/electron-backend/src/app/services/window-back-gestures.ts` forwards the
|
||||
main window's `app-command` `browser-backward` (Windows/Linux) and `swipe`
|
||||
left (macOS) to the renderer on the push-only channel `WINDOW:BACK_REQUESTED`
|
||||
(`WINDOW_BACK_REQUESTED`). The payload is the source name (`app-command` or
|
||||
`swipe`); the preload's `onWindowBackRequested` forwards only those two
|
||||
values and returns an unsubscribe. There is no renderer → main surface and no
|
||||
`ipcMain` handler: the renderer decides whether Back runs, and the main
|
||||
process never navigates the window. Forward commands are not forwarded.
|
||||
On Linux the Browser Back key is consumed in the window's
|
||||
`before-input-event` and sent once per press on the same channel, because
|
||||
Electron there raises `app-command` for both its keydown and keyup.
|
||||
Electron's Chromium would also traverse history on an unprevented mouse Back
|
||||
press, even to entries from before a reload (which reloads the app), so the
|
||||
renderer prevents every mouse Back press while it subscribes.
|
||||
Renderer code subscribes only behind
|
||||
`RuntimeCapabilitiesService.supportsWindowBackGestures`. The renderer
|
||||
contract is "Platform gestures" in
|
||||
[Header Back](./workspace-shell.md#header-back).
|
||||
|
||||
## Desktop Auto Updates
|
||||
|
||||
Desktop application updates are owned by the Electron main process. Renderer
|
||||
|
||||
@@ -1359,8 +1359,9 @@ but the component has not.
|
||||
`defaultPrevented`, so one keypress cannot close both the drawer and the
|
||||
obscured player) and suppresses workspace-level shortcuts (Ctrl/Cmd+F
|
||||
global search, Ctrl/Cmd+K command palette, Ctrl/Cmd+R global recent, the
|
||||
`?` shortcuts dialog — dialogs must not stack a second focus trap on the
|
||||
modal drawer, and navigation must not act behind it), and document-level
|
||||
`?` shortcuts dialog, the Back chord and the other Back gestures — dialogs
|
||||
must not stack a second focus trap on the modal drawer, and navigation must
|
||||
not act behind it), and document-level
|
||||
shortcuts owned by routed content (shared controls, Embedded MPV legacy
|
||||
dock, radio audio player, the live layouts' Ctrl/Cmd+B sidebar toggle,
|
||||
the M3U player's digit-key channel switching and sidebar toggle) opt out
|
||||
|
||||
@@ -49,7 +49,12 @@ and the history fallback on the list Back returns to.
|
||||
|
||||
The header Back is route-level in both states: it emits `backClicked`
|
||||
whether or not inline playback is active, so the arrow keeps one meaning and
|
||||
the list is one click away while watching. Only Escape unwinds one level: watch
|
||||
the list is one click away while watching. The platform Back gestures (the
|
||||
mouse Back button, Alt+← / Cmd+[, and Electron's `app-command` and trackpad
|
||||
swipe; see "Platform gestures" in
|
||||
[Header Back](./workspace-shell.md#header-back)) run the same target, so they
|
||||
leave the page in watch too, except while the player is fullscreen, where
|
||||
they do nothing. Only Escape unwinds one level: watch
|
||||
emits `closePlayerRequested`, browse emits `backClicked`. Hosts retain their
|
||||
existing route/inline/collection return behavior. The now-playing bar carries
|
||||
no second arrow; its "Close player" button is the pointer counterpart of the
|
||||
|
||||
@@ -97,8 +97,9 @@ The shell is intentionally split into four persistent regions:
|
||||
or Sources when the dashboard is off).
|
||||
2. Top header:
|
||||
1. Leading Back slot: the current page's registered Back, else browser
|
||||
history while an in-app previous page exists, else nothing. See
|
||||
[Header Back](#header-back).
|
||||
history while an in-app previous page exists, else nothing. The mouse
|
||||
Back button, Alt+← / Cmd+[ and the Electron window gestures run it.
|
||||
See [Header Back](#header-back).
|
||||
2. Playlist switcher.
|
||||
3. Route-aware search input and command palette trigger.
|
||||
4. Add source action.
|
||||
@@ -319,13 +320,80 @@ of jumping (see [Layout slides](#layout-slides)).
|
||||
There is no Forward button: Stalker inline details are store state, not
|
||||
history entries, so Forward would skip them.
|
||||
|
||||
**Platform gestures.** The mouse Back button, Alt+← (Cmd+[ on macOS) and, in
|
||||
Electron, the window's `app-command` `browser-backward` (Windows and Linux:
|
||||
mouse Back buttons and the Browser Back key) and trackpad `swipe` left (macOS)
|
||||
run the same action as the header button, through
|
||||
`WorkspaceBackGesturesService` (shell-provided). Each gesture has one of
|
||||
three results (`WorkspaceBackGestureResult`):
|
||||
|
||||
- `back`: the header has a target and nothing sits above the page, so
|
||||
`WorkspaceBackNavigationService.goBack()` runs. "Above the page" is document
|
||||
fullscreen (a fullscreen player), an open phone context drawer, a showing
|
||||
CDK backdrop, or a visible `[role="dialog"]`, `[role="alertdialog"]` or
|
||||
`[role="menu"]` outside hidden, `inert` or `aria-hidden` subtrees.
|
||||
- `blocked`: something sits above the page. Nothing navigates, and the
|
||||
gesture is still swallowed, because browser Back would navigate under the
|
||||
dialog (which then closes) or out of the fullscreen player.
|
||||
- `none`: the header has no Back. The gesture stays the browser's: in the PWA
|
||||
it may leave the app, as browser Back does.
|
||||
|
||||
Unless the result is `none`, the browser's default is prevented (the mouse
|
||||
press on `mouseup` and `auxclick`, listened to in the capture phase), so the
|
||||
browser cannot take a second history step. In Electron the mouse Back button
|
||||
is prevented even then: Electron's Chromium steps through history on an
|
||||
unprevented press itself, also to entries from before a reload, which reloads
|
||||
the app.
|
||||
|
||||
- **Chord.** `navigate-back` in `keyboard-shortcut-definitions.ts` lists it in
|
||||
the shortcuts dialog; `isWorkspaceBackShortcut()` matches it, on macOS also
|
||||
by the `BracketLeft` key position for layouts that type `[` with Option.
|
||||
`WorkspaceKeyboardShortcutsService` handles it in a capture-phase document
|
||||
listener and stops a chord that runs Back there, so a focused carousel, tab
|
||||
list, guide or player does not also read it as a plain arrow; a blocked
|
||||
chord is prevented but still reaches the dialog or menu. It is ignored
|
||||
while typing (Alt and Option arrows edit text). A held chord runs Back
|
||||
once: its repeats follow its first press until a key is released, so after
|
||||
a press that ran Back they are swallowed even when the page it reached has
|
||||
no Back left, and the browser cannot step on them.
|
||||
- **Electron.** `attachWindowBackGestures()`
|
||||
(`apps/electron-backend/src/app/services/window-back-gestures.ts`) forwards
|
||||
the two window events over the main → renderer push channel
|
||||
`WINDOW:BACK_REQUESTED` with the source (`app-command` or `swipe`); the
|
||||
preload's `onWindowBackRequested` passes only those two values on. The
|
||||
renderer subscribes only when
|
||||
`RuntimeCapabilitiesService.supportsWindowBackGestures` reports the
|
||||
subscription, so an older preload forwards nothing. `browser-forward` and
|
||||
`swipe` right are not forwarded. `swipe` needs macOS's "Swipe between
|
||||
pages" set to two or three fingers. On Linux Electron raises `app-command`
|
||||
for the keydown and the keyup of the Browser Back key alike, so the main
|
||||
process takes that key in `before-input-event` instead (Back once per
|
||||
press, the key never reaches the page) and leaves `app-command` to mouse
|
||||
Back buttons.
|
||||
- **One press, two events.** In Electron on Windows and Linux a mouse Back
|
||||
press also reaches the window as `app-command`: with the release on
|
||||
Windows, on press on Linux, and before or after the renderer sees the
|
||||
button. Whichever half comes first runs Back and the other is consumed. A
|
||||
command that arrives while the button is down belongs to that press however
|
||||
long it is held; otherwise a command and a press (or a press and a command)
|
||||
within `BACK_PRESS_TWIN_MS` (500 ms) of each other pair. Each press pairs
|
||||
once, so two quick presses still go back twice, and the chord and the swipe
|
||||
never pair.
|
||||
|
||||
Electron E2E `back-gestures.e2e.ts` drives each gesture (the mouse through
|
||||
CDP's `back` button, the window events emitted on the `BrowserWindow`) and
|
||||
checks that each goes back one history entry, a paired press goes back once
|
||||
(also when held), Forward does nothing, and the gestures wait while typing
|
||||
(the chord) or with a dialog open. Web E2E `workspace-header-back.e2e.ts`
|
||||
checks that the mouse Back button and the chord step once in the browser.
|
||||
|
||||
**Phone width.** `phoneDrawerToggle` sets how Back shares the leading slot with
|
||||
the context drawer toggle. `replace` (the default) takes the toggle's slot: a
|
||||
detail page's drawer belongs to the list that Back returns to. `beside` keeps
|
||||
both: the settings drawer holds the page's own sections. `yield` hides Back
|
||||
while the toggle shows: the history fallback must not cost a category list its
|
||||
only way into the drawer, and two navigation icons do not fit beside the
|
||||
switcher. System and browser Back still work there.
|
||||
switcher. The platform gestures above and browser Back still work there.
|
||||
|
||||
**Left in place.** These controls stay inside their surface on purpose:
|
||||
|
||||
@@ -517,7 +585,8 @@ Keyboard shortcut help is shell-owned:
|
||||
|
||||
1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`.
|
||||
It owns the workspace-scoped `document:keydown` listener for `?` /
|
||||
`Shift+/`.
|
||||
`Shift+/`, and a capture-phase one for the Back chord (see
|
||||
[Header Back](#header-back)).
|
||||
2. The listener ignores events from inputs, textareas, selects, and
|
||||
content-editable elements via `isTypingInInput(...)`.
|
||||
3. `libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts` is the
|
||||
|
||||
@@ -134,6 +134,16 @@ export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [
|
||||
order: 70,
|
||||
electronOnly: true,
|
||||
},
|
||||
{
|
||||
// The header's Back, like the browser chord on each platform. The
|
||||
// mouse Back button and the macOS trackpad swipe run it too.
|
||||
id: 'navigate-back',
|
||||
group: 'navigation',
|
||||
labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.GO_BACK',
|
||||
icon: 'arrow_back',
|
||||
keys: [{ mac: ['Cmd', '['], other: ['Alt', 'ArrowLeft'] }],
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'toggle-sidebar',
|
||||
group: 'navigation',
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
APP_KEYBOARD_SHORTCUTS,
|
||||
getKeyboardShortcutGroups,
|
||||
isKeyboardShortcutHelpTrigger,
|
||||
isWorkspaceBackShortcut,
|
||||
resolveZoomShortcutAction,
|
||||
} from './keyboard-shortcuts';
|
||||
|
||||
@@ -82,6 +83,140 @@ describe('keyboard shortcuts registry', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists Back first in the navigation group in every runtime with platform chords', () => {
|
||||
for (const isElectron of [false, true]) {
|
||||
const linuxGroups = getKeyboardShortcutGroups({
|
||||
isMac: false,
|
||||
isElectron,
|
||||
});
|
||||
const macGroups = getKeyboardShortcutGroups({
|
||||
isMac: true,
|
||||
isElectron,
|
||||
});
|
||||
|
||||
expect(
|
||||
linuxGroups.find((group) => group.id === 'navigation')?.items[0]
|
||||
?.id
|
||||
).toBe('navigate-back');
|
||||
expect(findChordLabels(linuxGroups, 'navigate-back')).toEqual([
|
||||
['Alt', '←'],
|
||||
]);
|
||||
expect(findChordLabels(macGroups, 'navigate-back')).toEqual([
|
||||
['Cmd', '['],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
describe('isWorkspaceBackShortcut', () => {
|
||||
const press = (init: KeyboardEventInit) =>
|
||||
new KeyboardEvent('keydown', init);
|
||||
const other = { isMac: false };
|
||||
const mac = { isMac: true };
|
||||
|
||||
it('matches Alt+ArrowLeft on Windows and Linux only', () => {
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: 'ArrowLeft', altKey: true }),
|
||||
other
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: 'ArrowLeft', altKey: true }),
|
||||
mac
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(press({ key: 'ArrowLeft' }), other)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('matches Cmd+[ on macOS only, by character or key position', () => {
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: '[', code: 'BracketLeft', metaKey: true }),
|
||||
mac
|
||||
)
|
||||
).toBe(true);
|
||||
// German layout: the BracketLeft position types "ü", and "[" is
|
||||
// Option+5.
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: 'ü', code: 'BracketLeft', metaKey: true }),
|
||||
mac
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({
|
||||
key: '[',
|
||||
code: 'Digit5',
|
||||
metaKey: true,
|
||||
altKey: true,
|
||||
}),
|
||||
mac
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: '[', code: 'BracketLeft', metaKey: true }),
|
||||
other
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra modifiers, AltGr and Forward', () => {
|
||||
// AltGr reports Ctrl+Alt on Windows.
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: 'ArrowLeft', altKey: true, ctrlKey: true }),
|
||||
other
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: 'ArrowLeft', altKey: true, shiftKey: true }),
|
||||
other
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: 'ArrowRight', altKey: true }),
|
||||
other
|
||||
)
|
||||
).toBe(false);
|
||||
// Cmd+Shift+[ ("{") switches tabs in macOS apps.
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({
|
||||
key: '{',
|
||||
code: 'BracketLeft',
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
mac
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({
|
||||
key: '[',
|
||||
code: 'BracketLeft',
|
||||
metaKey: true,
|
||||
ctrlKey: true,
|
||||
}),
|
||||
mac
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWorkspaceBackShortcut(
|
||||
press({ key: ']', code: 'BracketRight', metaKey: true }),
|
||||
mac
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveZoomShortcutAction', () => {
|
||||
const press = (init: KeyboardEventInit) =>
|
||||
new KeyboardEvent('keydown', init);
|
||||
|
||||
@@ -56,6 +56,7 @@ const KEY_ARIA_LABELS = new Map<string, string>([
|
||||
['ArrowUp', 'Up arrow'],
|
||||
['ArrowDown', 'Down arrow'],
|
||||
['Escape', 'Escape'],
|
||||
['[', 'Left bracket'],
|
||||
]);
|
||||
|
||||
const MODIFIER_KEYS = new Set(['Cmd', 'Ctrl', 'Alt', 'Shift']);
|
||||
@@ -91,6 +92,35 @@ export function isKeyboardShortcutHelpTrigger(event: KeyboardEvent): boolean {
|
||||
return event.key === '?' || (event.key === '/' && event.shiftKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the Back chord: Cmd+[ on macOS, Alt+← elsewhere (the browser's
|
||||
* own chords). On macOS the key position counts as well (`BracketLeft`), so
|
||||
* layouts that type `[` with Option still have a Cmd chord on that key.
|
||||
*/
|
||||
export function isWorkspaceBackShortcut(
|
||||
event: KeyboardEvent,
|
||||
options: { isMac: boolean }
|
||||
): boolean {
|
||||
if (event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.isMac) {
|
||||
return (
|
||||
event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
(event.key === '[' || event.code === 'BracketLeft')
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
event.key === 'ArrowLeft'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The app zoom shortcut `event` carries, or null. Follows the browser
|
||||
* convention on each platform — Cmd on macOS, Ctrl elsewhere, Alt never:
|
||||
|
||||
@@ -325,6 +325,22 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsStartupWindowMode).toBe(true);
|
||||
});
|
||||
|
||||
it('offers window Back gestures only with their push subscription', () => {
|
||||
const service = new RuntimeCapabilitiesService();
|
||||
|
||||
testWindow.electron = undefined;
|
||||
expect(service.supportsWindowBackGestures).toBe(false);
|
||||
|
||||
// An Electron bridge without the subscription (an older preload)
|
||||
// forwards no gestures.
|
||||
testWindow.electron = { platform: 'darwin', minimizeWindow: jest.fn() };
|
||||
expect(service.isElectron).toBe(true);
|
||||
expect(service.supportsWindowBackGestures).toBe(false);
|
||||
|
||||
testWindow.electron = { onWindowBackRequested: jest.fn() };
|
||||
expect(service.supportsWindowBackGestures).toBe(true);
|
||||
});
|
||||
|
||||
it('decouples external player launch support from path-setting support', () => {
|
||||
testWindow.electron = {
|
||||
openInMpv: jest.fn(),
|
||||
|
||||
@@ -70,6 +70,15 @@ export class RuntimeCapabilitiesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The main process forwards the window's platform Back gestures (mouse
|
||||
* Back buttons and Browser Back through `app-command` on Windows/Linux,
|
||||
* the trackpad swipe on macOS) to the workspace Back.
|
||||
*/
|
||||
get supportsWindowBackGestures(): boolean {
|
||||
return this.hasElectronMethod('onWindowBackRequested');
|
||||
}
|
||||
|
||||
/** Desktop-only preference: PWA clients cannot configure the shared server guard. */
|
||||
get supportsPortalConnectivityGuard(): boolean {
|
||||
return (
|
||||
|
||||
@@ -224,6 +224,13 @@ export interface ElectronBridgeWindowState {
|
||||
isFullScreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window event behind a Back request: `app-command` (`browser-backward`,
|
||||
* Windows and Linux: mouse Back buttons and the keyboard Browser Back key)
|
||||
* or `swipe` (macOS trackpad swipe).
|
||||
*/
|
||||
export type ElectronBridgeBackGestureSource = 'app-command' | 'swipe';
|
||||
|
||||
/**
|
||||
* Portal credentials for an auth-gated stream, passed alongside the scoped
|
||||
* header override. The main process attaches them only to requests going to
|
||||
@@ -779,6 +786,14 @@ export interface ElectronBridgeApi {
|
||||
onWindowStateChange: (
|
||||
callback: (state: ElectronBridgeWindowState) => void
|
||||
) => () => void;
|
||||
/**
|
||||
* Platform Back gestures the window receives, forwarded from the main
|
||||
* process. Subscribe-only: the renderer runs the workspace Back (or
|
||||
* ignores the request) and sends nothing back. Returns the unsubscribe.
|
||||
*/
|
||||
onWindowBackRequested: (
|
||||
callback: (source: ElectronBridgeBackGestureSource) => void
|
||||
) => () => void;
|
||||
/**
|
||||
* While active, the main process intercepts window close / app quit and
|
||||
* pushes a close request to the renderer instead of closing. Used by the
|
||||
|
||||
@@ -140,6 +140,11 @@ export const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
|
||||
export const WINDOW_CLOSE = 'WINDOW:CLOSE';
|
||||
export const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
|
||||
export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
|
||||
// Main → renderer only: the window received a platform Back gesture (Windows
|
||||
// and Linux `app-command` `browser-backward`, macOS trackpad `swipe` left),
|
||||
// carrying an `ElectronBridgeBackGestureSource`. The renderer decides
|
||||
// whether Back runs; nothing flows the other way.
|
||||
export const WINDOW_BACK_REQUESTED = 'WINDOW:BACK_REQUESTED';
|
||||
// Synchronous preload request for the persisted app zoom level (answered
|
||||
// with `event.returnValue`), and the preload's acknowledgement once it has
|
||||
// applied that level with webFrame.setZoomLevel at DOMContentLoaded
|
||||
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import type { ElectronBridgeBackGestureSource } from '@iptvnator/shared/interfaces';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
BACK_PRESS_TWIN_MS,
|
||||
WorkspaceBackGesturesService,
|
||||
} from './workspace-back-gestures.service';
|
||||
|
||||
const MOUSE_BACK = 3;
|
||||
|
||||
describe('WorkspaceBackGesturesService', () => {
|
||||
const testWindow = window as unknown as {
|
||||
electron?: Record<string, unknown>;
|
||||
};
|
||||
const backTarget: WorkspaceBackTarget = {
|
||||
label: signal(null),
|
||||
escapeShortcut: signal(false),
|
||||
run: jest.fn(),
|
||||
};
|
||||
let target: ReturnType<typeof signal<WorkspaceBackTarget | null>>;
|
||||
let backNavigation: { target: typeof target; goBack: jest.Mock };
|
||||
let runtime: { supportsWindowBackGestures: boolean };
|
||||
let drawerOpen: boolean;
|
||||
let bridgeListener:
|
||||
((source: ElectronBridgeBackGestureSource) => void) | null;
|
||||
let unsubscribeBridge: jest.Mock;
|
||||
let now: number;
|
||||
|
||||
function setFullscreenElement(element: Element | null | undefined): void {
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
value: element,
|
||||
});
|
||||
}
|
||||
|
||||
function createService(): WorkspaceBackGesturesService {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WorkspaceBackGesturesService,
|
||||
{
|
||||
provide: WorkspaceBackNavigationService,
|
||||
useValue: backNavigation,
|
||||
},
|
||||
{ provide: RuntimeCapabilitiesService, useValue: runtime },
|
||||
{
|
||||
provide: WorkspaceShellContextDrawerService,
|
||||
useValue: { isOpen: () => drawerOpen },
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(WorkspaceBackGesturesService);
|
||||
}
|
||||
|
||||
function mouse(type: string, button = MOUSE_BACK): MouseEvent {
|
||||
const event = new MouseEvent(type, {
|
||||
button,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.body.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** One mouse Back press; returns its mouseup and auxclick events. */
|
||||
function pressMouseBack(button = MOUSE_BACK): {
|
||||
mouseup: MouseEvent;
|
||||
auxclick: MouseEvent;
|
||||
} {
|
||||
mouse('mousedown', button);
|
||||
const mouseup = mouse('mouseup', button);
|
||||
const auxclick = mouse('auxclick', button);
|
||||
return { mouseup, auxclick };
|
||||
}
|
||||
|
||||
function windowGesture(source: ElectronBridgeBackGestureSource): void {
|
||||
expect(bridgeListener).not.toBeNull();
|
||||
bridgeListener?.(source);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
target = signal<WorkspaceBackTarget | null>(backTarget);
|
||||
backNavigation = {
|
||||
target,
|
||||
goBack: jest.fn(() => target() !== null),
|
||||
};
|
||||
runtime = { supportsWindowBackGestures: true };
|
||||
drawerOpen = false;
|
||||
bridgeListener = null;
|
||||
unsubscribeBridge = jest.fn();
|
||||
testWindow.electron = {
|
||||
onWindowBackRequested: jest.fn(
|
||||
(
|
||||
listener: (source: ElectronBridgeBackGestureSource) => void
|
||||
) => {
|
||||
bridgeListener = listener;
|
||||
return unsubscribeBridge;
|
||||
}
|
||||
),
|
||||
};
|
||||
now = 1_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
setFullscreenElement(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
delete testWindow.electron;
|
||||
setFullscreenElement(null);
|
||||
document.body.replaceChildren();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('mouse Back button', () => {
|
||||
it('runs Back and keeps the browser from its own history step', () => {
|
||||
createService();
|
||||
|
||||
const { mouseup, auxclick } = pressMouseBack();
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(1);
|
||||
expect(mouseup.defaultPrevented).toBe(true);
|
||||
expect(auxclick.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the press to the browser where there is no Back (PWA)', () => {
|
||||
target.set(null);
|
||||
runtime.supportsWindowBackGestures = false;
|
||||
createService();
|
||||
|
||||
const { mouseup, auxclick } = pressMouseBack();
|
||||
|
||||
expect(backNavigation.goBack).not.toHaveBeenCalled();
|
||||
expect(mouseup.defaultPrevented).toBe(false);
|
||||
expect(auxclick.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the press from Chromium in Electron even without a Back', () => {
|
||||
// Chromium would traverse to an entry from before a reload,
|
||||
// which reloads the app.
|
||||
target.set(null);
|
||||
createService();
|
||||
|
||||
const { mouseup, auxclick } = pressMouseBack();
|
||||
|
||||
expect(backNavigation.goBack).not.toHaveBeenCalled();
|
||||
expect(mouseup.defaultPrevented).toBe(true);
|
||||
expect(auxclick.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 4])('ignores mouse button %i', (button) => {
|
||||
createService();
|
||||
|
||||
const { mouseup } = pressMouseBack(button);
|
||||
|
||||
expect(backNavigation.goBack).not.toHaveBeenCalled();
|
||||
expect(mouseup.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('runs once per press, before handlers on the page', () => {
|
||||
createService();
|
||||
const pageListener = jest.fn((event: Event) =>
|
||||
event.stopPropagation()
|
||||
);
|
||||
document.body.addEventListener('mouseup', pageListener);
|
||||
|
||||
pressMouseBack();
|
||||
pressMouseBack();
|
||||
|
||||
expect(pageListener).toHaveBeenCalledTimes(2);
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(2);
|
||||
document.body.removeEventListener('mouseup', pageListener);
|
||||
});
|
||||
|
||||
it('stops listening when the shell goes away', () => {
|
||||
createService();
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
pressMouseBack();
|
||||
|
||||
expect(backNavigation.goBack).not.toHaveBeenCalled();
|
||||
expect(unsubscribeBridge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked', () => {
|
||||
/** Nothing navigates, and the browser takes no step either. */
|
||||
function expectIgnored(service: WorkspaceBackGesturesService): void {
|
||||
expect(service.peek()).toBe('blocked');
|
||||
expect(service.goBack('keyboard')).toBe('blocked');
|
||||
windowGesture('swipe');
|
||||
windowGesture('app-command');
|
||||
expect(backNavigation.goBack).not.toHaveBeenCalled();
|
||||
|
||||
// Swallowed in the PWA too: browser Back would navigate under
|
||||
// the dialog or out of the fullscreen player.
|
||||
TestBed.resetTestingModule();
|
||||
runtime.supportsWindowBackGestures = false;
|
||||
createService();
|
||||
const { mouseup, auxclick } = pressMouseBack();
|
||||
expect(mouseup.defaultPrevented).toBe(true);
|
||||
expect(auxclick.defaultPrevented).toBe(true);
|
||||
expect(backNavigation.goBack).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it('while a player is fullscreen', () => {
|
||||
const service = createService();
|
||||
setFullscreenElement(document.createElement('video'));
|
||||
|
||||
expectIgnored(service);
|
||||
});
|
||||
|
||||
it('not where the Fullscreen API is missing', () => {
|
||||
// iPhone Safari has no unprefixed `document.fullscreenElement`.
|
||||
const service = createService();
|
||||
setFullscreenElement(undefined);
|
||||
|
||||
expect(service.peek()).toBe('back');
|
||||
expect(service.goBack('keyboard')).toBe('back');
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('while the phone context drawer is open', () => {
|
||||
const service = createService();
|
||||
drawerOpen = true;
|
||||
|
||||
expectIgnored(service);
|
||||
});
|
||||
|
||||
it.each(['dialog', 'alertdialog', 'menu'])(
|
||||
'while a %s is open',
|
||||
(role) => {
|
||||
const service = createService();
|
||||
const popup = document.createElement('div');
|
||||
popup.setAttribute('role', role);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
expectIgnored(service);
|
||||
}
|
||||
);
|
||||
|
||||
it('while a CDK overlay backdrop shows', () => {
|
||||
const service = createService();
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className =
|
||||
'cdk-overlay-backdrop cdk-overlay-backdrop-showing';
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
expectIgnored(service);
|
||||
});
|
||||
|
||||
it('not by hidden popups or a detaching backdrop', () => {
|
||||
const service = createService();
|
||||
const hidden = document.createElement('div');
|
||||
hidden.setAttribute('role', 'dialog');
|
||||
hidden.hidden = true;
|
||||
const ariaHidden = document.createElement('div');
|
||||
ariaHidden.setAttribute('aria-hidden', 'true');
|
||||
ariaHidden.innerHTML = '<div role="menu"></div>';
|
||||
const detaching = document.createElement('div');
|
||||
detaching.className = 'cdk-overlay-backdrop';
|
||||
const listbox = document.createElement('div');
|
||||
listbox.setAttribute('role', 'listbox');
|
||||
document.body.append(hidden, ariaHidden, detaching, listbox);
|
||||
|
||||
expect(service.peek()).toBe('back');
|
||||
expect(service.goBack('keyboard')).toBe('back');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Electron window gestures', () => {
|
||||
it('runs Back for the swipe and the app command', () => {
|
||||
const service = createService();
|
||||
|
||||
windowGesture('swipe');
|
||||
windowGesture('app-command');
|
||||
now += BACK_PRESS_TWIN_MS + 1;
|
||||
target.set(null);
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(2);
|
||||
expect(service.goBack('swipe')).toBe('none');
|
||||
});
|
||||
|
||||
it('does not subscribe without the capability', () => {
|
||||
runtime.supportsWindowBackGestures = false;
|
||||
createService();
|
||||
|
||||
expect(
|
||||
testWindow.electron?.['onWindowBackRequested']
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs Back once when the command follows the release (Windows)', () => {
|
||||
createService();
|
||||
|
||||
const { mouseup, auxclick } = pressMouseBack();
|
||||
now += 5;
|
||||
windowGesture('app-command');
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(1);
|
||||
expect(mouseup.defaultPrevented).toBe(true);
|
||||
expect(auxclick.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('runs Back once when the command arrives while the button is down, however long it is held (Linux)', () => {
|
||||
createService();
|
||||
|
||||
mouse('mousedown');
|
||||
windowGesture('app-command');
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(1);
|
||||
now += 2_000;
|
||||
const mouseup = mouse('mouseup');
|
||||
const auxclick = mouse('auxclick');
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(1);
|
||||
// The renderer half is spent, so the browser takes no step either.
|
||||
expect(mouseup.defaultPrevented).toBe(true);
|
||||
expect(auxclick.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('runs Back once when the command arrives before the renderer sees the press', () => {
|
||||
createService();
|
||||
|
||||
windowGesture('app-command');
|
||||
now += 5;
|
||||
mouse('mousedown');
|
||||
now += 2_000;
|
||||
const mouseup = mouse('mouseup');
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(1);
|
||||
expect(mouseup.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('runs Back for each of two quick presses that both arrive twice', () => {
|
||||
createService();
|
||||
|
||||
pressMouseBack();
|
||||
windowGesture('app-command');
|
||||
now += 120;
|
||||
mouse('mousedown');
|
||||
windowGesture('app-command');
|
||||
mouse('mouseup');
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('treats a command after the pairing window as a new press', () => {
|
||||
createService();
|
||||
|
||||
pressMouseBack();
|
||||
now += BACK_PRESS_TWIN_MS + 1;
|
||||
windowGesture('app-command');
|
||||
now += BACK_PRESS_TWIN_MS + 1;
|
||||
pressMouseBack();
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('pairs only the mouse with the app command', () => {
|
||||
const service = createService();
|
||||
|
||||
pressMouseBack();
|
||||
windowGesture('swipe');
|
||||
expect(service.goBack('keyboard')).toBe('back');
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('spends no pairing on a press that did not run Back', () => {
|
||||
target.set(null);
|
||||
createService();
|
||||
|
||||
pressMouseBack();
|
||||
target.set(backTarget);
|
||||
windowGesture('app-command');
|
||||
|
||||
expect(backNavigation.goBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { DestroyRef, inject, Injectable } from '@angular/core';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import type { ElectronBridgeBackGestureSource } from '@iptvnator/shared/interfaces';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
/** What asked for Back, other than the mouse Back button. */
|
||||
export type WorkspaceBackGestureSource =
|
||||
'keyboard' | ElectronBridgeBackGestureSource;
|
||||
|
||||
/**
|
||||
* What a gesture does now. `back`: the header's Back runs. `blocked`: a
|
||||
* dialog, menu, the phone drawer or a fullscreen player is up, so the
|
||||
* gesture is swallowed and nothing navigates. `none`: the header has no
|
||||
* Back, so the gesture stays the browser's.
|
||||
*/
|
||||
export type WorkspaceBackGestureResult = 'back' | 'blocked' | 'none';
|
||||
|
||||
/** `MouseEvent.button` of the mouse Back button. */
|
||||
const MOUSE_BACK_BUTTON = 3;
|
||||
|
||||
/**
|
||||
* In Electron on Windows and Linux one mouse Back press also reaches the
|
||||
* window as `app-command`: on release on Windows, on press on Linux, and
|
||||
* either before or after the renderer sees the button. A command and a
|
||||
* press this close together are one press.
|
||||
*/
|
||||
export const BACK_PRESS_TWIN_MS = 500;
|
||||
|
||||
/** Popups that own input while they are open. */
|
||||
const POPUP_SELECTOR = '[role="dialog"], [role="alertdialog"], [role="menu"]';
|
||||
|
||||
function isShown(element: HTMLElement): boolean {
|
||||
return (
|
||||
!element.closest('[hidden], [inert], [aria-hidden="true"]') &&
|
||||
element.checkVisibility?.({ checkVisibilityCSS: true }) !== false
|
||||
);
|
||||
}
|
||||
|
||||
function hasOpenPopup(doc: Document): boolean {
|
||||
return (
|
||||
doc.querySelector('.cdk-overlay-backdrop-showing') !== null ||
|
||||
Array.from(doc.querySelectorAll<HTMLElement>(POPUP_SELECTOR)).some(
|
||||
isShown
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes platform Back gestures to the header's Back
|
||||
* (`WorkspaceBackNavigationService.goBack()`): the mouse Back button here,
|
||||
* the Alt+← / Cmd+[ chord through `WorkspaceKeyboardShortcutsService`, and
|
||||
* in Electron the window's `app-command` and trackpad swipe, which the main
|
||||
* process forwards. A gesture runs Back, or is swallowed while something
|
||||
* sits above the page, and then keeps the browser from its own history
|
||||
* step; see {@link WorkspaceBackGestureResult}.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WorkspaceBackGesturesService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly backNavigation = inject(WorkspaceBackNavigationService);
|
||||
// Root-provided; optional keeps standalone unit tests light.
|
||||
private readonly contextDrawer = inject(
|
||||
WorkspaceShellContextDrawerService,
|
||||
{ optional: true }
|
||||
);
|
||||
|
||||
/** The mouse Back press in progress, from its mousedown to mouseup. */
|
||||
private press: { handled: boolean } | null = null;
|
||||
/** Whether the last mouse Back press was consumed; auxclick follows it. */
|
||||
private pressConsumed = false;
|
||||
/** The last press or command that ran Back alone, awaiting its twin. */
|
||||
private unpaired: { source: 'pointer' | 'window'; at: number } | null =
|
||||
null;
|
||||
|
||||
private readonly onMouseDown = (event: MouseEvent): void => {
|
||||
if (event.button !== MOUSE_BACK_BUTTON) return;
|
||||
// A command that ran Back just before was this press (Linux).
|
||||
this.press = { handled: this.takeUnpaired('window') };
|
||||
};
|
||||
|
||||
private readonly onMouseUp = (event: MouseEvent): void => {
|
||||
if (event.button !== MOUSE_BACK_BUTTON) return;
|
||||
const press = this.press ?? { handled: this.takeUnpaired('window') };
|
||||
this.press = null;
|
||||
let result: WorkspaceBackGestureResult = 'back';
|
||||
if (!press.handled) {
|
||||
result = this.runBack();
|
||||
if (result === 'back') {
|
||||
press.handled = true;
|
||||
this.unpaired = { source: 'pointer', at: Date.now() };
|
||||
}
|
||||
}
|
||||
// Electron's Chromium steps through history on an unprevented press
|
||||
// too, even back to entries from before a reload, which reloads the
|
||||
// app. Where the window forwards Back gestures, the workspace owns
|
||||
// the button outright.
|
||||
this.pressConsumed =
|
||||
result !== 'none' || this.runtime.supportsWindowBackGestures;
|
||||
if (this.pressConsumed) event.preventDefault();
|
||||
};
|
||||
|
||||
private readonly onAuxClick = (event: MouseEvent): void => {
|
||||
if (event.button === MOUSE_BACK_BUTTON && this.pressConsumed) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const doc = this.document;
|
||||
// Capture phase: the press belongs to the workspace, whatever
|
||||
// element is under the pointer.
|
||||
doc.addEventListener('mousedown', this.onMouseDown, true);
|
||||
doc.addEventListener('mouseup', this.onMouseUp, true);
|
||||
doc.addEventListener('auxclick', this.onAuxClick, true);
|
||||
|
||||
// The capability, not a generic bridge check: an older preload has
|
||||
// no subscription to offer.
|
||||
const unsubscribe = this.runtime.supportsWindowBackGestures
|
||||
? window.electron?.onWindowBackRequested((source) => {
|
||||
this.goBack(source);
|
||||
})
|
||||
: undefined;
|
||||
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
doc.removeEventListener('mousedown', this.onMouseDown, true);
|
||||
doc.removeEventListener('mouseup', this.onMouseUp, true);
|
||||
doc.removeEventListener('auxclick', this.onAuxClick, true);
|
||||
unsubscribe?.();
|
||||
});
|
||||
}
|
||||
|
||||
/** What a gesture would do now, without running it. */
|
||||
peek(): WorkspaceBackGestureResult {
|
||||
if (this.isBlocked()) return 'blocked';
|
||||
return this.backNavigation.target() ? 'back' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the header's Back for a gesture. `back` also covers the window's
|
||||
* half of a mouse press that ran Back through its other half. Unless the
|
||||
* result is `none`, the caller prevents the browser's default.
|
||||
*/
|
||||
goBack(source: WorkspaceBackGestureSource): WorkspaceBackGestureResult {
|
||||
return source === 'app-command'
|
||||
? this.onWindowCommand()
|
||||
: this.runBack();
|
||||
}
|
||||
|
||||
private onWindowCommand(): WorkspaceBackGestureResult {
|
||||
const press = this.press;
|
||||
if (press && !press.handled) {
|
||||
// The command of the press in progress (Linux sends it on press,
|
||||
// Windows with the release): Back runs now, the mouseup is spent.
|
||||
const result = this.runBack();
|
||||
press.handled = result === 'back';
|
||||
return result;
|
||||
}
|
||||
if (this.takeUnpaired('pointer')) return 'back';
|
||||
const result = this.runBack();
|
||||
if (result === 'back') {
|
||||
this.unpaired = { source: 'window', at: Date.now() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private runBack(): WorkspaceBackGestureResult {
|
||||
const result = this.peek();
|
||||
if (result === 'back') this.backNavigation.goBack();
|
||||
return result;
|
||||
}
|
||||
|
||||
private isBlocked(): boolean {
|
||||
const doc = this.document;
|
||||
return (
|
||||
// Undefined where the Fullscreen API is missing (iPhone Safari).
|
||||
doc.fullscreenElement != null ||
|
||||
this.contextDrawer?.isOpen() === true ||
|
||||
hasOpenPopup(doc)
|
||||
);
|
||||
}
|
||||
|
||||
private takeUnpaired(source: 'pointer' | 'window'): boolean {
|
||||
const unpaired = this.unpaired;
|
||||
if (unpaired?.source !== source) return false;
|
||||
this.unpaired = null;
|
||||
return Date.now() - unpaired.at <= BACK_PRESS_TWIN_MS;
|
||||
}
|
||||
}
|
||||
+184
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { WorkspaceBackGesturesService } from '../workspace-back-gestures/workspace-back-gestures.service';
|
||||
import { WorkspaceKeyboardShortcutsDialogData } from './workspace-keyboard-shortcuts-dialog.component';
|
||||
import { WorkspaceKeyboardShortcutsService } from './workspace-keyboard-shortcuts.service';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
@@ -15,6 +16,7 @@ describe('WorkspaceKeyboardShortcutsService', () => {
|
||||
>;
|
||||
};
|
||||
let runtime: { isElectron: boolean };
|
||||
let backGestures: { goBack: jest.Mock };
|
||||
let service: WorkspaceKeyboardShortcutsService;
|
||||
|
||||
function openedDialogData(): WorkspaceKeyboardShortcutsDialogData {
|
||||
@@ -33,12 +35,19 @@ describe('WorkspaceKeyboardShortcutsService', () => {
|
||||
}),
|
||||
};
|
||||
runtime = { isElectron: true };
|
||||
backGestures = {
|
||||
goBack: jest.fn(() => 'back'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WorkspaceKeyboardShortcutsService,
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
{ provide: RuntimeCapabilitiesService, useValue: runtime },
|
||||
{
|
||||
provide: WorkspaceBackGesturesService,
|
||||
useValue: backGestures,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -82,6 +91,10 @@ describe('WorkspaceKeyboardShortcutsService', () => {
|
||||
provide: WorkspaceShellContextDrawerService,
|
||||
useValue: { isOpen: () => true },
|
||||
},
|
||||
{
|
||||
provide: WorkspaceBackGesturesService,
|
||||
useValue: backGestures,
|
||||
},
|
||||
],
|
||||
});
|
||||
TestBed.inject(WorkspaceKeyboardShortcutsService);
|
||||
@@ -106,6 +119,177 @@ describe('WorkspaceKeyboardShortcutsService', () => {
|
||||
input.remove();
|
||||
});
|
||||
|
||||
describe('Back chord', () => {
|
||||
function setPlatform(platform: string): void {
|
||||
Object.defineProperty(navigator, 'userAgentData', {
|
||||
configurable: true,
|
||||
value: { platform },
|
||||
});
|
||||
}
|
||||
|
||||
function press(
|
||||
init: KeyboardEventInit,
|
||||
target: EventTarget = document.body
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
it('runs Back for Alt+ArrowLeft outside macOS and keeps the chord from the page and the browser', () => {
|
||||
setPlatform('Windows');
|
||||
const pageListener = jest.fn();
|
||||
document.body.addEventListener('keydown', pageListener);
|
||||
|
||||
const event = press({ key: 'ArrowLeft', altKey: true });
|
||||
|
||||
expect(backGestures.goBack).toHaveBeenCalledWith('keyboard');
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
// A focused carousel or player must not also read the arrow.
|
||||
expect(pageListener).not.toHaveBeenCalled();
|
||||
document.body.removeEventListener('keydown', pageListener);
|
||||
});
|
||||
|
||||
it('runs Back for Cmd+[ on macOS only', () => {
|
||||
setPlatform('macOS');
|
||||
|
||||
press({ key: 'ArrowLeft', altKey: true });
|
||||
expect(backGestures.goBack).not.toHaveBeenCalled();
|
||||
|
||||
const event = press({
|
||||
key: '[',
|
||||
code: 'BracketLeft',
|
||||
metaKey: true,
|
||||
});
|
||||
expect(backGestures.goBack).toHaveBeenCalledWith('keyboard');
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('swallows the chord without stopping it while something sits above the page', () => {
|
||||
setPlatform('Linux');
|
||||
backGestures.goBack.mockReturnValue('blocked');
|
||||
const dialogListener = jest.fn();
|
||||
document.body.addEventListener('keydown', dialogListener);
|
||||
|
||||
const event = press({ key: 'ArrowLeft', altKey: true });
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(dialogListener).toHaveBeenCalledTimes(1);
|
||||
document.body.removeEventListener('keydown', dialogListener);
|
||||
});
|
||||
|
||||
it('leaves the chord to the page and the browser where there is no Back', () => {
|
||||
setPlatform('Linux');
|
||||
backGestures.goBack.mockReturnValue('none');
|
||||
const pageListener = jest.fn();
|
||||
document.body.addEventListener('keydown', pageListener);
|
||||
|
||||
const event = press({ key: 'ArrowLeft', altKey: true });
|
||||
|
||||
expect(backGestures.goBack).toHaveBeenCalledTimes(1);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(pageListener).toHaveBeenCalledTimes(1);
|
||||
document.body.removeEventListener('keydown', pageListener);
|
||||
});
|
||||
|
||||
it('ignores the chord while typing', () => {
|
||||
setPlatform('Windows');
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const event = press({ key: 'ArrowLeft', altKey: true }, input);
|
||||
|
||||
expect(backGestures.goBack).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('runs Back once for a held chord and swallows its repeats until keyup', () => {
|
||||
setPlatform('Windows');
|
||||
const repeat = () =>
|
||||
press({ key: 'ArrowLeft', altKey: true, repeat: true });
|
||||
const pageListener = jest.fn();
|
||||
document.body.addEventListener('keydown', pageListener);
|
||||
|
||||
press({ key: 'ArrowLeft', altKey: true });
|
||||
// Back reached the first in-app page: there is no Back left, but
|
||||
// the browser must still not take a step of its own.
|
||||
backGestures.goBack.mockReturnValue('none');
|
||||
const repeats = [repeat(), repeat()];
|
||||
|
||||
expect(backGestures.goBack).toHaveBeenCalledTimes(1);
|
||||
expect(repeats.map((event) => event.defaultPrevented)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(pageListener).not.toHaveBeenCalled();
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true })
|
||||
);
|
||||
expect(repeat().defaultPrevented).toBe(false);
|
||||
document.body.removeEventListener('keydown', pageListener);
|
||||
});
|
||||
|
||||
it('leaves the repeats of a chord that had no Back to the browser', () => {
|
||||
setPlatform('Windows');
|
||||
backGestures.goBack.mockReturnValue('none');
|
||||
|
||||
press({ key: 'ArrowLeft', altKey: true });
|
||||
backGestures.goBack.mockReturnValue('back');
|
||||
const repeat = press({
|
||||
key: 'ArrowLeft',
|
||||
altKey: true,
|
||||
repeat: true,
|
||||
});
|
||||
|
||||
expect(backGestures.goBack).toHaveBeenCalledTimes(1);
|
||||
expect(repeat.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a blocked chord blocked while it is held', () => {
|
||||
setPlatform('Windows');
|
||||
backGestures.goBack.mockReturnValue('blocked');
|
||||
const dialogListener = jest.fn();
|
||||
document.body.addEventListener('keydown', dialogListener);
|
||||
|
||||
press({ key: 'ArrowLeft', altKey: true });
|
||||
const repeat = press({
|
||||
key: 'ArrowLeft',
|
||||
altKey: true,
|
||||
repeat: true,
|
||||
});
|
||||
|
||||
expect(backGestures.goBack).toHaveBeenCalledTimes(1);
|
||||
expect(repeat.defaultPrevented).toBe(true);
|
||||
expect(dialogListener).toHaveBeenCalledTimes(2);
|
||||
document.body.removeEventListener('keydown', dialogListener);
|
||||
});
|
||||
|
||||
it('ignores other arrows, modifiers and a chord another handler consumed', () => {
|
||||
setPlatform('Windows');
|
||||
|
||||
press({ key: 'ArrowLeft' });
|
||||
press({ key: 'ArrowRight', altKey: true });
|
||||
press({ key: 'ArrowLeft', altKey: true, ctrlKey: true });
|
||||
const consumed = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowLeft',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
consumed.preventDefault();
|
||||
document.body.dispatchEvent(consumed);
|
||||
|
||||
expect(backGestures.goBack).not.toHaveBeenCalled();
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('F11 window fullscreen toggle', () => {
|
||||
const testWindow = window as unknown as {
|
||||
electron?: Record<string, unknown>;
|
||||
|
||||
+70
@@ -6,9 +6,14 @@ import {
|
||||
getKeyboardShortcutGroups,
|
||||
isKeyboardShortcutHelpTrigger,
|
||||
isTypingInInput,
|
||||
isWorkspaceBackShortcut,
|
||||
resolveZoomShortcutAction,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
WorkspaceBackGestureResult,
|
||||
WorkspaceBackGesturesService,
|
||||
} from '../workspace-back-gestures/workspace-back-gestures.service';
|
||||
import {
|
||||
WorkspaceKeyboardShortcutsDialogComponent,
|
||||
WorkspaceKeyboardShortcutsDialogData,
|
||||
@@ -28,8 +33,16 @@ export class WorkspaceKeyboardShortcutsService {
|
||||
optional: true,
|
||||
}
|
||||
);
|
||||
private readonly backGestures = inject(WorkspaceBackGesturesService);
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void =>
|
||||
this.handleKeydown(event);
|
||||
private readonly onDocumentKeydownCapture = (event: KeyboardEvent): void =>
|
||||
this.handleBackShortcut(event);
|
||||
/** The first press of the held Back chord; its repeats follow it. */
|
||||
private heldBackChord: WorkspaceBackGestureResult | null = null;
|
||||
private readonly onDocumentKeyup = (): void => {
|
||||
this.heldBackChord = null;
|
||||
};
|
||||
|
||||
private dialogRef: MatDialogRef<
|
||||
WorkspaceKeyboardShortcutsDialogComponent,
|
||||
@@ -39,8 +52,24 @@ export class WorkspaceKeyboardShortcutsService {
|
||||
constructor() {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
this.onDocumentKeydownCapture,
|
||||
true
|
||||
);
|
||||
document.addEventListener('keyup', this.onDocumentKeyup, true);
|
||||
this.destroyRef.onDestroy(() => {
|
||||
document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
document.removeEventListener(
|
||||
'keydown',
|
||||
this.onDocumentKeydownCapture,
|
||||
true
|
||||
);
|
||||
document.removeEventListener(
|
||||
'keyup',
|
||||
this.onDocumentKeyup,
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -100,6 +129,47 @@ export class WorkspaceKeyboardShortcutsService {
|
||||
this.openShortcutsDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt+← (Cmd+[ on macOS) runs the header's Back through
|
||||
* `WorkspaceBackGesturesService`. It listens in the capture phase and
|
||||
* stops a chord that runs Back there, so a focused carousel, tab list or
|
||||
* player cannot also read it as a plain arrow. While a dialog, menu, the
|
||||
* phone drawer or a fullscreen player is up the chord is swallowed but
|
||||
* still reaches them. Not while typing, where Alt/Option arrows move by
|
||||
* word. A held chord runs Back once: its repeats follow the first press
|
||||
* until a key is released, so after a press that ran Back they are
|
||||
* swallowed even when the page it reached has no Back left, and the
|
||||
* browser cannot step again. Without a Back the chord stays the
|
||||
* browser's.
|
||||
*/
|
||||
private handleBackShortcut(event: KeyboardEvent): void {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
isTypingInInput(event) ||
|
||||
!isWorkspaceBackShortcut(event, {
|
||||
isMac: this.getShortcutPlatform() === 'mac',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let result: WorkspaceBackGestureResult;
|
||||
if (event.repeat) {
|
||||
result = this.heldBackChord ?? 'none';
|
||||
} else {
|
||||
result = this.backGestures.goBack('keyboard');
|
||||
this.heldBackChord = result;
|
||||
}
|
||||
if (result === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (result === 'back') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 toggles OS-level window fullscreen in the desktop app. It is the
|
||||
* way out of a fullscreen launch on Windows/Linux, where the title bar is
|
||||
|
||||
@@ -31,6 +31,7 @@ import { WorkspaceShellHeaderService } from './services/workspace-shell-header.s
|
||||
import { WorkspaceShellRouteStateService } from './services/workspace-shell-route-state.service';
|
||||
import { WorkspaceShellSearchSyncService } from './services/workspace-shell-search-sync.service';
|
||||
import { WorkspaceShellSearchService } from './services/workspace-shell-search.service';
|
||||
import { WorkspaceBackGesturesService } from '../workspace-back-gestures/workspace-back-gestures.service';
|
||||
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
|
||||
import { WorkspaceLiveCategoriesPopoverService } from '../workspace-live-categories-popover/workspace-live-categories-popover.service';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
@@ -65,6 +66,9 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
|
||||
WorkspaceShellXtreamImportService,
|
||||
WorkspaceShellCommandPaletteService,
|
||||
WorkspaceKeyboardShortcutsService,
|
||||
// Mouse Back, the Back chord and the Electron window gestures; the
|
||||
// shortcuts service creates it for the chord.
|
||||
WorkspaceBackGesturesService,
|
||||
// The live layouts inside the router outlet open the folded categories
|
||||
// rail through this token; see `LiveCategoriesPopover`.
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user