2026-07-17 23:20:34 +02:00
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
|
|
2026-10-18 20:00:31 +02:00
|
|
|
// Keyboard shortcuts (SPEC §9.4; the list users see is lib/shortcuts.ts).
|
|
|
|
|
// Ignores keystrokes while typing in inputs, except Escape. "g" starts a
|
|
|
|
|
// two-key sequence (g i = Inbox). With `enabled` false only Escape works.
|
2026-07-17 23:20:34 +02:00
|
|
|
export interface Shortcuts {
|
2026-10-18 20:00:31 +02:00
|
|
|
enabled?: boolean;
|
2026-07-17 23:20:34 +02:00
|
|
|
onSearch?: () => void;
|
|
|
|
|
onCompose?: () => void;
|
|
|
|
|
onReply?: () => void;
|
|
|
|
|
onReplyAll?: () => void;
|
|
|
|
|
onForward?: () => void;
|
|
|
|
|
onArchive?: () => void;
|
2026-10-18 20:00:31 +02:00
|
|
|
onTrash?: () => void;
|
|
|
|
|
onTogglePin?: () => void;
|
|
|
|
|
onSnooze?: () => void;
|
|
|
|
|
onMarkRead?: () => void;
|
|
|
|
|
onMarkUnread?: () => void;
|
|
|
|
|
onBack?: () => void;
|
2026-07-17 23:20:34 +02:00
|
|
|
onGoInbox?: () => void;
|
2026-10-18 20:00:31 +02:00
|
|
|
onGoSnoozed?: () => void;
|
|
|
|
|
onGoDrafts?: () => void;
|
|
|
|
|
onGoSent?: () => void;
|
|
|
|
|
onGoSubscriptions?: () => void;
|
|
|
|
|
onHelp?: () => void;
|
2026-07-17 23:20:34 +02:00
|
|
|
onEscape?: () => void;
|
|
|
|
|
onNext?: () => void;
|
|
|
|
|
onPrev?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTyping(el: EventTarget | null): boolean {
|
|
|
|
|
if (!(el instanceof HTMLElement)) return false;
|
|
|
|
|
const tag = el.tagName;
|
2026-10-18 20:00:31 +02:00
|
|
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
2026-07-17 23:20:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
|
|
|
|
const ref = useRef(shortcuts);
|
|
|
|
|
ref.current = shortcuts;
|
|
|
|
|
const pendingG = useRef(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e: KeyboardEvent) => {
|
|
|
|
|
const s = ref.current;
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
s.onEscape?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-18 20:00:31 +02:00
|
|
|
if (s.enabled === false || isTyping(e.target)) return;
|
2026-07-17 23:20:34 +02:00
|
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
|
|
|
|
2026-10-18 20:00:31 +02:00
|
|
|
const run = (fn: (() => void) | undefined) => {
|
|
|
|
|
if (!fn) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
fn();
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
if (pendingG.current) {
|
|
|
|
|
pendingG.current = false;
|
2026-10-18 20:00:31 +02:00
|
|
|
const go: Record<string, (() => void) | undefined> = {
|
|
|
|
|
i: s.onGoInbox,
|
|
|
|
|
z: s.onGoSnoozed,
|
|
|
|
|
d: s.onGoDrafts,
|
|
|
|
|
t: s.onGoSent,
|
|
|
|
|
b: s.onGoSubscriptions,
|
|
|
|
|
};
|
|
|
|
|
if (e.key in go) {
|
|
|
|
|
run(go[e.key]);
|
2026-07-17 23:20:34 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 20:00:31 +02:00
|
|
|
// Shift+letter arrives as the capital letter.
|
|
|
|
|
const actions: Record<string, (() => void) | undefined> = {
|
|
|
|
|
"/": s.onSearch,
|
|
|
|
|
"?": s.onHelp,
|
|
|
|
|
c: s.onCompose,
|
|
|
|
|
r: s.onReply,
|
|
|
|
|
a: s.onReplyAll,
|
|
|
|
|
f: s.onForward,
|
|
|
|
|
e: s.onArchive,
|
|
|
|
|
"#": s.onTrash,
|
|
|
|
|
s: s.onTogglePin,
|
|
|
|
|
b: s.onSnooze,
|
|
|
|
|
I: s.onMarkRead,
|
|
|
|
|
U: s.onMarkUnread,
|
|
|
|
|
u: s.onBack,
|
|
|
|
|
j: s.onNext,
|
|
|
|
|
k: s.onPrev,
|
|
|
|
|
};
|
|
|
|
|
if (e.key === "g") {
|
|
|
|
|
pendingG.current = true;
|
|
|
|
|
window.setTimeout(() => (pendingG.current = false), 1000);
|
|
|
|
|
return;
|
2026-07-17 23:20:34 +02:00
|
|
|
}
|
2026-10-18 20:00:31 +02:00
|
|
|
if (e.key in actions) run(actions[e.key]);
|
2026-07-17 23:20:34 +02:00
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", handler);
|
|
|
|
|
return () => window.removeEventListener("keydown", handler);
|
|
|
|
|
}, []);
|
|
|
|
|
}
|