UNPKG

@finos/legend-shared

Version:
223 lines (201 loc) 8.07 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 enum KEYBOARD_NAMED_KEY { TAB = 'Tab', CAPSLOCK = 'CapsLock', SHIFT = 'Shift', META = 'Meta', ALT = 'Alt', CONTROL = 'Control', SPACE = 'Space', ESC = 'Escape', LEFT = 'ArrowLeft', RIGHT = 'ArrowRight', UP = 'ArrowUp', DOWN = 'ArrowDown', ENTER = 'Enter', DELETE = 'Delete', BACKSPACE = 'Backspace', BACKQUOTE = 'Backquote', } type KeyPressData = { modifiers: string[]; key: string }; export type KeyBindingConfig = { [key: string]: { combinations: string[]; handler: (keyCombination: string, event: KeyboardEvent) => void; }; }; /** * 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: string): KeyPressData[] { 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: KeyboardEvent, keyPressData: KeyPressData, ): boolean => // 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: KeyboardEvent, keyCombination: string, ): boolean => 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: KeyBindingConfig, ): EventListener { // this holds the possible hotkey matches for the current sequence of keyboard events const possibleMatches = new Map<string, KeyPressData[]>(); // 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: ReturnType<typeof setTimeout> | null = 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, ); }; }