import { patchModel, settingsAtom } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { fuzzyMatch } from 'fuzzbunny'; import { useAtomValue } from 'jotai'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { defaultHotkeys, formatHotkeyString, getHotkeyScope, type HotkeyAction, hotkeyActions, hotkeysAtom, useHotkeyLabel, } from '../../hooks/useHotKey'; import { capitalize } from '../../lib/capitalize'; import { showDialog } from '../../lib/dialog'; import { Button } from '../core/Button'; import { Dropdown, type DropdownItem } from '../core/Dropdown'; import { Heading } from '../core/Heading'; import { HotkeyRaw } from '../core/Hotkey'; import { Icon } from '../core/Icon'; import { IconButton } from '../core/IconButton'; import { PlainInput } from '../core/PlainInput'; import { HStack, VStack } from '../core/Stacks'; import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table'; const HOLD_KEYS = ['Shift', 'Control', 'Alt', 'Meta']; const LAYOUT_INSENSITIVE_KEYS = [ 'Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote', 'Space', ]; /** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */ function eventToHotkeyString(e: KeyboardEvent): string | null { // Don't capture modifier-only key presses if (HOLD_KEYS.includes(e.key)) { return null; } const parts: string[] = []; // Add modifiers in consistent order (Meta, Control, Alt, Shift) if (e.metaKey) { parts.push('Meta'); } if (e.ctrlKey) { parts.push('Control'); } if (e.altKey) { parts.push('Alt'); } if (e.shiftKey) { parts.push('Shift'); } // Get the main key - use the same logic as useHotKey.ts const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key; parts.push(key); return parts.join('+'); } export function SettingsHotkeys() { const settings = useAtomValue(settingsAtom); const hotkeys = useAtomValue(hotkeysAtom); const [filter, setFilter] = useState(''); const filteredActions = useMemo(() => { if (!filter.trim()) { return hotkeyActions; } return hotkeyActions.filter((action) => { const scope = getHotkeyScope(action).replace(/_/g, ' '); const label = action.replace(/[_.]/g, ' '); const searchText = `${scope} ${label}`; return fuzzyMatch(searchText, filter) != null; }); }, [filter]); if (settings == null) { return null; } return ( Keyboard Shortcuts Click the menu button to add, remove, or reset keyboard shortcuts. Scope Action Shortcut {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */} {filteredActions.map((action) => ( { const newHotkeys = { ...settings.hotkeys }; if (arraysEqual(keys, defaultHotkeys[action])) { // Remove from settings if it matches default (use default) delete newHotkeys[action]; } else { // Store the keys (including empty array to disable) newHotkeys[action] = keys; } await patchModel(settings, { hotkeys: newHotkeys }); }} onReset={async () => { const newHotkeys = { ...settings.hotkeys }; delete newHotkeys[action]; await patchModel(settings, { hotkeys: newHotkeys }); }} /> ))} ); } interface HotkeyRowProps { action: HotkeyAction; currentKeys: string[]; defaultKeys: string[]; onSave: (keys: string[]) => Promise; onReset: () => Promise; } function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) { const label = useHotkeyLabel(action); const scope = capitalize(getHotkeyScope(action).replace(/_/g, ' ')); const isCustomized = !arraysEqual(currentKeys, defaultKeys); const isDisabled = currentKeys.length === 0; const handleStartRecording = useCallback(() => { showDialog({ id: `record-hotkey-${action}`, title: label, size: 'sm', render: ({ hide }) => ( { await onSave([...currentKeys, key]); hide(); }} onCancel={hide} /> ), }); }, [action, label, currentKeys, onSave]); const handleRemove = useCallback( async (keyToRemove: string) => { const newKeys = currentKeys.filter((k) => k !== keyToRemove); await onSave(newKeys); }, [currentKeys, onSave], ); const handleClearAll = useCallback(async () => { await onSave([]); }, [onSave]); // Build dropdown items dynamically const dropdownItems: DropdownItem[] = [ { label: 'Add Keyboard Shortcut', leftSlot: , onSelect: handleStartRecording, }, ]; // Add remove options for each existing shortcut if (!isDisabled) { currentKeys.forEach((key) => { dropdownItems.push({ label: ( Remove ), leftSlot: , onSelect: () => handleRemove(key), }); }); if (currentKeys.length > 1) { dropdownItems.push( { type: 'separator', }, { label: 'Remove All Shortcuts', leftSlot: , onSelect: handleClearAll, }, ); } } if (isCustomized) { dropdownItems.push({ type: 'separator', }); dropdownItems.push({ label: 'Reset to Default', leftSlot: , onSelect: onReset, }); } return ( {scope} {label} {isDisabled ? ( Disabled ) : ( currentKeys.map((k) => ( )) )} ); } function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; const sortedA = [...a].sort(); const sortedB = [...b].sort(); return sortedA.every((v, i) => v === sortedB[i]); } interface RecordHotkeyDialogProps { label: string; onSave: (key: string) => void; onCancel: () => void; } function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) { const [recordedKey, setRecordedKey] = useState(null); const [isFocused, setIsFocused] = useState(false); useEffect(() => { if (!isFocused) return; const handleKeyDown = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { onCancel(); return; } const hotkeyString = eventToHotkeyString(e); if (hotkeyString) { setRecordedKey(hotkeyString); } }; window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => { window.removeEventListener('keydown', handleKeyDown, { capture: true }); }; }, [isFocused, onCancel]); const handleSave = useCallback(() => { if (recordedKey) { onSave(recordedKey); } }, [recordedKey, onSave]); return ( Record a key combination for {label} setIsFocused(true)} onBlur={() => setIsFocused(false)} onClick={(e) => { e.preventDefault(); e.currentTarget.focus(); }} className={classNames( 'flex items-center justify-center', 'px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full', 'border-border-subtle focus:border-border-focus', )} > {recordedKey ? ( ) : ( Press keys... )} Cancel Save ); }
Click the menu button to add, remove, or reset keyboard shortcuts.
Record a key combination for {label}