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(() => { 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; } /** A FIXED key — one no remap moves, such as Enter in the find box — in * the platform's spelling (⇧↩ / Shift+Enter). */ fixedKey(combo: string): string { const parsed = parseCombo(combo, IS_MAC); return parsed ? this.format(parsed) : combo; } /** tip() for a fixed key. */ tipFixed(text: string, combo: string): string { return `${text} (${this.fixedKey(combo)})`; } /** * 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>): 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])', ); } }