This commit is contained in:
sguenter 2023-10-07 13:51:36 +02:00
parent 17c0bfcc36
commit 6a166d2d5d
10 changed files with 167 additions and 82 deletions

View File

@ -1,2 +1,4 @@
/bin/
boostrapReactMobile.ts
#idea-ide
/.idea/*

View File

@ -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!"

View File

@ -1,5 +0,0 @@
export enum AffectedEnum {
NOT_FULL_FILLED = 0,
FULL_FILLED = 1,
FULL_FILLED_AND_CHANGED,
}

View File

@ -0,0 +1,5 @@
import { HotkeyEntry } from './HotkeyEntry';
export type HotkeyConfigWithOptionals<HotkeyConfig extends Record<string, HotkeyEntry<string>>> = {
[key in keyof HotkeyConfig]: Partial<HotkeyConfig[key]> & Pick<HotkeyConfig[key], 'keys'>;
};

View File

@ -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<URecord<(typeof specialKeys)[number], boolean>>;

8
src/HotkeyEntry.ts Normal file
View File

@ -0,0 +1,8 @@
import { HotkeyDefinitionType } from './HotkeyDefinitionType';
import { HotkeyListener } from './HotkeyListener';
export type HotkeyEntry<SubKeys extends string> = {
keys: HotkeyDefinitionType[];
subKeys: { [key in SubKeys]: HotkeyDefinitionType[] };
callbacks: HotkeyListener<SubKeys>[];
};

View File

@ -1,9 +1,7 @@
import { AffectedEnum } from './AffectedEnum';
export type HotkeyListenerEvent<SubKeys extends string | symbol | number> = {
event: KeyboardEvent;
type: 'keydown' | 'keyup';
subKeys: Record<SubKeys, AffectedEnum>;
subKeys: Record<SubKeys, boolean>;
isPressed: boolean;
};
export type HotkeyListener<SubKeys extends string | symbol | number> = (ev: HotkeyListenerEvent<SubKeys>) => unknown;

View File

@ -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<SubKeys extends string> = {
keys: HotkeyDefinitionType[];
subKeys: { [key in SubKeys]: HotkeyDefinitionType[] };
callbacks: HotkeyListener<SubKeys>[];
};
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 ignoreFormElements = true;
constructor(hotkeys: HotkeyConfig) {
this.hotkeys = hotkeys;
this.addKeyListeners();
constructor(hotkeys: HotkeyConfigWithOptionals<HotkeyConfig>, 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<HotkeyConfig extends Record<string, HotkeyEntry<strin
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)
);
}
addListener<HotkeyName extends keyof HotkeyConfig>(
hotkeyName: HotkeyName,
callback: HotkeyListener<keyof HotkeyConfig[HotkeyName]['subKeys']>,
callback: HotkeyListener<SubKeyType<HotkeyConfig, HotkeyName>>,
) {
const { callbacks } = this.hotkeys[hotkeyName];
callbacks.push(callback);
@ -67,105 +84,148 @@ export class HotkeyManager<HotkeyConfig extends Record<string, HotkeyEntry<strin
addSubKeyDefinition<HotkeyName extends keyof HotkeyConfig>(
hotkey: HotkeyName,
subkey: keyof HotkeyConfig[HotkeyName]['subKeys'],
subKey: SubKeyType<HotkeyConfig, HotkeyName>,
definition: HotkeyDefinitionType,
) {
this.hotkeys[hotkey].subKeys[subkey as string].push(definition);
this.hotkeys[hotkey].subKeys[subKey as string].push(definition);
}
removeSubKeyDefinition<HotkeyName extends keyof HotkeyConfig>(
hotkey: HotkeyName,
subkey: keyof HotkeyConfig[HotkeyName]['subKeys'],
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));
subKeys[subKey as string] = subKeys[subKey as string].filter((key) => !HotkeyManager.isEqual(key, definition));
}
setSubKeyDefinitions<HotkeyName extends keyof HotkeyConfig>(
hotkey: HotkeyName,
subkey: keyof HotkeyConfig[HotkeyName]['subKeys'],
subKey: SubKeyType<HotkeyConfig, HotkeyName>,
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 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 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<HotkeyConfig, typeof key>] =
isPressed && this.checkHotkeyDefinitions(value.subKeys[subKey]);
return accInner;
},
{} as Record<string, AffectedEnum>,
);
{} as Record<SubKeyType<HotkeyConfig, typeof key>, boolean>,
),
};
return acc;
}, {} as HotkeyPressedMap<HotkeyConfig>);
}
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);
}
}

11
src/HotkeyPressedMap.ts Normal file
View File

@ -0,0 +1,11 @@
import { HotkeyEntry } from './HotkeyEntry';
import { SubKeyType } from './SubKeyType';
export type HotkeyPressedMap<HotkeyConfig extends Record<string, HotkeyEntry<string>>> = {
[key in keyof HotkeyConfig]: {
isPressed: boolean;
subKeys: {
[subKey in SubKeyType<HotkeyConfig, key>]: boolean;
};
};
};

7
src/SubKeyType.ts Normal file
View File

@ -0,0 +1,7 @@
import { HotkeyEntry } from './HotkeyEntry';
import { HotkeyConfigWithOptionals } from './HotkeyConfigWithOptionals';
export type SubKeyType<
HotkeyConfig extends HotkeyConfigWithOptionals<Record<string, HotkeyEntry<string>>>,
HotkeyName extends keyof HotkeyConfig,
> = keyof HotkeyConfig[HotkeyName]['subKeys'];