Загрузка данных
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',
'z' = 'keyz',
'c' = 'keyc',
'mousedown' = 'mousedown',
}
type HotkeyCallback = () => void | Promise<void>;
interface RegisterHotkeyParams {
keys: Keys[];
callback: HotkeyCallback;
pressHoldRequired?: boolean;
}
interface UnregisterHotkeyParams {
keys: Keys[];
hash?: string | null;
}
interface HotkeyRegistration {
keys: Keys[];
callback: HotkeyCallback;
pressHoldRequired: boolean;
}
export interface IHotkeys {
register(params: RegisterHotkeyParams): string | null;
unregister(params: UnregisterHotkeyParams): void;
}
// todo: возможно можно сделать синглтоном и использовать импортируя экземпляр класса,
// там где это нужно вместо props drilling, как сейчас
export class Hotkeys implements IHotkeys {
private registrations = new Map<string, HotkeyRegistration>();
private activeHoldRegistrations = new Set<string>();
constructor() {
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
}
public register({
keys,
callback,
pressHoldRequired = false,
}: RegisterHotkeyParams): string | null {
if (keys.length === 0) {
console.error('[Hotkeys] попытка задать пустой хоткей');
return null;
}
const hash = `hotkeyCallback-${crypto.randomUUID()}`;
this.registrations.set(hash, {
keys,
callback,
pressHoldRequired,
});
return hash;
}
public unregister({ hash }: UnregisterHotkeyParams): void {
if (!hash) {
return;
}
this.registrations.delete(hash);
this.activeHoldRegistrations.delete(hash);
}
public destroy(): void {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
this.registrations.clear();
this.activeHoldRegistrations.clear();
}
private handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
if (event.repeat || isEditableElement(event.target)) {
return;
}
const registrations = [...this.registrations.entries()]
.filter(([, registration]) => matchesHotkey(event, registration.keys))
.reverse();
if (registrations.length === 0) {
return;
}
event.preventDefault();
for (const [hash, registration] of registrations) {
if (registration.pressHoldRequired) {
this.activeHoldRegistrations.add(hash);
}
// eslint-disable-next-line no-await-in-loop
await registration.callback();
}
};
private handleKeyUp = async (event: KeyboardEvent): Promise<void> => {
if (this.activeHoldRegistrations.size === 0) {
return;
}
const releasedKey = normalizeCode(event.code);
let shouldReset = false;
for (const hash of this.activeHoldRegistrations) {
const registration = this.registrations.get(hash);
if (!registration) {
this.activeHoldRegistrations.delete(hash);
continue;
}
if (!registration.keys.some((key) => matchesReleasedKey(key, releasedKey))) {
continue;
}
this.activeHoldRegistrations.delete(hash);
shouldReset = true;
}
if (!shouldReset) {
return;
}
const escapeCallbacks = [...this.registrations.values()]
.filter(({ keys }) => keys.length === 1 && keys[0] === Keys.escape)
.map(({ callback }) => callback)
.reverse();
for (const callback of escapeCallbacks) {
// eslint-disable-next-line no-await-in-loop
await callback();
}
};
}
function matchesHotkey(event: KeyboardEvent, keys: Keys[]): boolean {
const eventKey = normalizeCode(event.code);
const primaryKeys = keys.filter((key) => !isModifier(key));
if (primaryKeys.length > 1) {
return false;
}
if (primaryKeys.length === 1 && primaryKeys[0] !== eventKey) {
return false;
}
if (primaryKeys.length === 0 && !matchesModifierKey(keys, eventKey)) {
return false;
}
if (keys.includes(Keys.mod)) {
if (!event.ctrlKey && !event.metaKey) {
return false;
}
} else if (event.ctrlKey !== keys.includes(Keys.control) || event.metaKey !== keys.includes(Keys.meta)) {
return false;
}
return event.shiftKey === keys.includes(Keys.shift) && event.altKey === keys.includes(Keys.alt);
}
function matchesModifierKey(keys: Keys[], eventKey: Keys): boolean {
return (
keys.includes(eventKey) ||
(keys.includes(Keys.mod) && (eventKey === Keys.control || eventKey === Keys.meta))
);
}
function matchesReleasedKey(registeredKey: Keys, releasedKey: Keys): boolean {
return (
registeredKey === releasedKey ||
(registeredKey === Keys.mod && (releasedKey === Keys.control || releasedKey === Keys.meta))
);
}
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;
}