hotkeys/src/HotKeyManager.ts
2023-12-27 19:01:12 +01:00

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);
}
}