Загрузка данных


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;
}

interface UnregisterHotkeyParams {
  keys: Keys[];
  hash?: string | null;
}

interface HotkeyRegistration {
  keys: Keys[];
  callback: HotkeyCallback;
}

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>();

  constructor() {
    document.addEventListener('keydown', this.handleKeyDown);
  }

  public register({ keys, callback }: RegisterHotkeyParams): string | null {
    if (keys.length === 0) {
      console.error('[Hotkeys] попытка задать пустой хоткей');

      return null;
    }

    const hash = `hotkeyCallback-${crypto.randomUUID()}`;

    this.registrations.set(hash, {
      keys,
      callback,
    });

    return hash;
  }

  public unregister({ hash }: UnregisterHotkeyParams): void {
    if (!hash) {
      return;
    }

    this.registrations.delete(hash);
  }

  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 callbacks = [...this.registrations.values()]
      .filter(({ keys }) => matchesHotkey(event, keys))
      .map(({ callback }) => callback)
      .reverse();

    if (callbacks.length === 0) {
      return;
    }

    event.preventDefault();

    for (const callback of callbacks) {
      // 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 || primaryKeys[0] !== 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 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;
}