Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
12 kB · 320 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321import { useCallback, useEffect, useMemo, useRef, useState } from 'react';import { useActionRegistry, useHotkeyStore } from '../actions/ActionContext';import { formatHotkeyForDisplay, eventToHotkey, normalizeHotkey, hotkeyToString,} from '../actions/hotkey-utils';import { getRendererPlatform } from '../lib/platform';import { PlusIcon, XIcon, SearchIcon } from '../icons';import type { RegisteredAction, Hotkey } from '../actions/types';
/** Parse editingKey into action ID and slot info. */function parseEditingKey(key: string | null): { actionId: string | null; slot: number | 'new' | null;} { if (!key) return { actionId: null, slot: null }; const colonIdx = key.lastIndexOf(':'); if (colonIdx < 0) return { actionId: null, slot: null }; const actionId = key.slice(0, colonIdx); const slotRaw = key.slice(colonIdx + 1); const slot = slotRaw === 'new' ? 'new' : parseInt(slotRaw, 10); return { actionId, slot };}
export function HotkeysPane() { const registry = useActionRegistry(); const hotkeyStore = useHotkeyStore(); const isMac = getRendererPlatform() === 'darwin';
const [editingKey, setEditingKey] = useState<string | null>(null); const [captured, setCaptured] = useState<Hotkey | null>(null); const [filter, setFilter] = useState('');
// Refs so the persistent keydown listener always sees latest state const editingKeyRef = useRef(editingKey); editingKeyRef.current = editingKey; const capturedRef = useRef(captured); capturedRef.current = captured;
// Force re-render when overrides change so badges and conflicts update const [version, setVersion] = useState(0); useEffect(() => { return hotkeyStore.onChange(() => setVersion((v) => v + 1)); }, [hotkeyStore]);
// Reset captured combo whenever the user switches to a different editing target useEffect(() => { setCaptured(null); }, [editingKey]);
const allActions = useMemo(() => { const actions = registry.getAll(); // Sort by source then name for stable ordering actions.sort((a, b) => { if (a.source !== b.source) return a.source.localeCompare(b.source); return a.name.localeCompare(b.name); }); return actions; }, [registry]);
const filteredActions = useMemo(() => { if (!filter.trim()) return allActions; const q = filter.toLowerCase(); return allActions.filter( (a) => a.name.toLowerCase().includes(q) || (a.description ?? '').toLowerCase().includes(q), ); }, [allActions, filter]);
/** Whether the currently captured hotkey conflicts with another action's binding. */ const isCapturedConflicting = useMemo(() => { if (!captured || !editingKey) return false; const { actionId: editingActionId } = parseEditingKey(editingKey); const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); for (const action of allActions) { if (action.id === editingActionId) continue; // same-action duplicates are handled separately for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { if (hotkeyToString(normalizeHotkey(hk, isMac)) === capturedStr) { return true; } } } return false; }, [captured, editingKey, allActions, hotkeyStore, isMac, version]);
/** Live conflicts including the currently captured (but not yet confirmed) hotkey. */ const liveConflicts = useMemo(() => { const base = hotkeyStore.getConflicts(registry, isMac); if (!captured || !editingKey) return base;
const { actionId: editingActionId } = parseEditingKey(editingKey); const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); const result = new Map(base);
for (const action of allActions) { if (action.id === editingActionId) continue; for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { const hkStr = hotkeyToString(normalizeHotkey(hk, isMac)); if (hkStr === capturedStr) { const existing = result.get(capturedStr) ?? []; if (!existing.includes(action.id)) { result.set(capturedStr, [...existing, action.id]); } } } } return result; }, [hotkeyStore, registry, isMac, captured, editingKey, allActions, version]);
const finishEdit = useCallback( (actionId: string, nextHotkeys: Hotkey[]) => { hotkeyStore.setOverride(actionId, nextHotkeys); setEditingKey(null); setCaptured(null); }, [hotkeyStore], );
// One persistent document-level keydown listener. // Uses refs to avoid effect re-runs on every state change. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const currentEditing = editingKeyRef.current; if (!currentEditing) return;
e.preventDefault(); e.stopPropagation();
const { actionId, slot } = parseEditingKey(currentEditing); if (!actionId || slot === null) return;
const currentCaptured = capturedRef.current; const action = registry.get(actionId); if (!action) return;
const base = hotkeyStore.getEffectiveHotkeys(action);
if (e.key === 'Escape') { setEditingKey(null); setCaptured(null); return; }
if (e.key === 'Enter') { const next = [...base]; if (currentCaptured) { if (slot === 'new') { next.push(currentCaptured); } else if (typeof slot === 'number') { next[slot] = currentCaptured; } } // Deduplicate within this action const seen = new Set<string>(); const deduped = next.filter((hk) => { const key = hotkeyToString(normalizeHotkey(hk, isMac)); if (seen.has(key)) return false; seen.add(key); return true; }); finishEdit(actionId, deduped); return; }
if (e.key === 'Delete' || e.key === 'Backspace') { const next = [...base]; if (typeof slot === 'number') { next.splice(slot, 1); } finishEdit(actionId, next); return; }
// Skip bare modifier keys if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return;
setCaptured(eventToHotkey(e)); };
document.addEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener('keydown', handleKeyDown, true); }, [registry, hotkeyStore, finishEdit]);
const removeAt = (action: RegisteredAction, index: number) => { const next = [...hotkeyStore.getEffectiveHotkeys(action)]; next.splice(index, 1); hotkeyStore.setOverride(action.id, next); };
return ( <div className="-mx-5 -my-5 flex min-h-0 flex-1 flex-col px-5 py-5"> {/* Conflict banner */} {liveConflicts.size > 0 ? ( <div className="bg-warning-bg border-warning mb-3 rounded-md border p-2.5 text-sm"> <p className="text-warning font-medium">Conflicting hotkeys</p> <ul className="text-warning mt-1 list-inside list-disc text-xs"> {Array.from(liveConflicts.entries()).map(([key, actionIds]) => ( <li key={key}> {key}: {actionIds.join(', ')} </li> ))} </ul> </div> ) : null}
{/* Filter bar */} <div className="relative mb-3"> <SearchIcon className="text-foreground/40 pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2" /> <input type="text" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter by name or description…" className="border-border bg-foreground/5 text-foreground placeholder:text-foreground/30 focus:border-accent focus:ring-accent w-full rounded-md border py-1.5 pr-3 pl-8 text-sm outline-none focus:ring-1" /> </div>
{/* Table — scrollable body */} <div className="border-border divide-border min-h-0 flex-1 divide-y overflow-y-auto rounded-md border"> {filteredActions.map((action: RegisteredAction) => { const effective = hotkeyStore.getEffectiveHotkeys(action); const { actionId: editingActionId, slot } = parseEditingKey(editingKey); const isEditingAction = editingActionId === action.id;
return ( <div key={action.id} className="flex items-start overflow-hidden px-3 py-1.5" > <div className="min-w-[40%] flex-1 overflow-hidden pr-4"> <span className="text-foreground block truncate text-xs leading-snug"> {action.name} </span> {action.description ? ( <p className="text-foreground/50 truncate text-[11px] leading-snug"> {action.description} </p> ) : null} </div> <div className="flex min-w-0 flex-1 items-center justify-end overflow-hidden"> <div className="flex min-w-0 flex-wrap items-center justify-end gap-y-1"> {effective.map((hk, index) => { const isEditing = isEditingAction && slot === index; return ( <span key={`${action.id}-${index}`} className="border-border bg-foreground/5 ml-1.5 inline-flex items-center gap-1 rounded border px-1 py-0.5" > {isEditing ? ( <span className={`shrink-0 animate-pulse text-xs ${isCapturedConflicting ? 'text-destructive' : 'text-accent'}`} > {captured ? formatHotkeyForDisplay(captured, isMac) : 'Press keys…'} </span> ) : ( <> <button type="button" onClick={() => setEditingKey(`${action.id}:${index}`) } className="text-foreground/80 hover:text-foreground shrink-0 text-xs" > {formatHotkeyForDisplay(hk, isMac)} </button> <button type="button" onClick={(e) => { e.stopPropagation(); removeAt(action, index); }} aria-label="Remove hotkey" className="text-foreground/50 hover:text-foreground/80 opacity-70 hover:opacity-100" > <XIcon className="size-3" /> </button> </> )} </span> ); })} </div> <div className="flex shrink-0 items-center"> {isEditingAction && slot === 'new' ? ( <span className={`border-border mr-1 inline-flex shrink-0 animate-pulse items-center rounded border px-1 py-0.5 text-xs ${isCapturedConflicting ? 'bg-destructive/10 text-destructive' : 'bg-accent/10 text-accent'}`} > {captured ? formatHotkeyForDisplay(captured, isMac) : 'Press keys…'} </span> ) : null} <button type="button" onClick={() => setEditingKey(`${action.id}:new`)} aria-label="Add hotkey" className="text-foreground/40 hover:text-foreground/80 hover:bg-border/30 rounded p-1" > <PlusIcon className="size-3.5" /> </button> </div> </div> </div> ); })} {filteredActions.length === 0 ? ( <div className="text-foreground/40 py-8 text-center text-sm"> No commands match your filter </div> ) : null} </div> </div> );}