import type { ReactNode } from "react"; import React from "react"; import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, } from "@headlessui/react"; import { t } from "@lingui/core/macro"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { HiXMark } from "react-icons/hi2"; import { env } from "~/env"; import { useEventListener } from "~/hooks/useEventListener"; const ModifierKey = { CONTROL: "CONTROL", META: "META", ALT: "ALT", SHIFT: "SHIFT", } as const; type ModifierKey = (typeof ModifierKey)[keyof typeof ModifierKey]; const ModifierKeyInfo: Record< ModifierKey, { macSymbol: string; winName: string; linuxName: string; } > = { CONTROL: { macSymbol: "⌃", winName: "Ctrl", linuxName: "Ctrl" }, META: { macSymbol: "⌘", winName: "Win", linuxName: "Super" }, ALT: { macSymbol: "⌥", winName: "Alt", linuxName: "Alt" }, SHIFT: { macSymbol: "⇧", winName: "Shift", linuxName: "Shift" }, }; const ShortcutGroup = { GENERAL: "GENERAL", NAVIGATION: "NAVIGATION", ACTIONS: "ACTIONS", } as const; type ShortcutGroup = (typeof ShortcutGroup)[keyof typeof ShortcutGroup]; const getShortcutGroupInfo = (): Record => ({ GENERAL: { label: t`General` }, NAVIGATION: { label: t`Navigation` }, ACTIONS: { label: t`Actions` }, }); interface KeyStroke { key: string; modifiers?: ModifierKey[]; } interface Press { type: "PRESS"; stroke: KeyStroke; } interface Sequence { type: "SEQUENCE"; strokes: KeyStroke[]; } export type KeyboardShortcut = { action: () => void; description: string; group: ShortcutGroup; } & (Press | Sequence); interface ShortcutTreeStepNode { type: "STEP"; children: ShortcutTreeLevel; } interface ShortcutTreeActionNode { type: "ACTION"; shortcut: KeyboardShortcut; } type ShortcutTreeNode = ShortcutTreeStepNode | ShortcutTreeActionNode; type ShortcutTreeLevel = Record; const SEQUENCE_TIMEOUT_MS = 1000; const ShortcutConflictCode = { ACTION_ON_SEQUENCE: "ACTION_ON_SEQUENCE", DUPLICATE_ACTION: "DUPLICATE_ACTION", SEQUENCE_ON_ACTION: "SEQUENCE_ON_ACTION", } as const; type ShortcutConflictCode = keyof typeof ShortcutConflictCode; interface ShortcutConflictErrorOptions { code: ShortcutConflictCode; shortcut: KeyboardShortcut; conflictPath: string; existingShortcut?: KeyboardShortcut; } class ShortcutConflictError extends Error { public readonly code: ShortcutConflictCode; public readonly shortcut: KeyboardShortcut; public readonly conflictPath: string; public readonly existingShortcut?: KeyboardShortcut; constructor(options: ShortcutConflictErrorOptions) { super(ShortcutConflictError.formatMessage(options)); this.name = "ShortcutConflictError"; this.code = options.code; this.shortcut = options.shortcut; this.conflictPath = options.conflictPath; this.existingShortcut = options.existingShortcut; } private static stringifyShortcut(shortcut: KeyboardShortcut): string { if (shortcut.type === "SEQUENCE") { return shortcut.strokes.map(serializeKeyStroke).join(" → "); } return serializeKeyStroke(shortcut.stroke); } private static formatMessage(options: ShortcutConflictErrorOptions): string { const formatted = ShortcutConflictError.stringifyShortcut(options.shortcut); switch (options.code) { case ShortcutConflictCode.ACTION_ON_SEQUENCE: return `Cannot register "${formatted}": desired action conflicts with existing sequence at "${options.conflictPath}"`; case ShortcutConflictCode.DUPLICATE_ACTION: return `Cannot register "${formatted}": action already registered`; case ShortcutConflictCode.SEQUENCE_ON_ACTION: return `Cannot register "${formatted}": desired sequence conflicts with existing action at "${options.conflictPath}"`; } } } interface KeyboardShortcutContextType { registerShortcut: (shortcut: KeyboardShortcut) => () => void; openLegend: () => void; openLegendKeys: ReactNode; } const KeyboardShortcutContext = createContext< KeyboardShortcutContextType | undefined >(undefined); export function KeyboardShortcutProvider({ children, }: { children: ReactNode; }) { const treeRootRef = useRef({}); const currentNodeRef = useRef(treeRootRef.current); const shortcutsRef = useRef>(new Map()); const sequenceTimeoutRef = useRef(null); const [isLegendOpen, setIsLegendOpen] = useState(false); const openLegendShortcut: KeyboardShortcut = useMemo( () => ({ type: "PRESS", stroke: { key: "/", modifiers: ["META"], }, action: () => setIsLegendOpen(true), description: t`Open keyboard shortcuts`, group: ShortcutGroup.GENERAL, }), [setIsLegendOpen], ); const handleKeyDown = useCallback((event: KeyboardEvent) => { if (isTypingInInput(event)) { return; } if (sequenceTimeoutRef.current) { clearTimeout(sequenceTimeoutRef.current); sequenceTimeoutRef.current = null; } const serializedKey = serializeEvent(event); const node = currentNodeRef.current[serializedKey]; if (!node) { currentNodeRef.current = treeRootRef.current; return; } switch (node.type) { case "STEP": event.preventDefault(); currentNodeRef.current = node.children; sequenceTimeoutRef.current = setTimeout(() => { currentNodeRef.current = treeRootRef.current; }, SEQUENCE_TIMEOUT_MS); return; case "ACTION": event.preventDefault(); node.shortcut.action(); currentNodeRef.current = treeRootRef.current; return; default: return; } }, []); const registerShortcut = useCallback( (shortcut: KeyboardShortcut): (() => void) => { const strokes = shortcut.type === "SEQUENCE" ? shortcut.strokes : [shortcut.stroke]; const path = strokes.map(serializeKeyStroke); const pathKey = path.join(" → "); path.reduce((currentLevel, key, i) => { const isLast = i === path.length - 1; const existingNode = currentLevel[key]; if (env.NODE_ENV === "development" && existingNode) { const conflictPath = path.slice(0, i + 1).join(" → "); validateNoConflict(existingNode, isLast, shortcut, conflictPath); } if (isLast) { currentLevel[key] = { type: "ACTION", shortcut, }; return currentLevel; } if (existingNode?.type === "STEP") { return existingNode.children; } const newNode: ShortcutTreeStepNode = { type: "STEP", children: {}, }; currentLevel[key] = newNode; return newNode.children; }, treeRootRef.current); shortcutsRef.current.set(pathKey, shortcut); return () => { removePathAndPrune(treeRootRef.current, path); shortcutsRef.current.delete(pathKey); }; }, [], ); useEventListener("keydown", handleKeyDown); // Register built-in shortcut to open legend useEffect(() => { const cleanup = registerShortcut(openLegendShortcut); return cleanup; }, [registerShortcut, openLegendShortcut]); // Cleanup timeout on unmount useEffect(() => { return () => { if (sequenceTimeoutRef.current) { clearTimeout(sequenceTimeoutRef.current); } }; }, []); const shortcutsArray = Array.from(shortcutsRef.current.values()); const groupedShortcuts = shortcutsArray.reduce< Partial> >((acc, shortcut) => { const group = shortcut.group; acc[group] ??= []; acc[group].push(shortcut); return acc; }, {}); const openLegend = useCallback(() => { setIsLegendOpen(true); }, []); const openLegendKeys = useMemo( () => , [openLegendShortcut], ); return ( {children} {/* Shortcut Legend */} setIsLegendOpen(false)} >
{t`Keyboard Shortcuts`}
{shortcutsArray.length === 0 ? (

{t`No keyboard shortcuts registered.`}

) : (
{Object.values(ShortcutGroup).map((group, idx) => { const shortcuts = groupedShortcuts[group]; if (!shortcuts?.length) return null; const groupInfo = getShortcutGroupInfo(); return (

{groupInfo[group].label}

{shortcuts.map((shortcut) => ( ))}
); })}
)}
); } /** * Hook to register a keyboard shortcut. Cleanup is handled automatically. * Returns both formatted keys and pre-built tooltip content. * * IMPORTANT: The shortcut object reference must be stable. If it changes * on every render, the shortcut will be continuously registered and unregistered. * * @example * ```tsx * const { keys, tooltipContent } = useKeyboardShortcut({ * type: "PRESS", * stroke: { key: "k", modifiers: ["META"] }, * action: () => openCommandPalette(), * description: "Open command palette", * group: "GENERAL" * }); * * // Use tooltip for Tooltip component * * * * * // Or use keys directly for custom formatting * Press {keys} to search * ``` */ export function useKeyboardShortcuts(): KeyboardShortcutContextType { const context = useContext(KeyboardShortcutContext); if (!context) { throw new Error( "useKeyboardShortcuts must be used within KeyboardShortcutProvider", ); } return context; } export function useKeyboardShortcut(shortcut: KeyboardShortcut): { keys: ReactNode; tooltipContent: ReactNode; } { const context = useContext(KeyboardShortcutContext); if (!context) { throw new Error( "useKeyboardShortcut must be used within KeyboardShortcutProvider", ); } const { registerShortcut } = context; useEffect(() => { const cleanup = registerShortcut(shortcut); return cleanup; }, [shortcut, registerShortcut]); const keys = ; const tooltipContent = (
{shortcut.description} {keys}
); return { keys, tooltipContent }; } function ShortcutListItem({ shortcut }: { shortcut: KeyboardShortcut }) { return (
{shortcut.description}
); } function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) { const kbdClassName = "inline-flex h-5 w-5 items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-center text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950"; const stringifyModifier = (modifier: ModifierKey): string => { const isMac = typeof navigator !== "undefined" && navigator.userAgent.includes("Mac"); const isLinux = typeof navigator !== "undefined" && navigator.userAgent.includes("Linux"); const info = ModifierKeyInfo[modifier]; if (isMac) return info.macSymbol; if (isLinux) return info.linuxName; return info.winName; }; const formatStroke = (stroke: KeyStroke): ReactNode[] => { const parts: ReactNode[] = []; const modifierStrings = stroke.modifiers ? stroke.modifiers.map(stringifyModifier) : []; modifierStrings.forEach((mod, index) => { parts.push( {mod} , ); }); parts.push( {stroke.key.toUpperCase()} , ); return parts; }; if (shortcut.type === "SEQUENCE") { const parts: ReactNode[] = []; shortcut.strokes.forEach((stroke, strokeIndex) => { const strokeParts = formatStroke(stroke); // Add stroke index to keys to ensure uniqueness across multiple strokes const keyedParts = strokeParts.map((part, partIndex) => { if (React.isValidElement(part)) { return React.cloneElement(part, { key: `stroke-${strokeIndex}-${part.key || partIndex}`, }); } return part; }); parts.push(...keyedParts); }); return {parts}; } return ( {formatStroke(shortcut.stroke)} ); } /** Checks for conflicts given existing nodes and current path * Throws an error if conflict is found * */ function validateNoConflict( existingNode: ShortcutTreeNode, isLastKey: boolean, shortcut: KeyboardShortcut, conflictPath: string, ): void { if (isLastKey) { if (existingNode.type === "STEP") { throw new ShortcutConflictError({ code: ShortcutConflictCode.ACTION_ON_SEQUENCE, shortcut, conflictPath, }); } else { throw new ShortcutConflictError({ code: ShortcutConflictCode.DUPLICATE_ACTION, shortcut, conflictPath, existingShortcut: existingNode.shortcut, }); } } else if (existingNode.type === "ACTION") { throw new ShortcutConflictError({ code: ShortcutConflictCode.SEQUENCE_ON_ACTION, shortcut, conflictPath, existingShortcut: existingNode.shortcut, }); } } /** * Checks if the user is currently typing in an input field */ function isTypingInInput(event: KeyboardEvent): boolean { if (!event.target) return false; const target = event.target as HTMLElement; const tagName = target.tagName.toLowerCase(); const isInput = tagName === "input" || tagName === "textarea"; const isContentEditable = target.isContentEditable; return isInput || isContentEditable; } /** * Serializes a KeyStroke to a consistent string format * Returns lowercase string like "ctrl+shift+k" with alphabetically sorted modifiers */ function serializeKeyStroke(stroke: KeyStroke): string { const key = stroke.key.toLowerCase(); if (!stroke.modifiers || stroke.modifiers.length === 0) return key; const sorted = [...stroke.modifiers].sort(); return `${sorted.join("+")}+${key}`; } /** * Converts a keyboard event to a KeyStroke */ function eventToKeyStroke(event: KeyboardEvent): KeyStroke { const modifiers: ModifierKey[] = []; if (event.altKey) modifiers.push(ModifierKey.ALT); if (event.ctrlKey) modifiers.push(ModifierKey.CONTROL); if (event.metaKey) modifiers.push(ModifierKey.META); // Only include shift if the key is a letter (shift wasn't consumed to produce the character) if (event.shiftKey && /^[a-zA-Z]$/.test(event.key)) modifiers.push(ModifierKey.SHIFT); return { key: event.key, modifiers }; } /** * Serializes a keyboard event to a consistent string format */ function serializeEvent(event: KeyboardEvent): string { return serializeKeyStroke(eventToKeyStroke(event)); } /** * Removes a path from the tree and prunes empty branches */ function removePathAndPrune(tree: ShortcutTreeLevel, path: string[]): void { const [first, ...rest] = path; if (!first) return; if (rest.length === 0) { delete tree[first]; return; } const node = tree[first]; if (!node || node.type !== "STEP") return; removePathAndPrune(node.children, rest); if (Object.keys(node.children).length === 0) { delete tree[first]; } }