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


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