Загрузка данных
export enum Keys {
'escape' = 'escape',
'delete' = 'delete',
'tab' = 'tab',
'shift' = 'shift',
'control' = 'control',
'meta' = 'meta',
'mod' = 'mod',
'alt' = 'alt',
't' = 'keyt',
'h' = 'keyh',
'v' = 'keyv',
'f' = 'keyf',
'r' = 'keyr',
'y' = 'keyy',
'z' = 'keyz',
'c' = 'keyc',
}
type HotkeyCallback = () => void | Promise<void>;
type UnregisterHotkey = () => void;
interface RegisterHotkeyParams {
keys: readonly Keys[];
callback: HotkeyCallback;
}
interface HotkeyRegistration {
callback: HotkeyCallback;
}
export interface IHotkeys {
register(params: RegisterHotkeyParams): UnregisterHotkey;
destroy(): void;
}
// todo: возможно можно сделать синглтоном и использовать импортируя экземпляр класса,
// там где это нужно вместо props drilling, как сейчас
export class Hotkeys implements IHotkeys {
private registrations = new Map<string, HotkeyRegistration[]>();
constructor() {
document.addEventListener('keydown', this.handleKeyDown);
}
public register({ keys, callback }: RegisterHotkeyParams): UnregisterHotkey {
validateKeys(keys);
const registration: HotkeyRegistration = {
callback,
};
const shortcuts = getRegistrationShortcuts(keys);
for (const shortcut of shortcuts) {
const registrations = this.registrations.get(shortcut);
if (registrations) {
registrations.push(registration);
} else {
this.registrations.set(shortcut, [registration]);
}
}
return () => {
for (const shortcut of shortcuts) {
const registrations = this.registrations.get(shortcut);
if (!registrations) {
continue;
}
const registrationIndex = registrations.lastIndexOf(registration);
if (registrationIndex !== -1) {
registrations.splice(registrationIndex, 1);
}
if (registrations.length === 0) {
this.registrations.delete(shortcut);
}
}
};
}
public destroy(): void {
document.removeEventListener('keydown', this.handleKeyDown);
this.registrations.clear();
}
private handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
if (event.repeat || isEditableElement(event.target)) {
return;
}
const registrations = this.registrations.get(getEventShortcut(event));
const registration = registrations?.[registrations.length - 1];
if (!registration) {
return;
}
event.preventDefault();
await registration.callback();
};
}
function validateKeys(keys: readonly Keys[]): void {
if (keys.length === 0) {
throw new Error('[Hotkeys] попытка задать пустой хоткей');
}
let primaryKeysCount = 0;
let hasMod = false;
let hasControl = false;
let hasMeta = false;
for (const key of keys) {
if (!isModifier(key)) {
primaryKeysCount += 1;
}
if (key === Keys.mod) {
hasMod = true;
}
if (key === Keys.control) {
hasControl = true;
}
if (key === Keys.meta) {
hasMeta = true;
}
}
if (primaryKeysCount > 1) {
throw new Error('[Hotkeys] хоткей может содержать только одну основную клавишу');
}
if (hasMod && (hasControl || hasMeta)) {
throw new Error('[Hotkeys] mod нельзя использовать вместе с control или meta');
}
}
function getRegistrationShortcuts(keys: readonly Keys[]): string[] {
if (!keys.includes(Keys.mod)) {
return [createShortcut(keys)];
}
const keysWithoutMod = keys.filter((key) => key !== Keys.mod);
return [
createShortcut([...keysWithoutMod, Keys.control]),
createShortcut([...keysWithoutMod, Keys.meta]),
createShortcut([...keysWithoutMod, Keys.control, Keys.meta]),
];
}
function getEventShortcut(event: KeyboardEvent): string {
const key = normalizeCode(event.code);
return buildShortcut(
event.ctrlKey,
event.metaKey,
event.shiftKey,
event.altKey,
isModifier(key) ? '' : key,
);
}
function createShortcut(keys: readonly Keys[]): string {
let control = false;
let meta = false;
let shift = false;
let alt = false;
let primaryKey: Keys | '' = '';
for (const key of keys) {
switch (key) {
case Keys.control:
control = true;
break;
case Keys.meta:
meta = true;
break;
case Keys.shift:
shift = true;
break;
case Keys.alt:
alt = true;
break;
case Keys.mod:
break;
default:
primaryKey = key;
}
}
return buildShortcut(control, meta, shift, alt, primaryKey);
}
function buildShortcut(
control: boolean,
meta: boolean,
shift: boolean,
alt: boolean,
primaryKey: Keys | '',
): string {
let shortcut = '';
if (control) {
shortcut = Keys.control;
}
if (meta) {
shortcut += `${shortcut ? '+' : ''}${Keys.meta}`;
}
if (shift) {
shortcut += `${shortcut ? '+' : ''}${Keys.shift}`;
}
if (alt) {
shortcut += `${shortcut ? '+' : ''}${Keys.alt}`;
}
if (primaryKey) {
shortcut += `${shortcut ? '+' : ''}${primaryKey}`;
}
return shortcut;
}
function isModifier(key: Keys): boolean {
return key === Keys.mod || key === Keys.control || key === Keys.meta || key === Keys.shift || key === Keys.alt;
}
function normalizeCode(code: string): Keys {
switch (code.toLowerCase()) {
case 'controlleft':
case 'controlright':
return Keys.control;
case 'metaleft':
case 'metaright':
return Keys.meta;
case 'shiftleft':
case 'shiftright':
return Keys.shift;
case 'altleft':
case 'altright':
return Keys.alt;
default:
return code.toLowerCase() as Keys;
}
}
function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return target.isContentEditable || target.closest('input, textarea, select, [contenteditable]') !== null;
}