Skip to content

Instantly share code, notes, and snippets.

@artystable
Forked from abuduba/hotkeys.js
Created December 25, 2020 05:59
Show Gist options
  • Save artystable/fdbf99298e7f18c0f1a97f23630ddcd0 to your computer and use it in GitHub Desktop.
Save artystable/fdbf99298e7f18c0f1a97f23630ddcd0 to your computer and use it in GitHub Desktop.

Revisions

  1. @abuduba abuduba revised this gist Aug 22, 2019. 1 changed file with 0 additions and 20 deletions.
    20 changes: 0 additions & 20 deletions hotkeys-usage.js
    Original file line number Diff line number Diff line change
    @@ -1,20 +0,0 @@
    import { createContext } from './hotkeys';

    const c = createContext();

    // Alerts when "no way" is typed in.
    c.register('n o space w a y', () => {
    alert('Yes way!');
    });

    /*
    It replaces the native search behaviour when pressing ctrl+f
    Note, the callback gets the event from the last key,
    that is matched in the given sequence
    */
    c.register('ctrl+f', (event) => {
    // prevent the native search behavior
    event.preventDefault();
    const value = prompt('What you are looking for?');
    window.location.href = `https://www.google.com/?q=${value}`;
    });
  2. @abuduba abuduba revised this gist Aug 22, 2019. 1 changed file with 20 additions and 0 deletions.
    20 changes: 20 additions & 0 deletions hotkeys-usage.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,20 @@
    import { createContext } from './hotkeys';

    const c = createContext();

    // Alerts when "no way" is typed in.
    c.register('n o space w a y', () => {
    alert('Yes way!');
    });

    /*
    It replaces the native search behaviour when pressing ctrl+f
    Note, the callback gets the event from the last key,
    that is matched in the given sequence
    */
    c.register('ctrl+f', (event) => {
    // prevent the native search behavior
    event.preventDefault();
    const value = prompt('What you are looking for?');
    window.location.href = `https://www.google.com/?q=${value}`;
    });
  3. @abuduba abuduba revised this gist Aug 20, 2019. 1 changed file with 103 additions and 51 deletions.
    154 changes: 103 additions & 51 deletions hotkeys.js
    Original file line number Diff line number Diff line change
    @@ -5,10 +5,14 @@ const isEqual = (a, b) => {
    return false;
    }

    return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k]);
    return aKeys.every(
    (k) => Object.prototype.hasOwnProperty.call(b, k)
    && a[k] === b[k],
    );
    };

    const isArrayEqual = (a, b) => a.length === b.length && a.every((v, i) => isEqual(v, b[i]));
    const isArrayEqual = (a, b) => a.length === b.length
    && a.every((v, i) => isEqual(v, b[i]));

    export const matchHotkey = (buffer, hotkey) => {
    if (buffer.length < hotkey.length) {
    @@ -37,22 +41,74 @@ const isHotkeyValid = (hotkey) => Object.keys(hotkey)
    .filter((k) => !indexedModifiers[k])
    .length === 1;

    const validate = (value, message) => {
    if (!value) {
    throw new Error(message);
    }
    };

    const validateType = (value, name, type) => {
    validate(
    typeof value === type,
    `The ${name} must be a ${type}; given ${typeof value}`,
    );
    };

    export const normalizeHotkey = (hotkey) => hotkey.split(/ +/g).map(
    (part) => {
    const arr = part.split('+').filter(Boolean);
    const result = arrayToObject(arr);

    if (Object.keys(result).length < arr.length) {
    throw new Error(`Hotkey combination must not contain duplicates "${hotkey}"`);
    }
    validate(
    Object.keys(result).length >= arr.length,
    `Hotkey combination has duplicates "${hotkey}"`,
    );

    validate(
    isHotkeyValid(result),
    `Invalid hotkey combination: "${hotkey}"`,
    );

    if (!isHotkeyValid(result)) {
    throw new Error(`Invalid hotkey combination: "${hotkey}"`);
    }
    return result;
    },
    );

    const validateListenerArgs = (hotkey, callback) => {
    validateType(hotkey, 'hotkey', 'string');
    validateType(callback, 'callback', 'function');
    };

    const createListenersFn = (listeners, fn) => (hotkey, callback) => {
    validateListenerArgs(hotkey, callback);
    fn(listeners, hotkey, callback);
    };

    const registerListener = (listeners, hotkey, callback) => {
    listeners.push({ hotkey: normalizeHotkey(hotkey), callback });
    };

    const unregisterListener = (listeners, hotkey, callback) => {
    const normalized = normalizeHotkey(hotkey);

    const index = listeners.findIndex(
    (l) => l.callback === callback
    && isArrayEqual(normalized, l.hotkey),
    );

    if (index !== -1) {
    listeners.splice(index, 1);
    }
    };

    const debounce = (fn, time) => {
    let timeoutId = null;

    return () => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(fn, time);
    };
    };

    const getKey = (key) => {
    switch (key) {
    case '+':
    @@ -64,21 +120,15 @@ const getKey = (key) => {
    }
    };

    export const createContext = ({ debounceTime = 500 } = {}) => {
    if (typeof debounceTime !== 'number') {
    throw new Error('debounceTime must be a number');
    }

    if (debounceTime <= 0) {
    throw new Error('debounceTime must be a > 0');
    }

    const createKeyDownListener = (listeners, debounceTime) => {
    let buffer = [];
    const clearBuffer = () => { buffer = []; };
    let bufferClearTimeout = null;
    const listeners = [];

    const keyDownListener = (event) => {
    const clearBufferDebounced = debounce(
    () => { buffer = []; },
    debounceTime,
    );

    return (event) => {
    if (event.repeat) {
    return;
    }
    @@ -87,8 +137,7 @@ export const createContext = ({ debounceTime = 500 } = {}) => {
    return;
    }

    clearTimeout(bufferClearTimeout);
    bufferClearTimeout = setTimeout((clearBuffer), debounceTime);
    clearBufferDebounced();

    const description = {
    [getKey(event.key)]: true,
    @@ -108,40 +157,43 @@ export const createContext = ({ debounceTime = 500 } = {}) => {
    }
    });
    };
    };

    const register = (hotkey, callback) => {
    if (typeof hotkey !== 'string') {
    throw new Error(
    `The hotkey must be a string; given ${typeof hotkey}`,
    );
    }
    const validateContext = (options) => {
    const { debounceTime = 500, autoEnable = true } = (options || {});

    if (typeof callback !== 'function') {
    throw new Error(
    `The callback must be a function; given ${typeof callback}`,
    );
    }
    console.log(normalizeHotkey(hotkey));
    listeners.push({ hotkey: normalizeHotkey(hotkey), callback });
    };
    validateType(debounceTime, 'debounceTime', 'number');
    validate(debounceTime > 0, 'debounceTime must be > 0');
    validateType(autoEnable, 'autoEnable', 'boolean');

    const unregister = (hotkeyString, callback) => {
    const normalized = normalizeHotkey(hotkeyString);
    const index = listeners.findIndex(
    (l) => l.callback === callback
    && isArrayEqual(normalized, l.hotkey),
    );
    if (index !== -1) {
    listeners.splice(index, 1);
    }
    };
    return { debounceTime, autoEnable };
    };

    export const createContext = (options) => {
    const { debounceTime, autoEnable } = validateContext(options);

    const enable = () => document.addEventListener('keydown', keyDownListener);
    const disable = () => document.removeEventListener('keydown', keyDownListener);
    const listeners = [];
    const keyDownListener = createKeyDownListener(
    listeners,
    debounceTime,
    );

    const enable = () => document.addEventListener(
    'keydown',
    keyDownListener,
    );
    const disable = () => document.removeEventListener(
    'keydown',
    keyDownListener,
    );

    if (autoEnable) {
    enable();
    }

    return {
    register,
    unregister,
    register: createListenersFn(listeners, registerListener),
    unregister: createListenersFn(listeners, unregisterListener),
    enable,
    disable,
    };
  4. @abuduba abuduba created this gist Aug 16, 2019.
    148 changes: 148 additions & 0 deletions hotkeys.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,148 @@
    const isEqual = (a, b) => {
    const aKeys = Object.keys(a);

    if (aKeys.length !== Object.keys(b).length) {
    return false;
    }

    return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k]);
    };

    const isArrayEqual = (a, b) => a.length === b.length && a.every((v, i) => isEqual(v, b[i]));

    export 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 -= 1) {
    if (!isEqual(buffer[indexDiff + i], hotkey[i])) {
    return false;
    }
    }

    return true;
    };

    const arrayToObject = (arr) => arr.reduce(
    (obj, key) => ({ ...obj, [key]: true }),
    {},
    );

    const allModifiers = ['ctrl', 'shift', 'alt', 'meta'];
    const indexedModifiers = arrayToObject(allModifiers);

    const isHotkeyValid = (hotkey) => Object.keys(hotkey)
    .filter((k) => !indexedModifiers[k])
    .length === 1;

    export const normalizeHotkey = (hotkey) => hotkey.split(/ +/g).map(
    (part) => {
    const arr = part.split('+').filter(Boolean);
    const result = arrayToObject(arr);

    if (Object.keys(result).length < arr.length) {
    throw new Error(`Hotkey combination must not contain duplicates "${hotkey}"`);
    }

    if (!isHotkeyValid(result)) {
    throw new Error(`Invalid hotkey combination: "${hotkey}"`);
    }
    return result;
    },
    );

    const getKey = (key) => {
    switch (key) {
    case '+':
    return 'plus';
    case ' ':
    return 'space';
    default:
    return key;
    }
    };

    export const createContext = ({ debounceTime = 500 } = {}) => {
    if (typeof debounceTime !== 'number') {
    throw new Error('debounceTime must be a number');
    }

    if (debounceTime <= 0) {
    throw new Error('debounceTime must be a > 0');
    }

    let buffer = [];
    const clearBuffer = () => { buffer = []; };
    let bufferClearTimeout = null;
    const listeners = [];

    const keyDownListener = (event) => {
    if (event.repeat) {
    return;
    }

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

    clearTimeout(bufferClearTimeout);
    bufferClearTimeout = setTimeout((clearBuffer), debounceTime);

    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);
    }
    });
    };

    const register = (hotkey, callback) => {
    if (typeof hotkey !== 'string') {
    throw new Error(
    `The hotkey must be a string; given ${typeof hotkey}`,
    );
    }

    if (typeof callback !== 'function') {
    throw new Error(
    `The callback must be a function; given ${typeof callback}`,
    );
    }
    console.log(normalizeHotkey(hotkey));
    listeners.push({ hotkey: normalizeHotkey(hotkey), callback });
    };

    const unregister = (hotkeyString, callback) => {
    const normalized = normalizeHotkey(hotkeyString);
    const index = listeners.findIndex(
    (l) => l.callback === callback
    && isArrayEqual(normalized, l.hotkey),
    );
    if (index !== -1) {
    listeners.splice(index, 1);
    }
    };

    const enable = () => document.addEventListener('keydown', keyDownListener);
    const disable = () => document.removeEventListener('keydown', keyDownListener);

    return {
    register,
    unregister,
    enable,
    disable,
    };
    };