| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123 |
- import { useHotkeySequence } from '@tanstack/react-hotkeys'
- import { Fragment, useCallback, useMemo } from 'react'
- import { KeyboardShortcut } from 'ui'
- import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
- import { hotkeyToKeys } from './formatShortcut'
- import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry'
- import type { ShortcutHotkeyMeta, ShortcutOptions } from './types'
- import { useIsShortcutEnabled } from './useIsShortcutEnabled'
- import { orderShortcutCommands } from './utils'
- import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
- import useLatest from '@/hooks/misc/useLatest'
- /**
- * Subscribe to a registered keyboard shortcut.
- *
- * Looks up the shortcut's `sequence` and `label` from `SHORTCUT_DEFINITIONS`,
- * wires up a global hotkey listener via `@tanstack/react-hotkeys`, and
- * (optionally) registers the shortcut as an entry in the Cmd+P command menu
- * under the "Shortcuts" section for as long as the hook is mounted.
- *
- * Option resolution priority (highest first):
- * 1. `options` passed to this hook
- * 2. `def.options` from the registry entry
- * 3. Hard-coded fallbacks (`enabled: true`, `timeout: undefined`, `registerInCommandMenu: false`)
- *
- * `enabled` is ANDed with the user's global enable/disable preference — if the
- * user has disabled the shortcut in Preferences, it won't fire even if the
- * caller or registry say `enabled: true`.
- *
- * @param id The registered shortcut to bind to. See `SHORTCUT_IDS`.
- * @param callback Runs when the sequence matches. Always calls the latest
- * reference — no stale closure issues.
- * @param options Per-mount overrides. See `ShortcutOptions`.
- *
- * @example
- * useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, handleCopy)
- *
- * @example
- * // Surface in Cmd+P while this component is mounted:
- * useShortcut(SHORTCUT_IDS.SQL_EDITOR_RUN, runQuery, {
- * registerInCommandMenu: true,
- * })
- *
- * @example
- * // Gate on local state — disables hotkey AND hides Cmd+P entry when false:
- * useShortcut(SHORTCUT_IDS.SAVE, handleSave, {
- * enabled: hasUnsavedChanges,
- * registerInCommandMenu: true,
- * })
- */
- export function useShortcut(id: ShortcutId, callback: () => void, options?: ShortcutOptions) {
- const def = SHORTCUT_DEFINITIONS[id]
- // Handle override for the shortcut
- const globallyEnabled = useIsShortcutEnabled(id)
- const callerEnabled = options?.enabled ?? def.options?.enabled ?? true
- const enabled = globallyEnabled && callerEnabled
- const timeout = options?.timeout ?? def.options?.timeout ?? undefined
- const ignoreInputs = options?.ignoreInputs ?? def.options?.ignoreInputs
- const registerInCommandMenu =
- options?.registerInCommandMenu ?? def.options?.registerInCommandMenu ?? false
- const label = options?.label ?? def.label
- const conflictBehavior = options?.conflictBehavior ?? def.options?.conflictBehavior
- // Stable identity so we don't churn the registration store on every render.
- // setOptions in @tanstack/hotkeys notifies subscribers each call, which
- // would cascade to every component using useHotkeyRegistrations().
- const meta = useMemo<ShortcutHotkeyMeta>(
- () => ({ id, name: label, referenceGroup: def.referenceGroup }),
- [def.referenceGroup, id, label]
- )
- // Only include `ignoreInputs` when set. The library resolves it to a concrete
- // boolean at register time (false for Meta/Ctrl/Escape, true otherwise), but
- // its setOptions does an object spread on every re-render — passing
- // `ignoreInputs: undefined` would overwrite the resolved value and re-enable
- // the input-focus guard for shortcuts that should always fire.
- useHotkeySequence(def.sequence, callback, {
- enabled,
- timeout,
- meta,
- ...(ignoreInputs !== undefined && { ignoreInputs }),
- ...(conflictBehavior !== undefined && { conflictBehavior }),
- })
- // Handle overrides for command menu
- const enabledInCommandMenu = enabled && registerInCommandMenu
- const depsInCommandMenu = [enabled, label]
- const callbackRef = useLatest(callback)
- const setCommandMenuOpen = useSetCommandMenuOpen()
- const stableAction = useCallback(() => {
- setCommandMenuOpen(false)
- callbackRef.current()
- }, [callbackRef, setCommandMenuOpen])
- useRegisterCommands(
- COMMAND_MENU_SECTIONS.SHORTCUTS,
- [
- {
- id,
- name: label,
- action: stableAction,
- badge: () => (
- <div className="flex items-center gap-1">
- {def.sequence.map((step, i) => (
- <Fragment key={i}>
- {i > 0 && <span className="text-foreground-lighter text-[11px]">then</span>}
- <KeyboardShortcut keys={hotkeyToKeys(step)} />
- </Fragment>
- ))}
- </div>
- ),
- },
- ],
- {
- enabled: enabledInCommandMenu,
- deps: depsInCommandMenu,
- orderCommands: orderShortcutCommands,
- sectionMeta: { priority: 1 },
- }
- )
- }
|