useShortcut.tsx 4.9 KB

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