Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / components / hooks / useHotkeys.ts
blobd84fc0234967ea067ba9f606c79c75082fd1ccee
1 import type { DependencyList, RefObject } from 'react';
2 import { useRef } from 'react';
4 import { isMac } from '@proton/shared/lib/helpers/browser';
5 import isDeepEqual from '@proton/shared/lib/helpers/isDeepEqual';
6 import type { KeyboardKeyType } from '@proton/shared/lib/interfaces';
7 import { KeyboardKey } from '@proton/shared/lib/interfaces';
8 import isTruthy from '@proton/utils/isTruthy';
10 import { useEventListener } from './useHandler';
13  * A Hotkey being an array means we have a "modifier" combination
14  * of keys (e.g. "Cmd + K" would translate to ['Meta', 'K'])
15  * */
16 type Hotkey = KeyboardKeyType | KeyboardKeyType[];
19  * A Sequence is a suite of Hotkeys
20  * (e.g. press "G" then "I" to navigate to the Inbox folder)
21  * */
22 type Sequence = Hotkey[];
24 type HotKeyCallback = (e: KeyboardEvent) => void;
27  * The longest allowed sequence matches the "Konami code" length
28  * if ever someone wants to do implement it somewhere ¯\_(ツ)_/¯
29  * */
30 // @todo try to find a way to have an infinity of hotkeys if wanted
31 export type HotkeyTuple =
32     | [Hotkey, HotKeyCallback]
33     | [Hotkey, Hotkey, HotKeyCallback]
34     | [Hotkey, Hotkey, Hotkey, HotKeyCallback]
35     | [Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback]
36     | [Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback]
37     | [Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback]
38     | [Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback]
39     | [Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback]
40     | [Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback]
41     | [Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback]
42     | [Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, Hotkey, HotKeyCallback];
44 type KeyEventType = 'keyup' | 'keydown' | 'keypress';
46 type HotKeysOptions = {
47     keyEventType?: KeyEventType;
48     sequenceResetTime?: number;
49     dependencies?: DependencyList;
52 const MODIFIER_KEYS = {
53     META: KeyboardKey.Meta.toLowerCase(),
54     SHIFT: KeyboardKey.Shift.toLowerCase(),
55     CONTROL: KeyboardKey.Control.toLowerCase(),
56     ALT: KeyboardKey.Alt.toLowerCase(),
59 const isMacOS = isMac();
61 const normalizeHotkeySequence = (a: string | string[]) =>
62     Array.isArray(a) ? a.sort().map((k) => k.toLowerCase()) : [a.toLowerCase()];
65  * Here we normalize Meta / Ctrl for Mac / PC & Linux users
66  * E.g. ['Meta', 'A'] will translate `Cmd + A` for Mac users and `Ctrl + A` for PC/Linux users
67  * */
68 const normalizeMetaControl = (initalTuple: HotkeyTuple) => {
69     const sequence = initalTuple.slice(0, -1) as Sequence;
70     const callback = initalTuple[initalTuple.length - 1];
72     const keys = sequence.reduce<Sequence>((acc, hotkey) => {
73         if (Array.isArray(hotkey)) {
74             acc.push(hotkey.map((k) => (!isMacOS && k === KeyboardKey.Meta ? KeyboardKey.Control : k)));
75         } else {
76             acc.push(!isMacOS && hotkey === KeyboardKey.Meta ? KeyboardKey.Control : hotkey);
77         }
79         return acc;
80     }, []);
82     return [...keys, callback];
85 export const useHotkeys = (
86     ref: RefObject<HTMLElement | Document | undefined>,
87     hotkeyTupleArray: HotkeyTuple[],
88     options?: HotKeysOptions
89 ) => {
90     const { keyEventType = 'keydown', sequenceResetTime = 1000, dependencies = [] } = options || {};
91     const msSinceLastEvent = useRef(0);
93     const sequence = useRef<Sequence>([]);
95     const handleKeyDown = (e: KeyboardEvent) => {
96         if (!e.key) {
97             return;
98         }
99         const key = e.key.toLowerCase() as KeyboardKey;
101         if (Date.now() - msSinceLastEvent.current > sequenceResetTime) {
102             sequence.current = [];
103         }
105         msSinceLastEvent.current = Date.now();
107         if ([MODIFIER_KEYS.ALT, MODIFIER_KEYS.SHIFT, MODIFIER_KEYS.CONTROL, MODIFIER_KEYS.META].includes(key)) {
108             return;
109         }
111         const isAlphaNumericalOrSpace = key.match(/[a-zA-Z1-9 ]/gi);
113         const modifiedKey = [
114             key,
115             e.metaKey && MODIFIER_KEYS.META,
116             e.ctrlKey && MODIFIER_KEYS.CONTROL,
117             // Some non-alphanumerical keys need Shift or Alt modifiers
118             // to be typed, thus cannot be used (e.g. "?" or "/")
119             isAlphaNumericalOrSpace && e.shiftKey && MODIFIER_KEYS.SHIFT,
120             isAlphaNumericalOrSpace && e.altKey && MODIFIER_KEYS.ALT,
121         ].filter(isTruthy) as Hotkey;
123         sequence.current.push(modifiedKey);
125         const normalizedHotkeyTupleArray = hotkeyTupleArray.map(normalizeMetaControl);
127         for (let i = 0; i < normalizedHotkeyTupleArray.length; i++) {
128             const hotKeyTuple = normalizedHotkeyTupleArray[i];
130             const hotkeySequence = (hotKeyTuple.slice(0, -1) as Sequence).map(normalizeHotkeySequence);
131             /*
132              * take the number of items from the sequence as the number of items in
133              * the hotkey sequence, starting from the end, so that even if the sequences
134              * are not identical, a match is still found should the tails be identical
135              * */
136             const tailSequence = sequence.current.slice(-hotkeySequence.length).map(normalizeHotkeySequence);
138             if (isDeepEqual(hotkeySequence, tailSequence)) {
139                 const callback = hotKeyTuple[hotKeyTuple.length - 1] as HotKeyCallback;
140                 callback(e);
141                 break;
142             }
143         }
144     };
146     useEventListener(ref, keyEventType, handleKeyDown, dependencies);