@finos/legend-shared
Version:
Legend Studio shared utilities and helpers
223 lines (201 loc) • 8.07 kB
text/typescript
/**
* 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,
);
};
}