1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
// lib/shortcuts.ts — keyboard shortcut registry + key parsing.
//
// Combos use a normalized form: "mod+k", "mod+enter", "mod+1", "?".
// `mod` = ⌘ on macOS, Ctrl elsewhere. Auto-detected.
export type ActionGroup = "Navigation" | "Actions" | "View" | "Help";
export interface Action {
id: string;
label: string;
combo?: string; // "mod+k" etc. Optional — palette-only actions skip this.
group: ActionGroup;
hint?: string; // Sub-text shown in palette
run: () => void | Promise<void>;
}
const isMac = typeof navigator !== "undefined" && /mac/i.test(navigator.platform);
export const MOD_KEY = isMac ? "⌘" : "Ctrl";
// Normalize a KeyboardEvent into our combo string.
// Note: for symbol keys like "?" "!" "@", browser already encodes the shift,
// so we omit the shift modifier to avoid double-counting.
export function eventToCombo(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.metaKey || e.ctrlKey) parts.push("mod");
if (e.altKey) parts.push("alt");
const key = e.key === " " ? "space" : e.key.toLowerCase();
const isAlphanumeric = key.length === 1 && /[a-z0-9]/.test(key);
// Shift only encoded explicitly for alphanumerics; symbols already imply it.
if (e.shiftKey && isAlphanumeric) parts.push("shift");
if (!["meta", "control", "alt", "shift"].includes(key)) parts.push(key);
return parts.join("+");
}
// Pretty-print for help UI: "mod+k" → "⌘ K"
export function prettyCombo(combo: string): string {
return combo
.split("+")
.map((p) => {
if (p === "mod") return MOD_KEY;
if (p === "shift") return "⇧";
if (p === "alt") return isMac ? "⌥" : "Alt";
if (p === "enter") return "↵";
if (p === "escape" || p === "esc") return "Esc";
if (p === "space") return "Space";
if (p === "?") return "?";
return p.length === 1 ? p.toUpperCase() : p;
})
.join(" ");
}
// Singleton registry. Components register on mount, unregister on unmount.
const registry = new Map<string, Action>();
const subscribers = new Set<() => void>();
// Cached snapshot for useSyncExternalStore. Must return same reference between
// changes — rebuilding the array each call triggers React's infinite-loop guard.
let cachedList: Action[] = [];
function rebuildSnapshot() {
cachedList = Array.from(registry.values());
}
export const shortcuts = {
register(action: Action): () => void {
registry.set(action.id, action);
rebuildSnapshot();
notify();
return () => {
registry.delete(action.id);
rebuildSnapshot();
notify();
};
},
list(): Action[] {
return cachedList;
},
byCombo(combo: string): Action | undefined {
for (const a of registry.values()) if (a.combo === combo) return a;
return undefined;
},
subscribe(fn: () => void): () => void {
subscribers.add(fn);
return () => { subscribers.delete(fn); };
},
};
function notify() {
for (const fn of subscribers) fn();
}
// Should this event be ignored because user is typing in an input?
// Allow `mod+*` combos through — they're explicit shortcuts the user meant.
export function shouldIgnore(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null;
if (!target) return false;
if (e.metaKey || e.ctrlKey) return false;
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (target.isContentEditable) return true;
return false;
}
|