UNPKG

@finos/legend-shared

Version:
165 lines 8.28 kB
/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { uniqBy } from '../CommonUtils.js'; import { guaranteeNonNullable } from '../error/AssertionUtils.js'; /** * List of keyboard named key * See https://www.w3.org/TR/uievents-key/#named-key-attribute-values * See https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values */ export var KEYBOARD_NAMED_KEY; (function (KEYBOARD_NAMED_KEY) { KEYBOARD_NAMED_KEY["TAB"] = "Tab"; KEYBOARD_NAMED_KEY["CAPSLOCK"] = "CapsLock"; KEYBOARD_NAMED_KEY["SHIFT"] = "Shift"; KEYBOARD_NAMED_KEY["META"] = "Meta"; KEYBOARD_NAMED_KEY["ALT"] = "Alt"; KEYBOARD_NAMED_KEY["CONTROL"] = "Control"; KEYBOARD_NAMED_KEY["SPACE"] = "Space"; KEYBOARD_NAMED_KEY["ESC"] = "Escape"; KEYBOARD_NAMED_KEY["LEFT"] = "ArrowLeft"; KEYBOARD_NAMED_KEY["RIGHT"] = "ArrowRight"; KEYBOARD_NAMED_KEY["UP"] = "ArrowUp"; KEYBOARD_NAMED_KEY["DOWN"] = "ArrowDown"; KEYBOARD_NAMED_KEY["ENTER"] = "Enter"; KEYBOARD_NAMED_KEY["DELETE"] = "Delete"; KEYBOARD_NAMED_KEY["BACKSPACE"] = "Backspace"; KEYBOARD_NAMED_KEY["BACKQUOTE"] = "Backquote"; })(KEYBOARD_NAMED_KEY || (KEYBOARD_NAMED_KEY = {})); /** * Parses key bindings and pack them into parts * * grammar = `<sequence>` * <sequence> = `<press> <press> <press> ...` * <press> = `<key>` or `<mods>+<key>` * <mods> = `<mod>+<mod>+...` */ export function parseKeybinding(value) { return value .trim() .split(' ') .map((press) => { const modifiers = press.split(/\b\+/); const key = guaranteeNonNullable(modifiers.pop(), `Can't parse key binding: last part must be a non-modifier key`); return { modifiers, key }; }); } /** * Checks if a series of keypress events matches a key binding sequence either partially or exactly. */ export const isMatchingKeyPressData = (event, keyPressData) => // NOTE: we allow specifying with `event.code` only // - key: value of the key pressed by the user, taking into consideration the state of modifier keys // such as `Shift` as well as the keyboard locale and layout. This is not desirable, because // for matching, we have to do uppercase comparison, in Mac, if we presss Alt+B for example, we will // get symbols // - code: represents a physical key on the keyboard (as opposed to the character generated by pressing the key); // i.e. this property returns a value that isn't altered by keyboard layout or the state of the modifier keys // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code keyPressData.key === event.code && // ensure all the modifiers in the keybinding are pressed keyPressData.modifiers.every((modifier) => event.getModifierState(modifier)) && // modifier keys (Shift, Control, etc.) change the meaning of a keybinding, // so if they are pressed but aren't part of the current keypress sequence, it's not a match. ![ KEYBOARD_NAMED_KEY.SHIFT, KEYBOARD_NAMED_KEY.META, KEYBOARD_NAMED_KEY.ALT, KEYBOARD_NAMED_KEY.CONTROL, ].find((modifier) => // check if the current key pressed is one of the modifier keys keyPressData.key !== modifier && event.getModifierState(modifier) && // check if the modifier key pressed is part of the key combination !keyPressData.modifiers.includes(modifier)); export const isMatchingKeyCombination = (event, keyCombination) => isMatchingKeyPressData(event, guaranteeNonNullable(parseKeybinding(keyCombination)[0])); /** * Create event listener for keyboard event (recommended to be used with `keydown/keyup` event) * * This succinct logic is adapted from `tinykeys` * See https://github.com/jamiebuilds/tinykeys */ export function createKeybindingsHandler(config) { // this holds the possible hotkey matches for the current sequence of keyboard events const possibleMatches = new Map(); // NOTE: this timer is used to time the duration between key presses to determine when to // cancel the sequence, we set the default timeout to be 1000, as short timeout // can be slightly too fast for users let timer = null; const DEFAULT_TIMEOUT_DURATION = 1000; return (event) => { // Ensure and stop any event that isn't an keyboard event. // e.g. auto-complete option navigation and selection could fire an event if (!(event instanceof KeyboardEvent)) { return; } // NOTE: create a flat collection of key combination to handler, make sure // for each combination, only the first matching entry is considered, // i.e. explicitly here, we don't handle multiple handling // See https://github.com/finos/legend-studio/issues/1969 uniqBy(Object.values(config) .flatMap((entry) => entry.combinations.map((combination) => ({ combination, handler: entry.handler, }))) .filter((entry) => entry.combination.length), (val) => val.combination) // here, we go through each hotkey combination, and: // 1. parse the key combination // 2. if the key combination is already part of the possible matches, // retrieve the current match result: this result is the list of the // keypresses left to complete the combination, else, we just add the // new combination and proceed the same way .forEach((entry) => { const keyCombination = entry.combination; const parsedKeyCombination = parseKeybinding(entry.combination); // abort when we encounter an empty key combination if (!parsedKeyCombination.length) { return; } const remainingExpectedKeyPresses = possibleMatches.get(keyCombination) ?? parsedKeyCombination; // this should never be empty, as it becomes empty, it must mean: // 1. either the key combination has been matched before and not cleared // 2. the key combination is empty, which should not be possible due to the guard above const currentExpectedKeyPress = guaranteeNonNullable(remainingExpectedKeyPresses[0]); const matches = isMatchingKeyPressData(event, currentExpectedKeyPress); if (!matches) { // if the current key pressed is a modifier key // we don't consider this as a non-match yet // NOTE: here we use `event.key` instead of `event.code` since for `Shift` key, // the key is always `Shift` where the key code can be `ShiftLeft` if (!event.getModifierState(event.key)) { possibleMatches.delete(keyCombination); } } else if (remainingExpectedKeyPresses.length > 1) { // matches found, not all keypresses of the key combination have been fulfilled, remove // the matched keypress part of the combination (if it's in the list of possible matches) possibleMatches.set(keyCombination, remainingExpectedKeyPresses.slice(1)); } else { // matches found, all keypresses of the combination have been fulfilled, call the handler possibleMatches.delete(keyCombination); entry.handler(keyCombination, event); } }); if (timer) { clearTimeout(timer); } timer = setTimeout(possibleMatches.clear.bind(possibleMatches), DEFAULT_TIMEOUT_DURATION); }; } //# sourceMappingURL=KeyBinding.js.map