useShortcut.tsx123 lines · main
| 1 | import { useHotkeySequence } from '@tanstack/react-hotkeys' |
| 2 | import { Fragment, useCallback, useMemo } from 'react' |
| 3 | import { KeyboardShortcut } from 'ui' |
| 4 | import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' |
| 5 | |
| 6 | import { hotkeyToKeys } from './formatShortcut' |
| 7 | import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry' |
| 8 | import type { ShortcutHotkeyMeta, ShortcutOptions } from './types' |
| 9 | import { useIsShortcutEnabled } from './useIsShortcutEnabled' |
| 10 | import { orderShortcutCommands } from './utils' |
| 11 | import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' |
| 12 | import useLatest from '@/hooks/misc/useLatest' |
| 13 | |
| 14 | /** |
| 15 | * Subscribe to a registered keyboard shortcut. |
| 16 | * |
| 17 | * Looks up the shortcut's `sequence` and `label` from `SHORTCUT_DEFINITIONS`, |
| 18 | * wires up a global hotkey listener via `@tanstack/react-hotkeys`, and |
| 19 | * (optionally) registers the shortcut as an entry in the Cmd+P command menu |
| 20 | * under the "Shortcuts" section for as long as the hook is mounted. |
| 21 | * |
| 22 | * Option resolution priority (highest first): |
| 23 | * 1. `options` passed to this hook |
| 24 | * 2. `def.options` from the registry entry |
| 25 | * 3. Hard-coded fallbacks (`enabled: true`, `timeout: undefined`, `registerInCommandMenu: false`) |
| 26 | * |
| 27 | * `enabled` is ANDed with the user's global enable/disable preference — if the |
| 28 | * user has disabled the shortcut in Preferences, it won't fire even if the |
| 29 | * caller or registry say `enabled: true`. |
| 30 | * |
| 31 | * @param id The registered shortcut to bind to. See `SHORTCUT_IDS`. |
| 32 | * @param callback Runs when the sequence matches. Always calls the latest |
| 33 | * reference — no stale closure issues. |
| 34 | * @param options Per-mount overrides. See `ShortcutOptions`. |
| 35 | * |
| 36 | * @example |
| 37 | * useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, handleCopy) |
| 38 | * |
| 39 | * @example |
| 40 | * // Surface in Cmd+P while this component is mounted: |
| 41 | * useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery, { |
| 42 | * registerInCommandMenu: true, |
| 43 | * }) |
| 44 | * |
| 45 | * @example |
| 46 | * // Gate on local state — disables hotkey AND hides Cmd+P entry when false: |
| 47 | * useShortcut(SHORTCUT_IDS.SAVE, handleSave, { |
| 48 | * enabled: hasUnsavedChanges, |
| 49 | * registerInCommandMenu: true, |
| 50 | * }) |
| 51 | */ |
| 52 | export function useShortcut(id: ShortcutId, callback: () => void, options?: ShortcutOptions) { |
| 53 | const def = SHORTCUT_DEFINITIONS[id] |
| 54 | |
| 55 | // Handle override for the shortcut |
| 56 | const globallyEnabled = useIsShortcutEnabled(id) |
| 57 | const callerEnabled = options?.enabled ?? def.options?.enabled ?? true |
| 58 | const enabled = globallyEnabled && callerEnabled |
| 59 | const timeout = options?.timeout ?? def.options?.timeout ?? undefined |
| 60 | const ignoreInputs = options?.ignoreInputs ?? def.options?.ignoreInputs |
| 61 | const registerInCommandMenu = |
| 62 | options?.registerInCommandMenu ?? def.options?.registerInCommandMenu ?? false |
| 63 | const label = options?.label ?? def.label |
| 64 | const conflictBehavior = options?.conflictBehavior ?? def.options?.conflictBehavior |
| 65 | |
| 66 | // Stable identity so we don't churn the registration store on every render. |
| 67 | // setOptions in @tanstack/hotkeys notifies subscribers each call, which |
| 68 | // would cascade to every component using useHotkeyRegistrations(). |
| 69 | const meta = useMemo<ShortcutHotkeyMeta>( |
| 70 | () => ({ id, name: label, referenceGroup: def.referenceGroup }), |
| 71 | [def.referenceGroup, id, label] |
| 72 | ) |
| 73 | |
| 74 | // Only include `ignoreInputs` when set. The library resolves it to a concrete |
| 75 | // boolean at register time (false for Meta/Ctrl/Escape, true otherwise), but |
| 76 | // its setOptions does an object spread on every re-render — passing |
| 77 | // `ignoreInputs: undefined` would overwrite the resolved value and re-enable |
| 78 | // the input-focus guard for shortcuts that should always fire. |
| 79 | useHotkeySequence(def.sequence, callback, { |
| 80 | enabled, |
| 81 | timeout, |
| 82 | meta, |
| 83 | ...(ignoreInputs !== undefined && { ignoreInputs }), |
| 84 | ...(conflictBehavior !== undefined && { conflictBehavior }), |
| 85 | }) |
| 86 | |
| 87 | // Handle overrides for command menu |
| 88 | const enabledInCommandMenu = enabled && registerInCommandMenu |
| 89 | const depsInCommandMenu = [enabled, label] |
| 90 | const callbackRef = useLatest(callback) |
| 91 | const setCommandMenuOpen = useSetCommandMenuOpen() |
| 92 | const stableAction = useCallback(() => { |
| 93 | setCommandMenuOpen(false) |
| 94 | callbackRef.current() |
| 95 | }, [callbackRef, setCommandMenuOpen]) |
| 96 | |
| 97 | useRegisterCommands( |
| 98 | COMMAND_MENU_SECTIONS.SHORTCUTS, |
| 99 | [ |
| 100 | { |
| 101 | id, |
| 102 | name: label, |
| 103 | action: stableAction, |
| 104 | badge: () => ( |
| 105 | <div className="flex items-center gap-1"> |
| 106 | {def.sequence.map((step, i) => ( |
| 107 | <Fragment key={i}> |
| 108 | {i > 0 && <span className="text-foreground-lighter text-[11px]">then</span>} |
| 109 | <KeyboardShortcut keys={hotkeyToKeys(step)} /> |
| 110 | </Fragment> |
| 111 | ))} |
| 112 | </div> |
| 113 | ), |
| 114 | }, |
| 115 | ], |
| 116 | { |
| 117 | enabled: enabledInCommandMenu, |
| 118 | deps: depsInCommandMenu, |
| 119 | orderCommands: orderShortcutCommands, |
| 120 | sectionMeta: { priority: 1 }, |
| 121 | } |
| 122 | ) |
| 123 | } |