1 | const isEqual = (a, b) => {
|
2 | const aKeys = Object.keys(a);
|
3 |
|
4 | if (aKeys.length !== Object.keys(b).length) {
|
5 | return false;
|
6 | }
|
7 |
|
8 | return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k]);
|
9 | };
|
10 |
|
11 | const isArrayEqual = (a, b) => a.length === b.length && a.every((v, i) => isEqual(v, b[i]));
|
12 |
|
13 | export const matchHotkey = (buffer, hotkey) => {
|
14 | if (buffer.length < hotkey.length) {
|
15 | return false;
|
16 | }
|
17 |
|
18 | const indexDiff = buffer.length - hotkey.length;
|
19 | for (let i = hotkey.length - 1; i >= 0; i -= 1) {
|
20 | if (!isEqual(buffer[indexDiff + i], hotkey[i])) {
|
21 | return false;
|
22 | }
|
23 | }
|
24 |
|
25 | return true;
|
26 | };
|
27 |
|
28 | const arrayToObject = (arr) => arr.reduce((obj, key) => ({ ...obj, [key]: true }), {});
|
29 |
|
30 | const allModifiers = ['ctrl', 'shift', 'alt', 'meta'];
|
31 | const indexedModifiers = arrayToObject(allModifiers);
|
32 |
|
33 | const isHotkeyValid = (hotkey) => Object.keys(hotkey).filter((k) => !indexedModifiers[k]).length === 1;
|
34 |
|
35 | const validate = (value, message) => {
|
36 | if (!value) {
|
37 | throw new Error(message);
|
38 | }
|
39 | };
|
40 |
|
41 | const validateType = (value, name, type) => {
|
42 | validate(typeof value === type, `The ${name} must be a ${type}; given ${typeof value}`);
|
43 | };
|
44 |
|
45 | export const normalizeHotkey = (hotkey) =>
|
46 | hotkey.split(/ +/g).map((part) => {
|
47 | const arr = part.split('+').filter(Boolean);
|
48 | const result = arrayToObject(arr);
|
49 |
|
50 | validate(Object.keys(result).length >= arr.length, `Hotkey combination has duplicates "${hotkey}"`);
|
51 |
|
52 | validate(isHotkeyValid(result), `Invalid hotkey combination: "${hotkey}"`);
|
53 |
|
54 | return result;
|
55 | });
|
56 |
|
57 | const validateListenerArgs = (hotkey, callback) => {
|
58 | validateType(hotkey, 'hotkey', 'string');
|
59 | validateType(callback, 'callback', 'function');
|
60 | };
|
61 |
|
62 | const createListenersFn = (listeners, fn) => (hotkey, callback) => {
|
63 | validateListenerArgs(hotkey, callback);
|
64 | fn(listeners, hotkey, callback);
|
65 | };
|
66 |
|
67 | const registerListener = (listeners, hotkey, callback) => {
|
68 | listeners.push({ hotkey: normalizeHotkey(hotkey), callback });
|
69 | };
|
70 |
|
71 | const unregisterListener = (listeners, hotkey, callback) => {
|
72 | const normalized = normalizeHotkey(hotkey);
|
73 |
|
74 | const index = listeners.findIndex((l) => l.callback === callback && isArrayEqual(normalized, l.hotkey));
|
75 |
|
76 | if (index !== -1) {
|
77 | listeners.splice(index, 1);
|
78 | }
|
79 | };
|
80 |
|
81 | const debounce = (fn, time) => {
|
82 | let timeoutId: any = null;
|
83 |
|
84 | return () => {
|
85 | clearTimeout(timeoutId);
|
86 | timeoutId = setTimeout(fn, time);
|
87 | };
|
88 | };
|
89 |
|
90 | const getKey = (key) => {
|
91 | switch (key) {
|
92 | case '+':
|
93 | return 'plus';
|
94 | case ' ':
|
95 | return 'space';
|
96 | default:
|
97 | return key;
|
98 | }
|
99 | };
|
100 |
|
101 | const createKeyDownListener = (listeners, debounceTime) => {
|
102 | let buffer: any[] = [];
|
103 |
|
104 | const clearBufferDebounced = debounce(() => {
|
105 | buffer = [];
|
106 | }, debounceTime);
|
107 |
|
108 | return (event) => {
|
109 | if (event.repeat) {
|
110 | return;
|
111 | }
|
112 |
|
113 | if (event.getModifierState(event.key)) {
|
114 | return;
|
115 | }
|
116 |
|
117 | clearBufferDebounced();
|
118 |
|
119 | const description = {
|
120 | [getKey(event.key)]: true,
|
121 | };
|
122 |
|
123 | allModifiers.forEach((m) => {
|
124 | if (event[`${m}Key`]) {
|
125 | description[m] = true;
|
126 | }
|
127 | });
|
128 |
|
129 | buffer.push(description);
|
130 |
|
131 | listeners.forEach((listener) => {
|
132 | if (matchHotkey(buffer, listener.hotkey)) {
|
133 | listener.callback(event);
|
134 | }
|
135 | });
|
136 | };
|
137 | };
|
138 |
|
139 | const validateContext = (options) => {
|
140 | const { debounceTime = 500, autoEnable = true } = options || {};
|
141 |
|
142 | validateType(debounceTime, 'debounceTime', 'number');
|
143 | validate(debounceTime > 0, 'debounceTime must be > 0');
|
144 | validateType(autoEnable, 'autoEnable', 'boolean');
|
145 |
|
146 | return { debounceTime, autoEnable };
|
147 | };
|
148 |
|
149 | export const createContext = (options) => {
|
150 | const { debounceTime, autoEnable } = validateContext(options);
|
151 |
|
152 | const listeners = [];
|
153 | const keyDownListener = createKeyDownListener(listeners, debounceTime);
|
154 |
|
155 | const enable = () => document.addEventListener('keydown', keyDownListener);
|
156 | const disable = () => document.removeEventListener('keydown', keyDownListener);
|
157 |
|
158 | if (autoEnable) {
|
159 | enable();
|
160 | }
|
161 |
|
162 | return {
|
163 | register: createListenersFn(listeners, registerListener),
|
164 | unregister: createListenersFn(listeners, unregisterListener),
|
165 | enable,
|
166 | disable,
|
167 | };
|
168 | };
|