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>> { private keyPressedMap = new Map(); private hotKeys: HotkeyConfig; private hotKeysPressedMap: HotkeyPressedMap; private enabled = true; private readonly ignoreFormElements: boolean = true; constructor(hotkeys: HotkeyConfigWithOptionals, 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>>( hotkeys: HotkeyConfigWithOptionals, ) { type NewConfig = Override; 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; } addListener( hotkeyName: HotkeyName, callback: HotkeyListener>, ) { 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( hotkey: HotkeyName, subKey: SubKeyType, definition: HotkeyDefinitionType, ) { this.hotKeys[hotkey].subKeys[subKey as string].push(definition); } removeSubKeyDefinition( hotkey: HotkeyName, subKey: SubKeyType, definition: HotkeyDefinitionType, ) { const { subKeys } = this.hotKeys[hotkey]; subKeys[subKey as string] = subKeys[subKey as string].filter((key) => !HotKeyManager.isEqual(key, definition)); } setSubKeyDefinitions( hotkey: HotkeyName, subKey: SubKeyType, 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: Hotkey, subkey: SubKeyType) { 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] = isPressed && this.checkHotkeyDefinitions(value.subKeys[subKey]); return accInner; }, {} as Record, boolean>, ), }; return acc; }, {} as HotkeyPressedMap); } 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); } }