Files
metabuilder/hooks/useHotkeys.ts
johndoe6345789 5aabff44cd refactor(fakemui): flatten QML components directory structure and update documentation
Directory Restructuring:
- qml/qml-components/qml-components/* → qml/components/ (flattens nesting)
- All 104 QML files moved with git history preserved
- Eliminates redundant qml-components nesting

Documentation Updates:
- ARCHITECTURE.md: Updated qml/components references (2 locations)
- GETTING_STARTED.md: Updated qml/components path (1 location, end of file)
- README.md: Updated qml/components references (3 locations)
- CODE_REVIEW.md: Updated qml/components file paths (4 locations)
- docs/ARCHITECTURE.md: Complete refactor with qml/components paths

Verification:
-  No remaining qml-components/ references in documentation
-  All 104 QML files present in flattened structure
-  Directory structure verified (12 component categories)
-  First-class directory naming convention

Structure Post-Refactor:
qml/
├── components/
│   ├── atoms/ (16 files)
│   ├── core/ (11 files)
│   ├── data-display/ (10 files)
│   ├── feedback/ (11 files)
│   ├── form/ (19 files)
│   ├── lab/ (11 files)
│   ├── layout/ (12 files)
│   ├── navigation/ (12 files)
│   ├── surfaces/ (7 files)
│   ├── theming/ (4 files)
│   └── utils/ (13 files)
├── hybrid/
└── widgets/

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-23 19:54:21 +00:00

221 lines
5.7 KiB
TypeScript

/**
* useHotkeys Hook
* Global hotkey registration with combo key support
*
* Features:
* - Support for combo keys (e.g., 'ctrl+shift+k', 'cmd+s')
* - Scoped hotkey management per component
* - Handler registration function
* - Automatic cleanup on unmount
* - Platform-aware (Mac vs Windows/Linux)
* - Prevent default behavior
* - Multiple handlers for same hotkey
*
* @example
* // Basic hotkey
* const hotkeys = useHotkeys()
*
* hotkeys.register('ctrl+s', () => {
* console.log('Save shortcut triggered')
* })
*
* @example
* // Platform-specific hotkeys
* const hotkeys = useHotkeys()
*
* // Use cmd on Mac, ctrl on Windows
* const saveKey = navigator.platform.includes('Mac') ? 'cmd+s' : 'ctrl+s'
*
* hotkeys.register(saveKey, () => saveDocument())
* hotkeys.register('Escape', () => closeDialog())
*
* @example
* // With options
* const hotkeys = useHotkeys()
*
* const id = hotkeys.register('ctrl+shift+k', () => {
* openCommandPalette()
* }, {
* preventDefault: true,
* enabled: isComponentVisible
* })
*
* // Unregister later
* useEffect(() => {
* return () => hotkeys.unregister(id)
* }, [])
*
* @example
* // Combo key parsing
* // Supports: 'ctrl', 'cmd', 'shift', 'alt', 'meta'
* // Case-insensitive key names
* hotkeys.register('ctrl+shift+enter', handleSubmit)
* hotkeys.register('cmd+opt+k', handleSearch) // opt = alt on Mac
*/
import { useEffect, useRef, useCallback } from 'react'
export interface HotkeysOptions {
/** Prevent default behavior on hotkey press */
preventDefault?: boolean
/** Whether this hotkey is currently enabled */
enabled?: boolean
/** Debounce delay in milliseconds */
debounceMs?: number
}
export interface UseHotkeysReturn {
/** Register a hotkey combo, returns ID for later unregistration */
register: (combo: string, handler: () => void, options?: HotkeysOptions) => string
/** Unregister a hotkey by ID */
unregister: (id: string) => void
/** Unregister all hotkeys for this instance */
unregisterAll: () => void
}
interface ParsedCombo {
ctrl: boolean
cmd: boolean
shift: boolean
alt: boolean
meta: boolean
key: string
}
interface RegisteredHotkey {
combo: ParsedCombo
handler: () => void
options: HotkeysOptions
lastTriggeredAt: number
}
const parseCombo = (combo: string): ParsedCombo => {
const parts = combo.toLowerCase().split('+')
const parsed: ParsedCombo = {
ctrl: false,
cmd: false,
shift: false,
alt: false,
meta: false,
key: '',
}
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i].trim()
if (part === 'ctrl' || part === 'control') parsed.ctrl = true
if (part === 'cmd' || part === 'command') parsed.cmd = true
if (part === 'shift') parsed.shift = true
if (part === 'alt' || part === 'option' || part === 'opt') parsed.alt = true
if (part === 'meta') parsed.meta = true
}
parsed.key = parts[parts.length - 1].trim()
return parsed
}
const isMac = () => {
if (typeof window === 'undefined') return false
return /Mac|iPhone|iPad|iPod/.test(navigator.platform || '')
}
export function useHotkeys(): UseHotkeysReturn {
const hotkeysRef = useRef<Map<string, RegisteredHotkey>>(new Map())
const platformIsMacRef = useRef(isMac())
const register = useCallback(
(combo: string, handler: () => void, options: HotkeysOptions = {}): string => {
const id = `hotkey-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const parsedCombo = parseCombo(combo)
hotkeysRef.current.set(id, {
combo: parsedCombo,
handler,
options: {
preventDefault: false,
enabled: true,
debounceMs: 0,
...options,
},
lastTriggeredAt: 0,
})
return id
},
[]
)
const unregister = useCallback((id: string) => {
hotkeysRef.current.delete(id)
}, [])
const unregisterAll = useCallback(() => {
hotkeysRef.current.clear()
}, [])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
hotkeysRef.current.forEach((hotkey) => {
if (!hotkey.options.enabled) return
const { combo, handler, options, lastTriggeredAt } = hotkey
// Check key match
const isKeyMatch = event.key.toLowerCase() === combo.key.toLowerCase()
// Determine if ctrl/cmd is pressed (cross-platform)
const isMetaPressed = (() => {
if (combo.cmd && combo.ctrl) {
return platformIsMacRef.current ? event.metaKey : event.ctrlKey
}
if (combo.cmd) {
return platformIsMacRef.current ? event.metaKey : false
}
if (combo.ctrl) {
return platformIsMacRef.current ? event.metaKey : event.ctrlKey
}
return true // No meta key required
})()
// Check shift
const isShiftMatch = combo.shift ? event.shiftKey : true
// Check alt (option on Mac)
const isAltMatch = combo.alt ? event.altKey : true
// Check meta
const isMetaMatch = combo.meta ? event.metaKey : true
if (isKeyMatch && isMetaPressed && isShiftMatch && isAltMatch && isMetaMatch) {
// Check debounce
const now = Date.now()
if (
options.debounceMs &&
now - lastTriggeredAt < options.debounceMs
) {
return
}
if (options.preventDefault) {
event.preventDefault()
}
hotkey.lastTriggeredAt = now
handler()
}
})
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [])
return {
register,
unregister,
unregisterAll,
}
}