252 lines
7.1 KiB
TypeScript
252 lines
7.1 KiB
TypeScript
// 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 = `<sequence>`
|
|
* <sequence> = `<press> <press> <press> ...`
|
|
* <press> = `<key>` or `<mods>+<key>`
|
|
* <mods> = `<mod>+<mod>+...`
|
|
*/
|
|
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<KeyBindingPress[], KeyBindingPress[]>();
|
|
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);
|
|
};
|
|
}
|