// SPDX-FileCopyrightText: 2020 Jamie Kyle) // // SPDX-License-Identifier: MIT // COPIED FROM https://github.com/jamiebuilds/tinykeys/blob/111955cb6604fb5b8c4f152cb75b7f2cb63da913/src/tinykeys.ts type KeyBindingPress = [string[], string]; /** * A map of keybinding strings to event handlers. */ export interface KeyBindingMap { // eslint-disable-next-line no-unused-vars [keybinding: string]: (event: KeyboardEvent) => void; } export interface KeyBindingHandlerOptions { /** * Keybinding sequences will wait this long between key presses before * cancelling (default: 1000). * * **Note:** Setting this value too low (i.e. `300`) will be too fast for many * of your users. */ timeout?: number; } /** * Options to configure the behavior of keybindings. */ export interface KeyBindingOptions extends KeyBindingHandlerOptions { /** * Key presses will listen to this event (default: "keydown"). */ event?: 'keydown' | 'keyup'; } /** * These are the modifier keys that change the meaning of keybindings. * * Note: Ignoring "AltGraph" because it is covered by the others. */ const KEYBINDING_MODIFIER_KEYS = ['Shift', 'Meta', 'Alt', 'Control']; /** * Keybinding sequences should timeout if individual key presses are more than * 1s apart by default. */ const DEFAULT_TIMEOUT = 1000; /** * Keybinding sequences should bind to this event by default. */ const DEFAULT_EVENT = 'keydown'; /** * Platform detection code. * @see https://github.com/jamiebuilds/tinykeys/issues/184 */ const PLATFORM = typeof navigator === 'object' ? navigator.platform : ''; const APPLE_DEVICE = /Mac|iPod|iPhone|iPad/.test(PLATFORM); /** * An alias for creating platform-specific keybinding aliases. */ const MOD = APPLE_DEVICE ? 'Meta' : 'Control'; /** * Meaning of `AltGraph`, from MDN: * - Windows: Both Alt and Ctrl keys are pressed, or AltGr key is pressed * - Mac: ⌥ Option key pressed * - Linux: Level 3 Shift key (or Level 5 Shift key) pressed * - Android: Not supported * @see https://github.com/jamiebuilds/tinykeys/issues/185 */ const ALT_GRAPH_ALIASES = PLATFORM === 'Win32' ? ['Control', 'Alt'] : APPLE_DEVICE ? ['Alt'] : []; /** * There's a bug in Chrome that causes event.getModifierState not to exist on * KeyboardEvent's for F1/F2/etc keys. */ function getModifierState(event: KeyboardEvent, mod: string) { return typeof event.getModifierState === 'function' ? event.getModifierState(mod) || (ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState('AltGraph')) : false; } /** * Parses a "Key Binding String" into its parts * * grammar = `` * = ` ...` * = `` or `+` * = `++...` */ export function parseKeybinding(str: string): KeyBindingPress[] { return str .trim() .split(' ') .map((press) => { let mods = press.split(/\b\+/); const key = mods.pop() as string; mods = mods.map((mod) => (mod === '$mod' ? MOD : mod)); return [mods, key]; }); } /** * This tells us if a series of events matches a key binding sequence either * partially or exactly. */ function match(event: KeyboardEvent, press: KeyBindingPress): boolean { // prettier-ignore return !( // Allow either the `event.key` or the `event.code` // MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key // MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code ( press[1].toUpperCase() !== event.key.toUpperCase() && press[1] !== event.code ) || // Ensure all the modifiers in the keybinding are pressed. press[0].find(mod => { return !getModifierState(event, mod) }) || // KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a // keybinding. So if they are pressed but aren't part of the current // keybinding press, then we don't have a match. KEYBINDING_MODIFIER_KEYS.find(mod => { return !press[0].includes(mod) && press[1] !== mod && getModifierState(event, mod) }) ) } /** * Creates an event listener for handling keybindings. * * @example * ```js * import { createKeybindingsHandler } from "../src/keybindings" * * let handler = createKeybindingsHandler({ * "Shift+d": () => { * alert("The 'Shift' and 'd' keys were pressed at the same time") * }, * "y e e t": () => { * alert("The keys 'y', 'e', 'e', and 't' were pressed in order") * }, * "$mod+d": () => { * alert("Either 'Control+d' or 'Meta+d' were pressed") * }, * }) * * window.addEvenListener("keydown", handler) * ``` */ export function createKeybindingsHandler( keyBindingMap: KeyBindingMap, options: KeyBindingHandlerOptions = {} ): EventListener { const timeout = options.timeout ?? DEFAULT_TIMEOUT; const keyBindings = Object.keys(keyBindingMap).map((key) => { return [parseKeybinding(key), keyBindingMap[key]] as const; }); const possibleMatches = new Map(); let timer: number | null = null; return (event) => { // Ensure and stop any event that isn't a full keyboard event. // Autocomplete option navigation and selection would fire a instanceof Event, // instead of the expected KeyboardEvent if (!(event instanceof KeyboardEvent)) { return; } keyBindings.forEach((keyBinding) => { const sequence = keyBinding[0]; const callback = keyBinding[1]; const prev = possibleMatches.get(sequence); const remainingExpectedPresses = prev ? prev : sequence; const currentExpectedPress = remainingExpectedPresses[0]; const matches = match(event, currentExpectedPress); if (!matches) { // Modifier keydown events shouldn't break sequences // Note: This works because: // - non-modifiers will always return false // - if the current keypress is a modifier then it will return true when we check its state // MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState if (!getModifierState(event, event.key)) { possibleMatches.delete(sequence); } } else if (remainingExpectedPresses.length > 1) { possibleMatches.set(sequence, remainingExpectedPresses.slice(1)); } else { possibleMatches.delete(sequence); callback(event); } }); if (timer) { clearTimeout(timer); } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // skipcq: JS-0372 timer = setTimeout(possibleMatches.clear.bind(possibleMatches), timeout); }; } /** * Subscribes to keybindings. * * Returns an unsubscribe method. * * @example * ```js * import { tinykeys } from "../src/tinykeys" * * tinykeys(window, { * "Shift+d": () => { * alert("The 'Shift' and 'd' keys were pressed at the same time") * }, * "y e e t": () => { * alert("The keys 'y', 'e', 'e', and 't' were pressed in order") * }, * "$mod+d": () => { * alert("Either 'Control+d' or 'Meta+d' were pressed") * }, * }) * ``` */ export function tinykeys( target: Window | HTMLElement, keyBindingMap: KeyBindingMap, options: KeyBindingOptions = {} ): () => void { const event = options.event ?? DEFAULT_EVENT; const onKeyEvent = createKeybindingsHandler(keyBindingMap, options); target.addEventListener(event, onKeyEvent); return () => { target.removeEventListener(event, onKeyEvent); }; }