246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
import { HotkeyDefinitionType, specialKeys } from './HotkeyDefinitionType';
|
|
import { HotkeyListener } from './HotkeyListener';
|
|
import { JsonHelper, ObjectHelper, Override } from '@ainias42/js-helper';
|
|
import { HotkeyEntry } from './HotkeyEntry';
|
|
import { SubKeyType } from './SubKeyType';
|
|
import { HotkeyPressedMap } from './HotkeyPressedMap';
|
|
import { HotkeyConfigWithOptionals } from './HotkeyConfigWithOptionals';
|
|
|
|
export class HotKeyManager<HotkeyConfig extends Record<string, HotkeyEntry<string>>> {
|
|
private keyPressedMap = new Map<string, boolean>();
|
|
private hotKeys: HotkeyConfig;
|
|
private hotKeysPressedMap: HotkeyPressedMap<HotkeyConfig>;
|
|
private enabled = true;
|
|
private readonly ignoreFormElements: boolean = true;
|
|
|
|
constructor(hotkeys: HotkeyConfigWithOptionals<HotkeyConfig>, ignoreFormElements?: boolean) {
|
|
this.hotKeys = {} as HotkeyConfig;
|
|
this.addHotKeys(hotkeys);
|
|
|
|
if (ignoreFormElements !== undefined) {
|
|
this.ignoreFormElements = ignoreFormElements;
|
|
}
|
|
}
|
|
|
|
private static isFormElement(element: EventTarget | null) {
|
|
return (
|
|
element instanceof HTMLInputElement ||
|
|
element instanceof HTMLSelectElement ||
|
|
element instanceof HTMLTextAreaElement ||
|
|
(element instanceof HTMLElement && element.contentEditable === 'true')
|
|
);
|
|
}
|
|
|
|
private static isEqual(a: HotkeyDefinitionType, b: HotkeyDefinitionType) {
|
|
return JsonHelper.deepEqual(a, b);
|
|
}
|
|
|
|
private static checkAffections(keyDefinitions: HotkeyDefinitionType[], key: string): boolean {
|
|
return keyDefinitions.some((keyDefinition) => HotKeyManager.checkAffection(keyDefinition, key));
|
|
}
|
|
|
|
private static checkAffection(keyDefinition: HotkeyDefinitionType, key: string): boolean {
|
|
return (
|
|
keyDefinition.keys.includes(key) ||
|
|
(key in specialKeys && keyDefinition[key as (typeof specialKeys)[number]] !== undefined)
|
|
);
|
|
}
|
|
|
|
addHotKeys<NewHotkeyConfig extends Record<string, HotkeyEntry<string>>>(
|
|
hotkeys: HotkeyConfigWithOptionals<NewHotkeyConfig>,
|
|
) {
|
|
type NewConfig = Override<HotkeyConfig, NewHotkeyConfig>;
|
|
|
|
this.hotKeys = {
|
|
...this.hotKeys,
|
|
...ObjectHelper.entries(hotkeys).reduce((acc, [key, value]) => {
|
|
acc[key] = { subKeys: {}, callbacks: [], ...value } as unknown as NewConfig[typeof key];
|
|
return acc;
|
|
}, {} as NewConfig),
|
|
};
|
|
this.hotKeysPressedMap = this.generateHotkeyPressedMap();
|
|
|
|
return this as unknown as HotKeyManager<NewConfig>;
|
|
}
|
|
|
|
addListener<HotkeyName extends keyof HotkeyConfig>(
|
|
hotkeyName: HotkeyName,
|
|
callback: HotkeyListener<SubKeyType<HotkeyConfig, HotkeyName>>,
|
|
) {
|
|
const { callbacks } = this.hotKeys[hotkeyName];
|
|
callbacks.push(callback);
|
|
return () => {
|
|
const index = callbacks.indexOf(callback);
|
|
if (index > -1) {
|
|
callbacks.splice(index, 1);
|
|
}
|
|
};
|
|
}
|
|
|
|
enable() {
|
|
this.enabled = true;
|
|
}
|
|
|
|
disable() {
|
|
this.enabled = false;
|
|
}
|
|
|
|
addHotKeyDefinition(hotkey: keyof HotkeyConfig, definition: HotkeyDefinitionType) {
|
|
this.hotKeys[hotkey].keys.push(definition);
|
|
}
|
|
|
|
removeHotKeyDefinition(hotkey: keyof HotkeyConfig, definition: HotkeyDefinitionType) {
|
|
this.hotKeys[hotkey].keys = this.hotKeys[hotkey].keys.filter((key) => !HotKeyManager.isEqual(key, definition));
|
|
}
|
|
|
|
setHotKeyDefinitions(hotkey: keyof HotkeyConfig, definitions: HotkeyDefinitionType[]) {
|
|
this.hotKeys[hotkey].keys = definitions;
|
|
}
|
|
|
|
addSubKeyDefinition<HotkeyName extends keyof HotkeyConfig>(
|
|
hotkey: HotkeyName,
|
|
subKey: SubKeyType<HotkeyConfig, HotkeyName>,
|
|
definition: HotkeyDefinitionType,
|
|
) {
|
|
this.hotKeys[hotkey].subKeys[subKey as string].push(definition);
|
|
}
|
|
|
|
removeSubKeyDefinition<HotkeyName extends keyof HotkeyConfig>(
|
|
hotkey: HotkeyName,
|
|
subKey: SubKeyType<HotkeyConfig, HotkeyName>,
|
|
definition: HotkeyDefinitionType,
|
|
) {
|
|
const { subKeys } = this.hotKeys[hotkey];
|
|
subKeys[subKey as string] = subKeys[subKey as string].filter((key) => !HotKeyManager.isEqual(key, definition));
|
|
}
|
|
|
|
setSubKeyDefinitions<HotkeyName extends keyof HotkeyConfig>(
|
|
hotkey: HotkeyName,
|
|
subKey: SubKeyType<HotkeyConfig, HotkeyName>,
|
|
definitions: HotkeyDefinitionType[],
|
|
) {
|
|
this.hotKeys[hotkey].subKeys[subKey as string] = definitions;
|
|
}
|
|
|
|
getConfig() {
|
|
return this.hotKeys;
|
|
}
|
|
|
|
addKeyListeners() {
|
|
window.addEventListener('keydown', (e) => {
|
|
if (this.ignoreFormElements && HotKeyManager.isFormElement(e.target)) {
|
|
return;
|
|
}
|
|
|
|
this.keyPressedMap.set(e.key.toLowerCase(), true);
|
|
this.checkHotkeys(e);
|
|
});
|
|
window.addEventListener('keyup', (e) => {
|
|
// Ignore the "ignoreFormFields" setting for keyup
|
|
// Release should always trigger or else one hotkey can create form-field which is
|
|
// focused and then it will never be released
|
|
|
|
if (e.key === 'Meta') {
|
|
this.clearMap(e);
|
|
} else {
|
|
this.keyPressedMap.set(e.key.toLowerCase(), false);
|
|
this.checkHotkeys(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
getHotKeysPressedMap() {
|
|
return this.hotKeysPressedMap;
|
|
}
|
|
|
|
isHotKeyPressed(hotkey: keyof HotkeyConfig) {
|
|
return this.hotKeysPressedMap[hotkey].isPressed;
|
|
}
|
|
|
|
isSubKeyPressed<Hotkey extends keyof HotkeyConfig>(hotkey: Hotkey, subkey: SubKeyType<HotkeyConfig, Hotkey>) {
|
|
return this.hotKeysPressedMap[hotkey].isPressed && this.hotKeysPressedMap[hotkey].subKeys[subkey];
|
|
}
|
|
|
|
private checkHotkeyDefinitions(keyDefinitions: HotkeyDefinitionType[]): boolean {
|
|
return keyDefinitions.some((keyDefinition) => this.checkHotkeyDefinition(keyDefinition));
|
|
}
|
|
|
|
private checkHotkeyDefinition(keyDefinition: HotkeyDefinitionType): boolean {
|
|
if (!this.enabled) {
|
|
return false;
|
|
}
|
|
|
|
if (keyDefinition.keys.some((key) => this.keyPressedMap.get(key) !== true)) {
|
|
return false;
|
|
}
|
|
|
|
return !specialKeys.some(
|
|
(key) => keyDefinition[key] !== undefined && !!this.keyPressedMap.get(key) !== keyDefinition[key],
|
|
);
|
|
}
|
|
|
|
private generateHotkeyPressedMap() {
|
|
return ObjectHelper.entries(this.hotKeys).reduce((acc, [key, value]) => {
|
|
const isPressed = this.checkHotkeyDefinitions(value.keys);
|
|
acc[key] = {
|
|
isPressed,
|
|
subKeys: ObjectHelper.keys(value.subKeys).reduce(
|
|
(accInner, subKey) => {
|
|
accInner[subKey as SubKeyType<HotkeyConfig, typeof key>] =
|
|
isPressed && this.checkHotkeyDefinitions(value.subKeys[subKey]);
|
|
return accInner;
|
|
},
|
|
{} as Record<SubKeyType<HotkeyConfig, typeof key>, boolean>,
|
|
),
|
|
};
|
|
return acc;
|
|
}, {} as HotkeyPressedMap<HotkeyConfig>);
|
|
}
|
|
|
|
private triggerListenerFor(key: keyof HotkeyConfig, event: KeyboardEvent) {
|
|
const pressedEntry = this.hotKeysPressedMap[key];
|
|
this.hotKeys[key].callbacks.forEach((callback) =>
|
|
callback({ event, subKeys: pressedEntry.subKeys, isPressed: pressedEntry.isPressed }),
|
|
);
|
|
}
|
|
|
|
private checkHotkeys(event: KeyboardEvent) {
|
|
const hotkeysToTrigger: (keyof HotkeyConfig)[] = [];
|
|
|
|
const oldMap = this.hotKeysPressedMap;
|
|
this.hotKeysPressedMap = this.generateHotkeyPressedMap();
|
|
const hotkeysToCheckAffection = ObjectHelper.entries(this.hotKeysPressedMap).filter(([key, value]) => {
|
|
const oldValue = oldMap[key];
|
|
if (
|
|
oldValue.isPressed !== value.isPressed ||
|
|
(value.isPressed && !JsonHelper.deepEqual(oldValue.subKeys, value.subKeys))
|
|
) {
|
|
hotkeysToTrigger.push(key);
|
|
return false;
|
|
}
|
|
return value.isPressed;
|
|
});
|
|
|
|
// Check for repeated pressed. Nothing changed, but key is pressed again
|
|
const eventKey = event.key.toLowerCase();
|
|
hotkeysToCheckAffection.forEach(([key]) => {
|
|
const keyDefinition = this.hotKeys[key];
|
|
if (HotKeyManager.checkAffections(keyDefinition.keys, eventKey)) {
|
|
hotkeysToTrigger.push(key);
|
|
} else {
|
|
ObjectHelper.values(keyDefinition.subKeys).forEach((subKeyDefinitions) => {
|
|
if (HotKeyManager.checkAffections(subKeyDefinitions, eventKey)) {
|
|
hotkeysToTrigger.push(key);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
hotkeysToTrigger.forEach((key) => this.triggerListenerFor(key, event));
|
|
}
|
|
|
|
private clearMap(event: KeyboardEvent) {
|
|
this.keyPressedMap.clear();
|
|
this.checkHotkeys(event);
|
|
}
|
|
}
|