Files
webmail-client/frontend/src/lib/useKeyboardShortcuts.ts
T

102 lines
2.8 KiB
TypeScript
Raw Normal View History

2026-07-17 23:20:34 +02:00
import { useEffect, useRef } from "react";
// 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 {
enabled?: boolean;
2026-07-17 23:20:34 +02:00
onSearch?: () => void;
onCompose?: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onArchive?: () => void;
onTrash?: () => void;
onTogglePin?: () => void;
onSnooze?: () => void;
onMarkRead?: () => void;
onMarkUnread?: () => void;
onBack?: () => void;
2026-07-17 23:20:34 +02:00
onGoInbox?: () => void;
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;
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;
}
if (s.enabled === false || isTyping(e.target)) return;
2026-07-17 23:20:34 +02:00
if (e.metaKey || e.ctrlKey || e.altKey) return;
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;
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;
}
}
// 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
}
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);
}, []);
}