Загрузка данных
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 {
callback: HotkeyCallback;
pressHoldRequired: boolean;
}
interface HotkeyEntry {
keys: Keys[];
callbacks: Map<string, HotkeyRegistration>;
}
export interface IHotkeys {
register(params: RegisterHotkeyParams): string | null;
unregister(params: UnregisterHotkeyParams): void;
}
// todo: возможно можно сделать синглтоном и использовать импортируя эк земпляр класса,
// там где это нужно вместо props drilling, как сейчас
export class Hotkeys implements IHotkeys {
private hotkeys = new Map<string, HotkeyEntry>();
private activeHoldHotkeys = 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 normalizedKeys = normalizeKeys(keys);
const shortcut = getShortcut(normalizedKeys);
const entry = this.hotkeys.get(shortcut) ?? {
keys: normalizedKeys,
callbacks: new Map(),
};
const hash = `hotkeyCallback-${crypto.randomUUID()}`;
entry.callbacks.set(hash, {
callback,
pressHoldRequired,
});
this.hotkeys.set(shortcut, entry);
return hash;
}
public unregister({
keys,
hash,
}: UnregisterHotkeyParams): void {
if (!hash) {
return;
}
const shortcut = getShortcut(keys);
const entry = this.hotkeys.get(shortcut);
if (!entry) {
return;
}
entry.callbacks.delete(hash);
if (entry.callbacks.size > 0) {
return;
}
this.hotkeys.delete(shortcut);
this.activeHoldHotkeys.delete(shortcut);
}
public destroy(): void {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
this.hotkeys.clear();
this.activeHoldHotkeys.clear();
}
private handleKeyDown = async (
event: KeyboardEvent,
): Promise<void> => {
if (event.repeat || isEditableElement(event.target)) {
return;
}
for (const [shortcut, entry] of this.hotkeys) {
if (!matchesShortcut(event, entry.keys)) {
continue;
}
event.preventDefault();
const registrations = [...entry.callbacks.values()].reverse();
if (
registrations.some(
({ pressHoldRequired }) => pressHoldRequired,
)
) {
this.activeHoldHotkeys.add(shortcut);
}
for (const { callback } of registrations) {
// eslint-disable-next-line no-await-in-loop
await callback();
}
return;
}
};
private handleKeyUp = async (
event: KeyboardEvent,
): Promise<void> => {
if (this.activeHoldHotkeys.size === 0) {
return;
}
const releasedKey = normalizeCode(event.code);
const shouldRelease = [...this.activeHoldHotkeys].some(
(shortcut) => {
const entry = this.hotkeys.get(shortcut);
return entry?.keys.some(
(key) => matchesReleasedKey(key, releasedKey),
);
},
);
if (!shouldRelease) {
return;
}
this.activeHoldHotkeys.clear();
const escapeEntry = this.hotkeys.get(
getShortcut([Keys.escape]),
);
if (!escapeEntry) {
return;
}
const callbacks = [
...escapeEntry.callbacks.values(),
].reverse();
for (const { callback } of callbacks) {
// eslint-disable-next-line no-await-in-loop
await callback();
}
};
}
const modifierOrder: Partial<Record<Keys, number>> = {
[Keys.mod]: 0,
[Keys.control]: 1,
[Keys.meta]: 2,
[Keys.shift]: 3,
[Keys.alt]: 4,
};
function normalizeKeys(keys: Keys[]): Keys[] {
return [...new Set(keys)].sort((left, right) => {
const leftOrder = modifierOrder[left] ?? 10;
const rightOrder = modifierOrder[right] ?? 10;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.localeCompare(right);
});
}
function getShortcut(keys: Keys[]): string {
return normalizeKeys(keys).join('+');
}
function matchesShortcut(
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 {
if (keys.includes(eventKey)) {
return true;
}
return (
keys.includes(Keys.mod) &&
(
eventKey === Keys.control ||
eventKey === Keys.meta
)
);
}
function matchesReleasedKey(
registeredKey: Keys,
releasedKey: Keys,
): boolean {
if (registeredKey === releasedKey) {
return true;
}
return (
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
);
}