Refactor KeyboardShortcut component for improved key handling and logging

- Updated the `parseShortcut` function to utilize a modifier aliases object for better readability and maintainability.
- Introduced `getPressedKey` function to streamline key detection logic.
- Replaced the previous shortcut key set management with a more efficient use of `useMemo` and `useState` for tracking pressed keys.
- Added optional `log` prop to enable logging of key events for debugging purposes.
- Enhanced the overall structure and performance of the KeyboardShortcut component.
This commit is contained in:
Tom Butcher 2026-08-01 14:00:40 +01:00
parent 0e0accc115
commit 9d3de7bc01

View File

@ -1,62 +1,76 @@
import { useEffect, useRef, cloneElement, useCallback } from 'react' import { useEffect, useRef, cloneElement, useMemo, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Popover, Typography } from 'antd' import { Popover, Typography } from 'antd'
// Utility to parse shortcut string like 'cmd+shift+p' or 'ctrl+s' const MODIFIER_ALIASES = {
cmd: 'meta',
meta: 'meta',
ctrl: 'control',
control: 'control',
alt: 'alt',
option: 'alt',
shift: 'shift'
}
function parseShortcut(shortcut) { function parseShortcut(shortcut) {
const parts = shortcut.toLowerCase().split('+') return shortcut
return { .toLowerCase()
meta: parts.includes('cmd') || parts.includes('meta') || false, .split('+')
ctrl: parts.includes('ctrl') || parts.includes('control') || false, .map((part) => MODIFIER_ALIASES[part] ?? part)
alt: parts.includes('alt') || parts.includes('option') || false,
shift: parts.includes('shift') || false,
key: parts.find(
(p) =>
!['cmd', 'meta', 'ctrl', 'control', 'alt', 'option', 'shift'].includes(
p
)
)[0]
}
} }
const { Text } = Typography function getPressedKey(event) {
const KeyboardShortcut = ({ shortcut, children, hint, onTrigger }) => {
const childRef = useRef()
const shortcutObj = parseShortcut(shortcut)
// Helper to get the set of keys required for the shortcut
const getShortcutKeySet = useCallback((shortcutObj) => {
const keys = []
if (shortcutObj.meta) keys.push('Meta')
if (shortcutObj.ctrl) keys.push('Control')
if (shortcutObj.alt) keys.push('Alt')
if (shortcutObj.shift) keys.push('Shift')
// shortcutObj.code is like 'keyp', so extract the last char
if (shortcutObj.key) {
keys.push('Key' + shortcutObj.key.toUpperCase())
}
return new Set(keys)
}, [])
const shortcutKeySet = getShortcutKeySet(shortcutObj)
useEffect(() => {
const pressedKeys = new Set()
const handleKeyDown = (event) => {
if ( if (
event.key === 'Meta' || event.key === 'Meta' ||
event.key === 'Control' || event.key === 'Control' ||
event.key === 'Alt' || event.key === 'Alt' ||
event.key === 'Shift' event.key === 'Shift'
) { ) {
pressedKeys.add(event.key) return event.key.toLowerCase()
} else if (event.code && event.code.startsWith('Key')) {
pressedKeys.add(event.code)
} }
if (event.code) {
const code = event.code.toLowerCase()
if (code.startsWith('key')) {
return code.slice(3)
}
return code
}
return null
}
const { Text } = Typography
const KeyboardShortcut = ({
shortcut,
children,
hint,
onTrigger,
log = false
}) => {
const childRef = useRef()
const pressedKeysRef = useRef(new Set())
const shortcutKeys = useMemo(() => parseShortcut(shortcut), [shortcut])
const [pressedKeys, setPressedKeys] = useState([])
useEffect(() => {
const syncPressedKeys = () => {
setPressedKeys([...pressedKeysRef.current])
}
const handleKeyDown = (event) => {
const key = getPressedKey(event)
if (key) {
pressedKeysRef.current.add(key)
syncPressedKeys()
}
if (log) {
console.log('Key Down:', key, 'Pressed Keys:', [...pressedKeysRef.current])
}
if ( if (
shortcutKeySet.size && shortcutKeys.length &&
[...shortcutKeySet].every((k) => pressedKeys.has(k)) shortcutKeys.every((k) => pressedKeysRef.current.has(k))
) { ) {
if (typeof onTrigger === 'function') { if (typeof onTrigger === 'function') {
onTrigger(event) onTrigger(event)
@ -64,27 +78,24 @@ const KeyboardShortcut = ({ shortcut, children, hint, onTrigger }) => {
event.preventDefault() event.preventDefault()
} }
} }
const handleKeyUp = (event) => { const handleKeyUp = (event) => {
if ( const key = getPressedKey(event)
event.key === 'Meta' || if (key) {
event.key === 'Control' || pressedKeysRef.current.delete(key)
event.key === 'Alt' || syncPressedKeys()
event.key === 'Shift'
) {
pressedKeys.delete(event.key)
} else if (event.code && event.code.startsWith('Key')) {
pressedKeys.delete(event.key.toUpperCase())
} }
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp) window.addEventListener('keyup', handleKeyUp)
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp) window.removeEventListener('keyup', handleKeyUp)
} }
}, [shortcut, shortcutObj, onTrigger, shortcutKeySet]) }, [shortcutKeys, onTrigger, log])
// Clone the child to attach a ref
const element = cloneElement(children, { ref: childRef }) const element = cloneElement(children, { ref: childRef })
if (hint) { if (hint) {
@ -108,7 +119,8 @@ KeyboardShortcut.propTypes = {
shortcut: PropTypes.string.isRequired, // e.g. 'cmd+shift+p' shortcut: PropTypes.string.isRequired, // e.g. 'cmd+shift+p'
onTrigger: PropTypes.func.isRequired, onTrigger: PropTypes.func.isRequired,
children: PropTypes.element.isRequired, children: PropTypes.element.isRequired,
hint: PropTypes.string // Optional, e.g. ' P' hint: PropTypes.string, // Optional, e.g. ' P'
log: PropTypes.bool // Optional, e.g. true
} }
export default KeyboardShortcut export default KeyboardShortcut