useShortcut.tsx123 lines · main
1import { useHotkeySequence } from '@tanstack/react-hotkeys'
2import { Fragment, useCallback, useMemo } from 'react'
3import { KeyboardShortcut } from 'ui'
4import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
5
6import { hotkeyToKeys } from './formatShortcut'
7import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry'
8import type { ShortcutHotkeyMeta, ShortcutOptions } from './types'
9import { useIsShortcutEnabled } from './useIsShortcutEnabled'
10import { orderShortcutCommands } from './utils'
11import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
12import 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 */
52export 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}