@finos/legend-shared
Version:
Legend Studio shared utilities and helpers
165 lines • 8.28 kB
JavaScript
/**
* 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