The keymap was scattered over ShortcutsService if-chains, the file panel's key switch, macOS-only menu accelerators and hand-typed hints in 16 tooltips x 7 locales, and large parts of the app had no keyboard path at all. src/shared/keymap.ts is now the one table: scopes, per-platform defaults, exact matching, formatting with localized key names, conflicts, reserved combos and native-menu accelerators, proven by keymap.spec.ts. - ShortcutsService resolves against the effective keymap: claim() takes the key (preventDefault, held keys repeat only movement, never mid-IME), and from a text field only chords and F-keys fire. The file panel, App, editor, manager and Settings windows all dispatch through it. - FileActionsService.run is the one file-command dispatcher for the list keys, the row menu, the macOS menu bar and the toolbar. - New keys: the row menu (Shift+F10 / Menu / Ctrl+Return), the source picker (Mod+K), the path bar (Mod+L), transfers (Mod+J), sorting (Alt+1-7), Shift+movement selection, close other tabs, move and disconnect tabs, refresh and edit on Windows/Linux, permissions, copy path, reveal, trash, open in default app, editor find next/previous. - Right-click menus: soft shortcut hints, icons on the everyday verbs (the toolbar's glyphs), section headers, scrolling, item keys while open, a highlighted first item when opened from the keyboard. The panel source picker now renders through the same menu. - Dialogs: role=dialog, a Tab trap via cycleFocus (WKWebView skips buttons on Tab without Keyboard navigation), initial focus, Esc that stops propagating; the chmod dialog takes focus and applies on Enter. - Fixed: Ctrl+Tab also flipped the active panel; Esc in a manager dialog closed the manager; Ctrl-F/T fired Cmd shortcuts on macOS; shortcuts fired under dialogs; keys died after any button click on macOS; the editor's save prompt and find box never got focus; WebView2 reload keys; a window could save default settings before its first load. - Settings closes on its own close-window key (new settingsClose request). Co-Authored-By: Claude <noreply@anthropic.com>
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
import { Injectable, computed, inject } from '@angular/core';
|
|
import {
|
|
type Combo,
|
|
EN_KEY_NAMES,
|
|
effectiveKeymap,
|
|
firesInTextField,
|
|
formatCombo,
|
|
isRepeatable,
|
|
type KeyEventLike,
|
|
type KeyNames,
|
|
matchesEvent,
|
|
parseCombo,
|
|
type ResolvedBinding,
|
|
type Scope,
|
|
sameCombo,
|
|
serializeCombo,
|
|
type ShortcutId,
|
|
} from '@shared/keymap';
|
|
import { I18nService } from './i18n.service';
|
|
import { IS_MAC } from './platform';
|
|
import { SettingsService } from './settings.service';
|
|
|
|
export type { ShortcutId } from '@shared/keymap';
|
|
|
|
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
|
|
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
|
|
|
|
/**
|
|
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
|
|
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
|
|
* remaps, live in every window — and formats the hints menus and tooltips show.
|
|
*
|
|
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
|
|
* macOS and Ctrl elsewhere.
|
|
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
|
|
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
|
|
* key a window claims here (claim() → preventDefault) never reaches the menu
|
|
* bar, whose accelerators bun builds from the same table. Keys nobody claims
|
|
* fall through to it.
|
|
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
|
|
* - Escape, the arrows inside menus and dialog Enter stay component-local:
|
|
* they are fixed, so nobody can remap themselves out of a dialog.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class ShortcutsService {
|
|
private readonly settings = inject(SettingsService);
|
|
private readonly i18n = inject(I18nService);
|
|
|
|
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
|
|
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
|
|
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
|
|
* makes every hint follow a language switch. */
|
|
private readonly names = computed<KeyNames>(() => {
|
|
const names = { ...EN_KEY_NAMES };
|
|
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
|
|
names[key] = this.i18n.t(`keys.names.${key}`);
|
|
}
|
|
return names;
|
|
});
|
|
|
|
/** The command a keydown triggers in `scope`, or null. Window scopes include
|
|
* `any`; `panel` doesn't (the window root runs those). */
|
|
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
|
|
// an IME's Enter commits a candidate, it is never a shortcut
|
|
if (event.isComposing || event.keyCode === 229) return null;
|
|
const typing = this.isTypingTarget(event);
|
|
for (const binding of this.keymap()) {
|
|
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
|
|
continue;
|
|
}
|
|
for (const combo of binding.combos) {
|
|
if (typing && !firesInTextField(combo, IS_MAC)) continue;
|
|
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
|
|
* never runs it a second time. A held key repeats only movement-like
|
|
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
|
|
*/
|
|
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
|
|
const id = this.resolve(event, scope);
|
|
if (!id) return null;
|
|
event.preventDefault();
|
|
return event.repeat && !isRepeatable(id) ? null : id;
|
|
}
|
|
|
|
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
|
|
matches(id: ShortcutId, event: KeyEventLike): boolean {
|
|
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
|
|
}
|
|
|
|
binding(id: ShortcutId): ResolvedBinding | undefined {
|
|
return this.byId().get(id);
|
|
}
|
|
|
|
format(combo: Combo): string {
|
|
return formatCombo(combo, IS_MAC, this.names());
|
|
}
|
|
|
|
/** The hint menus show: the command's first key, or '' when unbound. */
|
|
label(id: ShortcutId): string {
|
|
const first = this.byId().get(id)?.combos[0];
|
|
return first ? this.format(first) : '';
|
|
}
|
|
|
|
labels(id: ShortcutId): string[] {
|
|
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
|
|
}
|
|
|
|
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
|
|
tip(text: string, id: ShortcutId): string {
|
|
const keys = this.labels(id);
|
|
return keys.length ? `${text} (${keys.join(' / ')})` : text;
|
|
}
|
|
|
|
/** The same for a FIXED key — one no remap moves, such as Enter in the find
|
|
* box — so it reads in the platform's spelling too (⇧↩ / Shift+Enter). */
|
|
tipFixed(text: string, combo: string): string {
|
|
const parsed = parseCombo(combo, IS_MAC);
|
|
return parsed ? `${text} (${this.format(parsed)})` : text;
|
|
}
|
|
|
|
/**
|
|
* Store new combos for commands (Settings → Keyboard). Only rows that differ
|
|
* from their defaults are kept, so a reset — or a remap back to the
|
|
* default — leaves nothing behind.
|
|
*/
|
|
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
|
|
const next = { ...this.settings.keybindings() };
|
|
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
|
|
const defaults = this.byId().get(id)?.defaults ?? [];
|
|
const isDefault =
|
|
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
|
|
if (isDefault) delete next[id];
|
|
else next[id] = combos.map(serializeCombo);
|
|
}
|
|
this.settings.setKeybindings(next);
|
|
}
|
|
|
|
resetAll(): void {
|
|
this.settings.setKeybindings({});
|
|
}
|
|
|
|
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
|
|
* Mouse events too: Mod+click toggle-selects in the panels. */
|
|
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
|
return IS_MAC ? event.metaKey : event.ctrlKey;
|
|
}
|
|
|
|
/** True when the event comes from something the user types into. */
|
|
isTypingTarget(event: { target?: EventTarget | null }): boolean {
|
|
const target = event.target as HTMLElement | null;
|
|
return !!target?.closest?.(
|
|
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
|
|
);
|
|
}
|
|
}
|