▍ humdrum codex / soft
3.4 KB raw
  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;
}