Skip to content

Commit c9ccf45

Browse files
authored
Merge pull request #341 from devtron-labs/feat/use-register-shortcut
feat: replace react-keybind with useRegisterShortcut & remove react-keybind from dependency
2 parents aa3d21f + af340f7 commit c9ccf45

File tree

13 files changed

+300
-113
lines changed

13 files changed

+300
-113
lines changed

package-lock.json

Lines changed: 2 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@devtron-labs/devtron-fe-common-lib",
3-
"version": "0.5.2",
3+
"version": "0.5.3",
44
"description": "Supporting common component library",
55
"type": "module",
66
"main": "dist/index.js",
@@ -83,7 +83,6 @@
8383
"react-dom": "^17.0.2",
8484
"react-draggable": "^4.4.5",
8585
"react-ga4": "^1.4.1",
86-
"react-keybind": "^0.9.4",
8786
"react-mde": "^11.5.0",
8887
"react-router": "^5.3.0",
8988
"react-router-dom": "^5.3.0",

src/Common/Hooks/UseRegisterShortcut/UseRegisterShortcut.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
*/
1616

1717
import { useContext } from 'react'
18-
import { context } from './UseRegisterShortcutContext'
18+
import { UseRegisterShortcutContext } from './UseRegisterShortcutContext'
1919

20-
const useRegisterShortcut = () => useContext(context)
20+
const useRegisterShortcut = () => useContext(UseRegisterShortcutContext)
2121

2222
export default useRegisterShortcut

src/Common/Hooks/UseRegisterShortcut/UseRegisterShortcutContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@
1717
import { createContext } from 'react'
1818
import { UseRegisterShortcutContextType } from './types'
1919

20-
export const context = createContext<UseRegisterShortcutContextType>(null)
20+
export const UseRegisterShortcutContext = createContext<UseRegisterShortcutContextType>(null)

src/Common/Hooks/UseRegisterShortcut/UseRegisterShortcutProvider.tsx

Lines changed: 160 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,173 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useMemo, useState } from 'react'
18-
import { context } from './UseRegisterShortcutContext'
19-
import { UseRegisterShortcutProviderType } from './types'
17+
import { useCallback, useEffect, useMemo, useRef } from 'react'
18+
import { deepEquals } from '@rjsf/utils'
19+
import { UseRegisterShortcutContext } from './UseRegisterShortcutContext'
20+
import { UseRegisterShortcutProviderType, ShortcutType, UseRegisterShortcutContextType } from './types'
21+
import { preprocessKeys, verifyCallbackStack } from './utils'
2022

21-
const UseRegisterShortcutProvider = ({ children }: UseRegisterShortcutProviderType) => {
22-
const [registerShortcut, setRegisterShortcut] = useState(true)
23+
const IGNORE_TAGS_FALLBACK = ['input', 'textarea', 'select']
24+
const DEFAULT_TIMEOUT = 300
2325

24-
const providerValue = useMemo(
26+
const UseRegisterShortcutProvider = ({
27+
ignoreTags,
28+
preventDefault = false,
29+
shortcutTimeout,
30+
children,
31+
}: UseRegisterShortcutProviderType) => {
32+
const disableShortcutsRef = useRef<boolean>(false)
33+
const shortcutsRef = useRef<Record<string, ShortcutType>>({})
34+
const keysDownRef = useRef<Set<Uppercase<string>>>(new Set())
35+
const keyDownTimeoutRef = useRef<ReturnType<typeof setTimeout>>(-1)
36+
const ignoredTags = ignoreTags ?? IGNORE_TAGS_FALLBACK
37+
38+
const registerShortcut: UseRegisterShortcutContextType['registerShortcut'] = useCallback(
39+
({ keys, callback, description = '' }) => {
40+
const { keys: processedKeys, id } = preprocessKeys(keys)
41+
if (typeof callback !== 'function') {
42+
throw new Error('callback provided is not a function')
43+
}
44+
45+
const match =
46+
shortcutsRef.current[id] && deepEquals(shortcutsRef.current[id].keys, keys)
47+
? shortcutsRef.current[id]
48+
: null
49+
50+
if (match) {
51+
verifyCallbackStack(match.callbackStack)
52+
match.callbackStack.push(callback)
53+
return
54+
}
55+
56+
shortcutsRef.current[id] = { keys: processedKeys, callbackStack: [callback], description }
57+
},
58+
[],
59+
)
60+
61+
const unregisterShortcut: UseRegisterShortcutContextType['unregisterShortcut'] = useCallback((keys) => {
62+
const { id } = preprocessKeys(keys)
63+
64+
if (!shortcutsRef.current[id]) {
65+
return
66+
}
67+
68+
const { callbackStack } = shortcutsRef.current[id]
69+
verifyCallbackStack(callbackStack)
70+
callbackStack.pop()
71+
72+
if (!callbackStack.length) {
73+
// NOTE: delete the shortcut only if all registered callbacks are unregistered
74+
// if 2 shortcuts are registered with the same keys then there needs to be 2 unregister calls
75+
delete shortcutsRef.current[id]
76+
}
77+
}, [])
78+
79+
const setDisableShortcuts: UseRegisterShortcutContextType['setDisableShortcuts'] = useCallback((shouldDisable) => {
80+
disableShortcutsRef.current = shouldDisable
81+
}, [])
82+
83+
const triggerShortcut: UseRegisterShortcutContextType['triggerShortcut'] = useCallback((keys) => {
84+
const { id } = preprocessKeys(keys)
85+
86+
if (!shortcutsRef.current[id]) {
87+
return
88+
}
89+
90+
const { callbackStack } = shortcutsRef.current[id]
91+
verifyCallbackStack(callbackStack)
92+
93+
// NOTE: call the last callback in the callback stack
94+
callbackStack[callbackStack.length - 1]()
95+
}, [])
96+
97+
const handleKeyupEvent = useCallback(() => {
98+
if (!keysDownRef.current.size) {
99+
return
100+
}
101+
102+
const { id } = preprocessKeys(Array.from(keysDownRef.current.values()) as ShortcutType['keys'])
103+
104+
if (shortcutsRef.current[id]) {
105+
const { callbackStack } = shortcutsRef.current[id]
106+
verifyCallbackStack(callbackStack)
107+
callbackStack[callbackStack.length - 1]()
108+
}
109+
110+
keysDownRef.current.clear()
111+
112+
if (keyDownTimeoutRef.current > -1) {
113+
clearTimeout(keyDownTimeoutRef.current)
114+
keyDownTimeoutRef.current = -1
115+
}
116+
}, [])
117+
118+
const handleKeydownEvent = useCallback((event: KeyboardEvent) => {
119+
if (preventDefault) {
120+
event.preventDefault()
121+
}
122+
123+
if (
124+
ignoredTags.map((tag) => tag.toUpperCase()).indexOf((event.target as HTMLElement).tagName.toUpperCase()) >
125+
-1 ||
126+
disableShortcutsRef.current
127+
) {
128+
return
129+
}
130+
131+
keysDownRef.current.add(event.key.toUpperCase() as Uppercase<string>)
132+
133+
if (event.ctrlKey) {
134+
keysDownRef.current.add('CONTROL')
135+
}
136+
if (event.metaKey) {
137+
keysDownRef.current.add('META')
138+
}
139+
if (event.altKey) {
140+
keysDownRef.current.add('ALT')
141+
}
142+
if (event.shiftKey) {
143+
keysDownRef.current.add('SHIFT')
144+
}
145+
146+
if (keyDownTimeoutRef.current === -1) {
147+
keyDownTimeoutRef.current = setTimeout(() => {
148+
handleKeyupEvent()
149+
}, shortcutTimeout ?? DEFAULT_TIMEOUT)
150+
}
151+
}, [])
152+
153+
const handleBlur = useCallback(() => {
154+
keysDownRef.current.clear()
155+
}, [])
156+
157+
useEffect(() => {
158+
window.addEventListener('keydown', handleKeydownEvent)
159+
window.addEventListener('keyup', handleKeyupEvent)
160+
window.addEventListener('blur', handleBlur)
161+
162+
return () => {
163+
window.removeEventListener('keydown', handleKeydownEvent)
164+
window.removeEventListener('keyup', handleKeyupEvent)
165+
window.removeEventListener('blur', handleBlur)
166+
167+
if (keyDownTimeoutRef.current > -1) {
168+
clearTimeout(keyDownTimeoutRef.current)
169+
}
170+
}
171+
}, [handleKeyupEvent, handleKeydownEvent, handleBlur])
172+
173+
const providerValue: UseRegisterShortcutContextType = useMemo(
25174
() => ({
26175
registerShortcut,
27-
setRegisterShortcut: (allowShortcut: boolean) => setRegisterShortcut(allowShortcut),
176+
unregisterShortcut,
177+
setDisableShortcuts,
178+
triggerShortcut,
28179
}),
29-
[registerShortcut],
180+
[registerShortcut, unregisterShortcut, setDisableShortcuts, triggerShortcut],
30181
)
31182

32-
return <context.Provider value={providerValue}>{children}</context.Provider>
183+
return <UseRegisterShortcutContext.Provider value={providerValue}>{children}</UseRegisterShortcutContext.Provider>
33184
}
34185

35186
export default UseRegisterShortcutProvider

src/Common/Hooks/UseRegisterShortcut/types.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,66 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { IS_PLATFORM_MAC_OS } from '@Common/Constants'
18+
19+
export const KEYBOARD_KEYS_MAP = {
20+
Control: 'Ctrl',
21+
Shift: '⇧',
22+
Meta: IS_PLATFORM_MAC_OS ? '⌘' : 'Win',
23+
Alt: IS_PLATFORM_MAC_OS ? '⌥' : 'Alt',
24+
F: 'F',
25+
E: 'E',
26+
R: 'R',
27+
K: 'K',
28+
} as const
29+
30+
export type SupportedKeyboardKeysType = keyof typeof KEYBOARD_KEYS_MAP
31+
32+
export interface ShortcutType {
33+
keys: SupportedKeyboardKeysType[]
34+
callbackStack: Array<() => void>
35+
description?: string
36+
}
37+
38+
interface RegisterShortcutType extends Pick<ShortcutType, 'keys' | 'description'> {
39+
callback: ShortcutType['callbackStack'][number]
40+
}
41+
1742
export interface UseRegisterShortcutContextType {
18-
registerShortcut: boolean
19-
setRegisterShortcut: (allowShortcut: boolean) => void
43+
/**
44+
* This method registers a shortcut with its corresponding callback
45+
*
46+
* If keys is undefined or null this method will throw an error
47+
*/
48+
registerShortcut: (props: RegisterShortcutType) => void
49+
/**
50+
* This method unregisters the provided shortcut if found
51+
*
52+
* If keys is undefined or null this method will throw an error
53+
*/
54+
unregisterShortcut: (keys: ShortcutType['keys']) => void
55+
/**
56+
* Globally disable all shortcuts with this function
57+
*/
58+
setDisableShortcuts: (shouldDisable: boolean) => void
59+
/**
60+
* Programmatically trigger a shortcut if already registered
61+
*/
62+
triggerShortcut: (keys: ShortcutType['keys']) => void
2063
}
2164

2265
export interface UseRegisterShortcutProviderType {
23-
children: React.ReactElement
66+
children: React.ReactNode
67+
/**
68+
* Defines how long after holding the keys down do we trigger the callback in milliseconds
69+
*/
70+
shortcutTimeout?: number
71+
/**
72+
* Defines which html tags to ignore as source of an event
73+
*/
74+
ignoreTags?: string[]
75+
/**
76+
* If true, call preventDefault on the event
77+
*/
78+
preventDefault?: boolean
2479
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ShortcutType } from './types'
2+
3+
export const preprocessKeys = (keys: ShortcutType['keys']) => {
4+
if (!keys) {
5+
throw new Error('keys undefined')
6+
}
7+
8+
// NOTE: converting key to a string for the case for bad inputs
9+
const processedKeys = keys.map((key) => `${key}`.toUpperCase()).sort() as ShortcutType['keys']
10+
11+
return {
12+
keys: processedKeys,
13+
id: processedKeys.join(),
14+
}
15+
}
16+
17+
export const verifyCallbackStack = (stack: ShortcutType['callbackStack']) => {
18+
if (!stack || !Array.isArray(stack) || !stack.every((callback) => typeof callback === 'function')) {
19+
throw new Error('callback stack is undefined')
20+
}
21+
}

src/Common/Tooltip/ShortcutKeyComboTooltipContent.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { KEYBOARD_KEYS_MAP, TooltipProps } from './types'
1+
import { KEYBOARD_KEYS_MAP } from '@Common/Hooks/UseRegisterShortcut/types'
2+
import { TooltipProps } from './types'
23

34
const ShortcutKeyComboTooltipContent = ({ text, combo }: TooltipProps['shortcutKeyCombo']) => (
45
<div className="flexbox dc__gap-8 px-8 py-4 flex-wrap">

src/Common/Tooltip/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export { default as Tooltip } from './Tooltip'
2-
export type { SupportedKeyboardKeysType } from './types'
32
export { TOOLTIP_CONTENTS } from './constants'

src/Common/Tooltip/types.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
1-
import { IS_PLATFORM_MAC_OS } from '@Common/Constants'
1+
import { SupportedKeyboardKeysType } from '@Common/Hooks/UseRegisterShortcut/types'
22
import { TippyProps } from '@tippyjs/react'
33

4-
export const KEYBOARD_KEYS_MAP = {
5-
Control: IS_PLATFORM_MAC_OS ? '⌘' : 'Ctrl',
6-
Shift: '⇧',
7-
F: 'F',
8-
E: 'E',
9-
} as const
10-
11-
export type SupportedKeyboardKeysType = keyof typeof KEYBOARD_KEYS_MAP
12-
134
type BaseTooltipProps =
145
| {
156
/**

0 commit comments

Comments
 (0)