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


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