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'])
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)
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 ¯\_(ツ)_/¯
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
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)));
76 acc.push(!isMacOS && hotkey === KeyboardKey.Meta ? KeyboardKey.Control : hotkey);
82 return [...keys, callback];
85 export const useHotkeys = (
86 ref: RefObject<HTMLElement | Document | undefined>,
87 hotkeyTupleArray: HotkeyTuple[],
88 options?: HotKeysOptions
90 const { keyEventType = 'keydown', sequenceResetTime = 1000, dependencies = [] } = options || {};
91 const msSinceLastEvent = useRef(0);
93 const sequence = useRef<Sequence>([]);
95 const handleKeyDown = (e: KeyboardEvent) => {
99 const key = e.key.toLowerCase() as KeyboardKey;
101 if (Date.now() - msSinceLastEvent.current > sequenceResetTime) {
102 sequence.current = [];
105 msSinceLastEvent.current = Date.now();
107 if ([MODIFIER_KEYS.ALT, MODIFIER_KEYS.SHIFT, MODIFIER_KEYS.CONTROL, MODIFIER_KEYS.META].includes(key)) {
111 const isAlphaNumericalOrSpace = key.match(/[a-zA-Z1-9 ]/gi);
113 const modifiedKey = [
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);
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
136 const tailSequence = sequence.current.slice(-hotkeySequence.length).map(normalizeHotkeySequence);
138 if (isDeepEqual(hotkeySequence, tailSequence)) {
139 const callback = hotKeyTuple[hotKeyTuple.length - 1] as HotKeyCallback;
146 useEventListener(ref, keyEventType, handleKeyDown, dependencies);