diff --git a/.npnignore b/.npnignore index aad6693..0b7a66d 100644 --- a/.npnignore +++ b/.npnignore @@ -1,2 +1,4 @@ /bin/ -boostrapReactMobile.ts + +#idea-ide +/.idea/* diff --git a/bin/release.sh b/bin/release.sh index e05415f..105226e 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -3,7 +3,7 @@ # Exit when a command fails set -e -REPOSITORY=git@github.com:Ainias/js-helper.git +REPOSITORY=ainias@git.silas.link:Ainias/hotkeys.git if [[ -z "$1" ]]; then echo "versioname not given!" diff --git a/src/AffectedEnum.ts b/src/AffectedEnum.ts deleted file mode 100644 index 9b0eca8..0000000 --- a/src/AffectedEnum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum AffectedEnum { - NOT_FULL_FILLED = 0, - FULL_FILLED = 1, - FULL_FILLED_AND_CHANGED, -} diff --git a/src/HotkeyConfigWithOptionals.ts b/src/HotkeyConfigWithOptionals.ts new file mode 100644 index 0000000..ee051d5 --- /dev/null +++ b/src/HotkeyConfigWithOptionals.ts @@ -0,0 +1,5 @@ +import { HotkeyEntry } from './HotkeyEntry'; + +export type HotkeyConfigWithOptionals>> = { + [key in keyof HotkeyConfig]: Partial & Pick; +}; diff --git a/src/HotkeyDefinitionType.ts b/src/HotkeyDefinitionType.ts index ece5d64..15d4483 100644 --- a/src/HotkeyDefinitionType.ts +++ b/src/HotkeyDefinitionType.ts @@ -4,5 +4,4 @@ export const specialKeys = ['meta', 'control', 'alt', 'shift', 'tab'] as const; export type HotkeyDefinitionType = { keys: string[]; - ignoreFormElements?: boolean; -} & URecord<(typeof specialKeys)[number], boolean>; +} & Partial>; diff --git a/src/HotkeyEntry.ts b/src/HotkeyEntry.ts new file mode 100644 index 0000000..74c50ea --- /dev/null +++ b/src/HotkeyEntry.ts @@ -0,0 +1,8 @@ +import { HotkeyDefinitionType } from './HotkeyDefinitionType'; +import { HotkeyListener } from './HotkeyListener'; + +export type HotkeyEntry = { + keys: HotkeyDefinitionType[]; + subKeys: { [key in SubKeys]: HotkeyDefinitionType[] }; + callbacks: HotkeyListener[]; +}; diff --git a/src/HotkeyListener.ts b/src/HotkeyListener.ts index 6e0a409..8675c98 100644 --- a/src/HotkeyListener.ts +++ b/src/HotkeyListener.ts @@ -1,9 +1,7 @@ -import { AffectedEnum } from './AffectedEnum'; - export type HotkeyListenerEvent = { event: KeyboardEvent; - type: 'keydown' | 'keyup'; - subKeys: Record; + subKeys: Record; + isPressed: boolean; }; export type HotkeyListener = (ev: HotkeyListenerEvent) => unknown; diff --git a/src/HotkeyManager.ts b/src/HotkeyManager.ts index aa0cc4b..eed1def 100644 --- a/src/HotkeyManager.ts +++ b/src/HotkeyManager.ts @@ -1,22 +1,28 @@ import { HotkeyDefinitionType, specialKeys } from './HotkeyDefinitionType'; import { HotkeyListener } from './HotkeyListener'; import { JsonHelper, ObjectHelper } from '@ainias42/js-helper'; -import { AffectedEnum } from './AffectedEnum'; - -type HotkeyEntry = { - keys: HotkeyDefinitionType[]; - subKeys: { [key in SubKeys]: HotkeyDefinitionType[] }; - callbacks: HotkeyListener[]; -}; +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 ignoreFormElements = true; - constructor(hotkeys: HotkeyConfig) { - this.hotkeys = hotkeys; - this.addKeyListeners(); + constructor(hotkeys: HotkeyConfigWithOptionals, ignoreFormElements?: boolean) { + this.hotkeys = ObjectHelper.entries(hotkeys).reduce((acc, [key, value]) => { + acc[key] = { subKeys: {}, callbacks: [], ...value } as unknown as HotkeyConfig[typeof key]; + return acc; + }, {} as HotkeyConfig); + this.hotkeysPressedMap = this.generateHotkeyPressedMap(); + + if (ignoreFormElements !== undefined) { + this.ignoreFormElements = ignoreFormElements; + } } private static isFormElement(element: EventTarget | null) { @@ -31,9 +37,20 @@ export class HotkeyManager 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) + ); + } + addListener( hotkeyName: HotkeyName, - callback: HotkeyListener, + callback: HotkeyListener>, ) { const { callbacks } = this.hotkeys[hotkeyName]; callbacks.push(callback); @@ -67,105 +84,148 @@ export class HotkeyManager( hotkey: HotkeyName, - subkey: keyof HotkeyConfig[HotkeyName]['subKeys'], + subKey: SubKeyType, definition: HotkeyDefinitionType, ) { - this.hotkeys[hotkey].subKeys[subkey as string].push(definition); + this.hotkeys[hotkey].subKeys[subKey as string].push(definition); } removeSubKeyDefinition( hotkey: HotkeyName, - subkey: keyof HotkeyConfig[HotkeyName]['subKeys'], + subKey: SubKeyType, definition: HotkeyDefinitionType, ) { const { subKeys } = this.hotkeys[hotkey]; - subKeys[subkey as string] = subKeys[subkey as string].filter((key) => !HotkeyManager.isEqual(key, definition)); + subKeys[subKey as string] = subKeys[subKey as string].filter((key) => !HotkeyManager.isEqual(key, definition)); } setSubKeyDefinitions( hotkey: HotkeyName, - subkey: keyof HotkeyConfig[HotkeyName]['subKeys'], + subKey: SubKeyType, definitions: HotkeyDefinitionType[], ) { - this.hotkeys[hotkey].subKeys[subkey as string] = definitions; + this.hotkeys[hotkey].subKeys[subKey as string] = definitions; } getConfig() { return this.hotkeys; } - private addKeyListeners() { + addKeyListeners() { window.addEventListener('keydown', (e) => { + if (this.ignoreFormElements && HotkeyManager.isFormElement(e.target)) { + return; + } + this.keyPressedMap.set(e.key.toLowerCase(), true); - this.checkHotkeys(e, 'keydown'); + this.checkHotkeys(e); }); window.addEventListener('keyup', (e) => { - // Check first as afterwards the keys are not set - this.checkHotkeys(e, 'keyup'); - this.keyPressedMap.set(e.key.toLowerCase(), false); + // TODO check if meta key is still working correctly + if (this.ignoreFormElements && HotkeyManager.isFormElement(e.target)) { + return; + } + if (e.key === 'Meta') { - this.keyPressedMap.clear(); + this.clearMap(e); + } else { + this.keyPressedMap.set(e.key.toLowerCase(), false); + this.checkHotkeys(e); } }); } - private checkHotkeyDefinition(keyDefinition: HotkeyDefinitionType, event: KeyboardEvent): AffectedEnum { + 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 AffectedEnum.NOT_FULL_FILLED; - } - if (keyDefinition.ignoreFormElements !== false && HotkeyManager.isFormElement(event.target)) { - return AffectedEnum.NOT_FULL_FILLED; + return false; } if (keyDefinition.keys.some((key) => this.keyPressedMap.get(key) !== true)) { - return AffectedEnum.NOT_FULL_FILLED; + return false; } - if ( - specialKeys.some( - (key) => keyDefinition[key] !== undefined && this.keyPressedMap.get(key) !== keyDefinition[key], - ) - ) { - return AffectedEnum.NOT_FULL_FILLED; - } - - // Check if key is inside keyDefinition - const changedKey = event.key.toLowerCase(); - if (!keyDefinition.keys.includes(changedKey) && !(changedKey in keyDefinition)) { - return AffectedEnum.FULL_FILLED; - } - - return AffectedEnum.FULL_FILLED_AND_CHANGED; + return !specialKeys.some( + (key) => keyDefinition[key] !== undefined && !!this.keyPressedMap.get(key) !== keyDefinition[key], + ); } - private getKeyReducer(event: KeyboardEvent) { - return (acc: AffectedEnum, key: HotkeyDefinitionType) => { - if (acc === AffectedEnum.FULL_FILLED_AND_CHANGED) { - return acc; - } - return Math.max(acc, this.checkHotkeyDefinition(key, event)); - }; - } - - private checkHotkeys(event: KeyboardEvent, type: 'keydown' | 'keyup') { - ObjectHelper.values(this.hotkeys).forEach((hotkey) => { - const isAffected = hotkey.keys.reduce(this.getKeyReducer(event), AffectedEnum.NOT_FULL_FILLED); - if (isAffected !== AffectedEnum.NOT_FULL_FILLED) { - const subKeys = ObjectHelper.entries(hotkey.subKeys).reduce( - (acc, [key, value]) => { - acc[key] = value.reduce(this.getKeyReducer(event), AffectedEnum.NOT_FULL_FILLED); - return acc; + 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, - ); + {} as Record, boolean>, + ), + }; + return acc; + }, {} as HotkeyPressedMap); + } - if ( - isAffected === AffectedEnum.FULL_FILLED_AND_CHANGED || - ObjectHelper.values(subKeys).some((value) => value === AffectedEnum.FULL_FILLED_AND_CHANGED) - ) { - hotkey.callbacks.forEach((callback) => callback({ event, subKeys, type })); - } + 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); } } diff --git a/src/HotkeyPressedMap.ts b/src/HotkeyPressedMap.ts new file mode 100644 index 0000000..9fdc393 --- /dev/null +++ b/src/HotkeyPressedMap.ts @@ -0,0 +1,11 @@ +import { HotkeyEntry } from './HotkeyEntry'; +import { SubKeyType } from './SubKeyType'; + +export type HotkeyPressedMap>> = { + [key in keyof HotkeyConfig]: { + isPressed: boolean; + subKeys: { + [subKey in SubKeyType]: boolean; + }; + }; +}; diff --git a/src/SubKeyType.ts b/src/SubKeyType.ts new file mode 100644 index 0000000..7532e3b --- /dev/null +++ b/src/SubKeyType.ts @@ -0,0 +1,7 @@ +import { HotkeyEntry } from './HotkeyEntry'; +import { HotkeyConfigWithOptionals } from './HotkeyConfigWithOptionals'; + +export type SubKeyType< + HotkeyConfig extends HotkeyConfigWithOptionals>>, + HotkeyName extends keyof HotkeyConfig, +> = keyof HotkeyConfig[HotkeyName]['subKeys'];