Implementation

I’m using ES6 syntax, so please make sure you’re familiar with at least the block-scoped variables, arrow functions, object destructing, and template strings.

I’m going to describe every single function separately, do some refactor, and add validation, then show the code all together at the end.

The article does not include how to run the ES6 modules in the browser. If you don’t know that yet, start with a transpiler like Babel.

Register the hotkey

At the first stage, we need to create a function that registers the callback for a specific hotkey. The function gets a hotkey (string) and a callback function that is stored for further reuse. Let’s call it register :

const listeners = [];

const register = (hotkey, callback) => {

listeners.push({ hotkey: normalizeHotkey(hotkey), callback });

}

There is a listeners array: that’s a bag for the registered callbacks. We’ll deal with the array later, but for now, please pay attention to the normalizeHotkey function.

The function converts the hotkey string to a structure that:

Is easy (efficient) to compare against others, and

Is easy (efficient) to create from keyboard events

The reason for this is that different strings may correspond to the same hotkey. For example, ctrl+shift+s and shift+ctrl+s refer to the same combination, and it might be hard to match with the keyboard event directly.

The function converts the string to an array of dictionaries. The dictionary key keeps an actual key with true as a value.

Some examples of how it works:

normalizeHotkey('a') returns [{ a: true }]

returns normalizeHotkey('ctrl+a') returns [{ ctrl: true, a: true }]

returns normalizeHotkey('meta+a shift+b') returns

[{ meta: true, a: true }, { shift: true, b: true }]

Implementation:

const arrayToObject = arr => arr.reduce(

(obj, key) => ({ ...obj, [key]: true }),

{},

); const normalizeHotkey = (hotkey) =>

hotkey.split(' ').map((part) => arrayToObject(part.split('+')));

The keydown listener

Okay, we’re done with registering. Now we need to set up a keydown listener.

As we support multiple key combinations, we need to store the subsequent keydown events to determine if a series of events match the combination(s).

Every time the key is pressed, the event description is added to the buffer array and it continues to grow as long as the keydown event is triggered. We don’t want to continue matching the hotkey after a period of inactivity, thus the buffer must be cleared after the keydown listener stops being triggered for a while.

This method is called a debounce. If you’re not familiar with this concept yet, I would suggest you check out some articles like this.

Minimal implementation of the debounce function that accepts a function and the debounce time as parameters. The function returns the ”debounced” function:

const debounce = (fn, time) => {

let timeoutId = null; return () => {

clearTimeout(timeoutId);

timeoutId = setTimeout(fn, time);

};

};

The keydown listener implementation:

let buffer = []; const clearBufferDebounced = debounce(

() => { buffer = []; },

debounceTime,

); const keyDownListener = (event) => {

if (event.repeat) {

return;

} if (event.getModifierState(event.key)) {

return;

} clearBufferDebounced(); const description = {

[getKey(event.key)]: true,

}; allModifiers.forEach((m) => {

if (event[`${m}Key`]) {

description[m] = true;

}

}); buffer.push(description); listeners.forEach((listener) => {

if (matchHotkey(buffer, listener.hotkey)) {

listener.callback(event);

}

});

};

The main logic checks every listener and calls its callback when there’s a match with the buffer.

listeners.forEach((listener) => {

if (matchHotkey(buffer, listener.hotkey)) {

listener.callback(event);

}

});

But, the event must meet the below conditions to get there.

If you hold a key for some time, it may start to trigger the keydown event repeatedly. This behavior is undesirable and we have to find a way to ignore such events.

if (event.repeat) {

return;

}

The event is also ignored when you press the modifier itself:

if (event.getModifierState(event.key)) {

return;

}

The method event.getModifierState accepts a string representation of the modifier key, and returns true if the current key is a modifier. See the documentation for more details.

To explain why we need this, let’s consider a hotkey sequence ctrl+c ctrl+k . We can achieve that in two ways:

Hold control and press c then k ; or

and press then ; or Hold control and press c , release control then hold it again and press k

The first will result in three events stored in the buffer — ctrl ctrl+c ctrl+k . As ctrl+c ctrl+k matches the buffer, it will work as expected.

The latter results in four events — ctrl ctrl+c ctrl ctrl+k . As there is a ctrl event in between ctrl+c and ctrl+k , the sequences won’t match.

Yep, you’re right, this is a limit. So, you cannot bind to the modifier key only. Hotkeys like shift , ctrl or alt won’t work. This case will be covered by the validation later.

The description variable keeps the normalized hotkey definition — the same format we’ve stored in the listeners array.

I decided to use the key property as keyCode , charCode , which are already deprecated.

The key property holds a printed representation of the keyboard key, so we don’t have to write custom logic to find out what the current key is.

There are two exceptions that we need to handle — the space key and the plus sign. We’re using both as delimiters in the hotkey string we pass to the register and the unregister function.

The workaround is to replace ' ' with 'space' and '+' with 'plus' :

const getKey = (key) => {

switch (key) {

case '+':

return 'plus';

case ' ':

return 'space';

default:

return key;

}

};

The allModifiers array contains a list of modifiers, and it is used to fill the description map. It’s introduced to avoid redundant syntax like:

(...)

if (event.ctrlKey) {

description.ctrl = true;

} if (event.shiftKey) {

description.shift = true;

}

(...)

You might think we could set all of the modifiers directly like:

const description = {

(...)

ctrl: event.ctrlKey,

shift: event.shiftKey,

alt: event.altKey,

meta: event.metaKey

}

It looks neat and clean but doesn’t match the exact state as returned from the normalizeHotkey function — the latter includes only the pressed keys.

The current implementation seems to be hackish as it assumes there are *Key properties ( ctrlKey , altKey , shiftKey , metaKey ) in the event object:

allModifiers.forEach((m) => {

if (event[`${m}Key`]) {

description[m] = true;

}

});

Even if it works, we’re limited to these four modifiers. The other solution would be to use the event.getModifierState like:

allModifiers.forEach((m) => {

if (event.getModifierState(m) {

description[m.toLowerCase()] = true;

}

});

It looks better. However:

It requires you to operate on both regular and lower-case names

ctrl becomes control — if you want to keep ctrl you need to map it in the normalizeHotkeyMethod

I just wanted to let you know about a possible improvement.

Last but not least, the description is added to the buffer array, then it’s checked against the listeners .

buffer.push(description);

listeners.forEach((listener) => {

if (matchHotkey(buffer, listener.hotkey)) {

listener.callback(event);

}

});

The matching algorithm

This algorithm checks if the buffer contains subsequent elements from the hotkey array.

const isEqual = (a, b) => {

const aKeys = Object.keys(a);

if (aKeys.length !== Object.keys(b).length) {

return false;

} return aKeys.every(

(k) => b.hasOwnProperty(k) && a[k] === b[k]

);

} const matchHotkey = (buffer, hotkey) => {

if (buffer.length < hotkey.length) {

return false

} const indexDiff = buffer.length - hotkey.length;

for(let i = hotkey.length - 1; i >= 0; i--) {

if(!isEqual(buffer[indexDiff + i], hotkey[i])) {

return false;

}

}

return true;

}