91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
// ---------------------------------------------------------------------------
|
||
// Keyboard shortcuts – OS-aware key resolution
|
||
//
|
||
// Design:
|
||
// - SHORTCUT_DEFINITIONS: abstract schema using modifier tokens
|
||
// - resolveKeys(): pure function, safe to call anywhere (including tests)
|
||
// - useOs(): client-only React hook that detects Mac vs other after hydration
|
||
//
|
||
// Modifier tokens:
|
||
// "mod" → ⌘ (Mac) | Ctrl (other)
|
||
// "shift" → ⇧ (Mac) | Shift (other)
|
||
// "alt" → ⌥ (Mac) | Alt (other)
|
||
// "enter" → ↵ (Mac) | Enter (other)
|
||
// "esc" → Esc (both)
|
||
// anything else passes through as-is (plain letter keys like "A")
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export type Os = "mac" | "other" | "unknown";
|
||
export type Modifier = string; // "mod" | "shift" | "alt" | "enter" | "esc" | plain key
|
||
|
||
export interface ShortcutDefinition {
|
||
modifiers: readonly Modifier[];
|
||
label: string;
|
||
}
|
||
|
||
// ─── Abstract shortcut definitions ───────────────────────────────────────────
|
||
|
||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||
{ modifiers: ["mod", "enter"], label: "Generate event" },
|
||
{ modifiers: ["mod", "shift", "A"], label: "Attach image" },
|
||
{ modifiers: ["mod", "V"], label: "Paste image" },
|
||
{ modifiers: ["esc"], label: "Clear prompt" },
|
||
];
|
||
|
||
// ─── Key resolution ───────────────────────────────────────────────────────────
|
||
|
||
const MAC_MAP: Record<string, string> = {
|
||
mod: "⌘",
|
||
shift: "⇧",
|
||
alt: "⌥",
|
||
enter: "↵",
|
||
esc: "Esc",
|
||
};
|
||
|
||
const OTHER_MAP: Record<string, string> = {
|
||
mod: "Ctrl",
|
||
shift: "Shift",
|
||
alt: "Alt",
|
||
enter: "Enter",
|
||
esc: "Esc",
|
||
};
|
||
|
||
/**
|
||
* Pure function — maps abstract modifier tokens to display glyphs.
|
||
* "unknown" falls back to Mac (most common dev/user base).
|
||
*/
|
||
export function resolveKeys(modifiers: readonly Modifier[], os: Os): string[] {
|
||
const map = os === "other" ? OTHER_MAP : MAC_MAP;
|
||
return modifiers.map((m) => map[m] ?? m);
|
||
}
|
||
|
||
// ─── OS detection hook (client-only) ─────────────────────────────────────────
|
||
|
||
/**
|
||
* Detects the user's OS after hydration.
|
||
* Returns "unknown" on the server or before the effect runs.
|
||
*
|
||
* Detection order (most → least reliable):
|
||
* 1. navigator.userAgentData.platform (modern browsers, Chromium)
|
||
* 2. navigator.platform (legacy, still widely supported)
|
||
* 3. navigator.userAgent string match (last resort)
|
||
*/
|
||
export function detectOs(): Os {
|
||
if (typeof navigator === "undefined") return "unknown";
|
||
|
||
// Modern API — Chromium 90+
|
||
const uaData = (navigator as Navigator & { userAgentData?: { platform: string } })
|
||
.userAgentData;
|
||
if (uaData?.platform) {
|
||
return uaData.platform.toLowerCase().includes("mac") ? "mac" : "other";
|
||
}
|
||
|
||
// Legacy API — still reliable on Safari and Firefox
|
||
if (navigator.platform) {
|
||
return navigator.platform.toLowerCase().startsWith("mac") ? "mac" : "other";
|
||
}
|
||
|
||
// Last resort — UA string
|
||
return /mac/i.test(navigator.userAgent) ? "mac" : "other";
|
||
}
|