@@ -14,11 +14,6 @@ import { createLogger } from '@/lib/logs/console/logger'
1414
1515const logger = createLogger ( 'GlobalCommands' )
1616
17- /**
18- * Detects if the current platform is macOS.
19- *
20- * @returns True if running on macOS, false otherwise
21- */
2217function isMacPlatform ( ) : boolean {
2318 if ( typeof window === 'undefined' ) return false
2419 return (
@@ -27,18 +22,6 @@ function isMacPlatform(): boolean {
2722 )
2823}
2924
30- /**
31- * Represents a parsed keyboard shortcut.
32- *
33- * We support the following modifiers:
34- * - Mod: maps to Meta on macOS, Ctrl on other platforms
35- * - Ctrl, Meta, Shift, Alt
36- *
37- * Examples:
38- * - "Mod+A"
39- * - "Mod+Shift+T"
40- * - "Meta+K"
41- */
4225export interface ParsedShortcut {
4326 key : string
4427 mod ?: boolean
@@ -48,24 +31,10 @@ export interface ParsedShortcut {
4831 alt ?: boolean
4932}
5033
51- /**
52- * Declarative command registration.
53- */
5434export interface GlobalCommand {
55- /** Unique id for the command. If omitted, one is generated. */
5635 id ?: string
57- /** Shortcut string in the form "Mod+Shift+T", "Mod+A", "Meta+K", etc. */
5836 shortcut : string
59- /**
60- * Whether to allow the command to run inside editable elements like inputs,
61- * textareas or contenteditable. Defaults to true to ensure browser defaults
62- * are overridden when desired.
63- */
6437 allowInEditable ?: boolean
65- /**
66- * Handler invoked when the shortcut is matched. Use this to trigger actions
67- * like navigation or dispatching application events.
68- */
6938 handler : ( event : KeyboardEvent ) => void
7039}
7140
@@ -80,16 +49,13 @@ interface GlobalCommandsContextValue {
8049
8150const GlobalCommandsContext = createContext < GlobalCommandsContextValue | null > ( null )
8251
83- /**
84- * Parses a human-readable shortcut into a structured representation.
85- */
8652function parseShortcut ( shortcut : string ) : ParsedShortcut {
8753 const parts = shortcut . split ( '+' ) . map ( ( p ) => p . trim ( ) )
8854 const modifiers = new Set ( parts . slice ( 0 , - 1 ) . map ( ( p ) => p . toLowerCase ( ) ) )
8955 const last = parts [ parts . length - 1 ]
9056
9157 return {
92- key : last . length === 1 ? last . toLowerCase ( ) : last , // keep non-letter keys verbatim
58+ key : last . length === 1 ? last . toLowerCase ( ) : last ,
9359 mod : modifiers . has ( 'mod' ) ,
9460 ctrl : modifiers . has ( 'ctrl' ) ,
9561 meta : modifiers . has ( 'meta' ) || modifiers . has ( 'cmd' ) || modifiers . has ( 'command' ) ,
@@ -98,16 +64,10 @@ function parseShortcut(shortcut: string): ParsedShortcut {
9864 }
9965}
10066
101- /**
102- * Checks if a KeyboardEvent matches a parsed shortcut, honoring platform-specific
103- * interpretation of "Mod" (Meta on macOS, Ctrl elsewhere).
104- */
10567function matchesShortcut ( e : KeyboardEvent , parsed : ParsedShortcut ) : boolean {
10668 const isMac = isMacPlatform ( )
10769 const expectedCtrl = parsed . ctrl || ( parsed . mod ? ! isMac : false )
10870 const expectedMeta = parsed . meta || ( parsed . mod ? isMac : false )
109-
110- // Normalize key for comparison: for letters compare lowercase
11171 const eventKey = e . key . length === 1 ? e . key . toLowerCase ( ) : e . key
11272
11373 return (
@@ -119,10 +79,6 @@ function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
11979 )
12080}
12181
122- /**
123- * Provider that captures global keyboard shortcuts and routes them to
124- * registered commands. Commands can be registered from any descendant component.
125- */
12682export function GlobalCommandsProvider ( { children } : { children : ReactNode } ) {
12783 const registryRef = useRef < Map < string , RegistryCommand > > ( new Map ( ) )
12884 const isMac = useMemo ( ( ) => isMacPlatform ( ) , [ ] )
@@ -140,13 +96,11 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
14096 allowInEditable : cmd . allowInEditable ?? true ,
14197 } )
14298 createdIds . push ( id )
143- logger . info ( 'Registered global command' , { id, shortcut : cmd . shortcut } )
14499 }
145100
146101 return ( ) => {
147102 for ( const id of createdIds ) {
148103 registryRef . current . delete ( id )
149- logger . info ( 'Unregistered global command' , { id } )
150104 }
151105 }
152106 } , [ ] )
@@ -155,8 +109,6 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
155109 const onKeyDown = ( e : KeyboardEvent ) => {
156110 if ( e . isComposing ) return
157111
158- // Evaluate matches in registration order (latest registration wins naturally
159- // due to replacement on same id). Break on first match.
160112 for ( const [ , cmd ] of registryRef . current ) {
161113 if ( ! cmd . allowInEditable ) {
162114 const ae = document . activeElement
@@ -168,16 +120,8 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
168120 }
169121
170122 if ( matchesShortcut ( e , cmd . parsed ) ) {
171- // Always override default browser behavior for matched commands.
172123 e . preventDefault ( )
173124 e . stopPropagation ( )
174- logger . info ( 'Executing global command' , {
175- id : cmd . id ,
176- shortcut : cmd . shortcut ,
177- key : e . key ,
178- isMac,
179- path : typeof window !== 'undefined' ? window . location . pathname : undefined ,
180- } )
181125 try {
182126 cmd . handler ( e )
183127 } catch ( err ) {
@@ -197,22 +141,28 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
197141 return < GlobalCommandsContext . Provider value = { value } > { children } </ GlobalCommandsContext . Provider >
198142}
199143
200- /**
201- * Registers a set of global commands for the lifetime of the component.
202- *
203- * Returns nothing; cleanup is automatic on unmount.
204- */
205144export function useRegisterGlobalCommands ( commands : GlobalCommand [ ] | ( ( ) => GlobalCommand [ ] ) ) {
206145 const ctx = useContext ( GlobalCommandsContext )
207146 if ( ! ctx ) {
208147 throw new Error ( 'useRegisterGlobalCommands must be used within GlobalCommandsProvider' )
209148 }
210149
150+ const commandsRef = useRef < GlobalCommand [ ] > ( [ ] )
151+ const list = typeof commands === 'function' ? commands ( ) : commands
152+ commandsRef . current = list
153+
211154 useEffect ( ( ) => {
212- const list = typeof commands === 'function' ? commands ( ) : commands
213- const unregister = ctx . register ( list )
155+ const wrappedCommands = commandsRef . current . map ( ( cmd ) => ( {
156+ ...cmd ,
157+ handler : ( event : KeyboardEvent ) => {
158+ const currentCmd = commandsRef . current . find ( ( c ) => c . id === cmd . id )
159+ if ( currentCmd ) {
160+ currentCmd . handler ( event )
161+ }
162+ } ,
163+ } ) )
164+ const unregister = ctx . register ( wrappedCommands )
214165 return unregister
215- // We intentionally want to register once for the given commands
216166 // eslint-disable-next-line react-hooks/exhaustive-deps
217167 } , [ ] )
218168}
0 commit comments