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:
authored and GitHub committed 2026-10-15 22:15:42 +02:00
1 parent 002faebb08
commit aecb9acb8e
44 files changed
+1758 -8

No files matched your search

@@ -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);
});
});
});
@@ -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;
}
}
@@ -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>;
@@ -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`.
{