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


export enum Keys {
  'escape' = 'escape',
  'delete' = 'delete',
  'tab' = 'tab',
  'shift' = 'shiftleft',
  'control' = 'controlleft',
  'meta' = 'metaleft',
  'alt' = 'altleft',
  't' = 'keyt',
  'h' = 'keyh',
  'v' = 'keyv',
  'f' = 'keyf',
  'z' = 'keyz',
  'y' = 'keyy',
  'c' = 'keyc',
  'mousedown' = 'mousedown',
}

type HotkeyCallback = () => void | Promise<void>;

interface RegisterHotkeyParams {
  keys: Keys[];
  callback: HotkeyCallback;
  pressHoldRequired?: boolean;
}

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

export interface IHotkeys {
  register(params: RegisterHotkeyParams): string | null;
  unregister(params: UnregisterHotkeyParams): void;
}

type Tree = {
  children: Map<Keys, Tree>;
  callbacks: Map<string, HotkeyCallback> | null;
  pressHoldRequired?: boolean;
};

// todo: возможно можно сделать синглтоном и использовать импортируя эк земпляр класса,
//  там где это нужно вместо props drilling, как сейчас

export class Hotkeys implements IHotkeys {
  private tree: Tree = createTree();

  private buffer: Keys[] = [];
  private prevKey: Keys | null = null;

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

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

    let node = this.tree;

    for (const key of keys) {
      const child = node.children.get(normalize(key));

      if (!child) {
        return;
      }

      node = child;
    }

    node.callbacks?.delete(hash);

    if (node.callbacks?.size === 0) {
      node.callbacks = null;
    }
  }

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

    let node = this.tree;

    keys.forEach((key) => {
      const normalizedKey = normalize(key);

      if (!node.children.has(normalizedKey)) {
        node.children.set(normalizedKey, createTree(pressHoldRequired));
      }

      node = node.children.get(normalizedKey) as Tree;
    });

    if (!node.callbacks) {
      node.callbacks = new Map();
    }

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

    node.callbacks.set(hash, callback);

    return hash;
  }

  public destroy(): void {
    document.removeEventListener('keydown', this.handleKeyDown);
    document.removeEventListener('keyup', this.handleKeyUp);

    this.tree = createTree();
    this.buffer = [];
    this.prevKey = null;
  }

  private findNode(keys: Keys[]): Tree | null {
    let node = this.tree;

    for (const key of keys) {
      const child = node.children.get(normalize(key));

      if (!child) {
        return null;
      }

      node = child;
    }

    return node;
  }

  private handleKeyUp = async (): Promise<void> => {
    const node = this.findNode(this.buffer);

    if (!node) {
      this.resetBuffer();
      return;
    }

    if (node.callbacks && node.pressHoldRequired) {
      await this.handleKeyDown({
        code: 'Escape',
        preventDefault: () => {},
      } as KeyboardEvent);
    }

    this.resetBuffer();
  };

  private handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
    if (isEditableElement(event.target)) {
      this.resetBuffer();
      return;
    }

    const key = normalize(event.code);

    if (key === this.prevKey) {
      return;
    }

    this.prevKey = key;

    if (key === Keys.escape) {
      this.buffer = [];
    }

    this.buffer.push(key);

    const node = this.findNode(this.buffer);

    if (!node) {
      this.resetBuffer();
      return;
    }

    if (!node.callbacks || node.children.size > 0) {
      return;
    }

    event.preventDefault();

    const callbacks = [...node.callbacks.values()].reverse();

    for (const callback of callbacks) {
      // eslint-disable-next-line no-await-in-loop
      await callback();
    }

    this.resetBuffer();
  };

  private resetBuffer(): void {
    this.buffer = [];
    this.prevKey = null;
  }
}

function createTree(pressHoldRequired = false): Tree {
  return {
    children: new Map(),
    callbacks: null,
    pressHoldRequired,
  };
}

function normalize(key: string): Keys {
  switch (key.toLowerCase()) {
    case 'controlright':
      return Keys.control;

    case 'shiftright':
      return Keys.shift;

    case 'metaright':
      return Keys.meta;

    case 'altright':
      return Keys.alt;

    default:
      return key.toLowerCase() as Keys;
  }
}

function isEditableElement(target: EventTarget | null): boolean {
  if (!(target instanceof HTMLElement)) {
    return false;
  }

  return (
    target.isContentEditable ||
    target instanceof HTMLInputElement ||
    target instanceof HTMLTextAreaElement ||
    target instanceof HTMLSelectElement
  );
}



private registerUndoRedoHotkeys(): void {
  const undoRedo = this.eventManager.getUndoRedo();

  const undo = () => {
    undoRedo.undo();
  };

  const redo = () => {
    undoRedo.redo();
  };

  const hotkeys = [
    {
      keys: [Keys.control, Keys.z],
      callback: undo,
    },
    {
      keys: [Keys.meta, Keys.z],
      callback: undo,
    },
    {
      keys: [Keys.control, Keys.shift, Keys.z],
      callback: redo,
    },
    {
      keys: [Keys.shift, Keys.control, Keys.z],
      callback: redo,
    },
    {
      keys: [Keys.meta, Keys.shift, Keys.z],
      callback: redo,
    },
    {
      keys: [Keys.shift, Keys.meta, Keys.z],
      callback: redo,
    },
    {
      keys: [Keys.control, Keys.y],
      callback: redo,
    },
    {
      keys: [Keys.meta, Keys.y],
      callback: redo,
    },
  ];

  this.undoRedoHotkeys = hotkeys.map(({ keys, callback }) => ({
    keys,
    hash: this.hotkeys.register({
      keys,
      callback,
    }),
  }));
}

private unregisterUndoRedoHotkeys(): void {
  this.undoRedoHotkeys.forEach(({ keys, hash }) => {
    this.hotkeys.unregister({
      keys,
      hash,
    });
  });

  this.undoRedoHotkeys = [];
}