mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -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
+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