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


import {
  AutoscaleInfo,
  CrosshairMode,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitive,
  ISeriesPrimitiveAxisView,
  Logical,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';

import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';

import { SettingsTab, SettingsValues } from '@src/types';

export interface DrawingInteraction {
  selected$: Observable<boolean>;
  locked$: Observable<boolean>;

  isSelected(): boolean;
  isLocked(): boolean;

  select(): void;
  deselect(): void;

  beginChange(): void;
  commitChange(): void;
}

export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
  show(): void;
  hide(): void;
  rebind(series: ISeriesApi<SeriesType>): void;
  destroy(): void;

  waitTillReady(): Promise<void>;
  isCreationPending(): boolean;
  shouldShowInObjectTree(): boolean;

  getState(): unknown;
  setState(state: unknown): void;

  getSettings(): SettingsValues;
  getSettingsTabs(): SettingsTab[];
  updateSettings(settings: SettingsValues): void;

  subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;

  getRenderData(): unknown;
}

interface SeriesDrawingBaseParams {
  container: HTMLElement;
  chart: IChartApi;
  series: SeriesApi;
  interaction: DrawingInteraction;
}

export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
  protected hidden = false;
  protected chart: IChartApi;
  protected series: SeriesApi;
  protected subscriptions = new Subscription();
  protected abstract mode: unknown; // todo: хочется иметь единый mode
  protected abstract settings: TSettings;
  protected readonly container: HTMLElement;
  protected isBound = false;

  private readonly interaction: DrawingInteraction;
  private readonly settingsSubject = new Subject<SettingsValues>();
  private isInteractionBound = false;
  private isChangeStarted = false;

  protected readyPromise: Promise<void> | null = null;
  protected resolveReady: (() => void) | null = null;
  protected requestUpdate: (() => void) | null = null;

  constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
    this.chart = chart;
    this.series = series;
    this.container = container;
    this.interaction = interaction;
  }

  public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
    callback(this.getSettings());

    return this.settingsSubject.subscribe(callback);
  }

  public show(): void {
    this.hidden = false;
    this.render();
  }

  public hide(): void {
    this.hidden = true;
    this.showCrosshair();
    this.render();
  }

  public rebind(series: SeriesApi): void {
    if (this.series === series) {
      return;
    }

    this.showCrosshair();
    this.unbindEvents();
    this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);

    this.series = series;
    this.requestUpdate = null;

    this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
    this.render();
  }

  public destroy(): void {
    this.showCrosshair();
    this.unbindEvents();
    this.subscriptions.unsubscribe();
    this.settingsSubject.complete();
    this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
    this.requestUpdate = null;
    this.resolveReady?.();
    this.isChangeStarted = false;
  }

  public waitTillReady(): Promise<void> {
    if (this.mode === 'ready') {
      return Promise.resolve();
    }

    if (!this.readyPromise) {
      this.readyPromise = new Promise((resolve) => {
        this.resolveReady = resolve;
      });
    }

    return this.readyPromise;
  }

  public shouldShowInObjectTree(): boolean {
    return this.mode !== 'idle';
  }

  public getSettings(): SettingsValues {
    return { ...this.settings };
  }

  public updateSettings(settings: SettingsValues): void {
    this.settings = {
      ...this.settings,
      ...settings,
    };

    this.settingsSubject.next(this.getSettings());
    this.render();
  }

  public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
    this.requestUpdate = param.requestUpdate;
    this.bindInteraction();
    this.bindEvents();
  }

  public detached(): void {
    this.showCrosshair();
    this.unbindEvents();
    this.requestUpdate = null;
  }

  public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
    return null;
  }

  public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
    const hoveredItem = this.getHoveredItem(x, y);

    if (!hoveredItem || !this.isLocked()) {
      return hoveredItem;
    }

    return {
      ...hoveredItem,
      cursorStyle: 'pointer',
    };
  }

  public abstract getRenderData(): unknown; // todo: make proper type
  public abstract getState(): unknown;
  public abstract getSettingsTabs(): SettingsTab[];
  public abstract isCreationPending(): boolean;
  public abstract setState(state: unknown): void;
  public abstract updateAllViews(): void;
  public abstract paneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
  public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];

  protected isSelected(): boolean {
    return this.interaction.isSelected();
  }

  protected isLocked(): boolean {
    return this.interaction.isLocked();
  }

  protected select(): void {
    this.interaction.select();
  }

  protected deselect(): void {
    this.interaction.deselect();
  }

  protected shouldShowHandles(): boolean {
    return !this.isLocked() && (this.isSelected() || this.isCreationPending());
  }

  protected render(): void {
    this.updateAllViews();
    this.requestUpdate?.();
  }

  protected hideCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Hidden,
      },
    });
  }

  protected showCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Normal,
      },
    });
  }

  protected getEventPoint(event: PointerEvent): Point {
    return getPointerPointFromEvent(this.container, event);
  }

  protected bindEvents(): void {
    if (this.isBound) {
      return;
    }

    this.isBound = true;

    this.container.addEventListener('dblclick', this.handleDoubleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
    this.container.addEventListener('contextmenu', this.handleContextMenu);

    window.addEventListener('pointermove', this.handlePointerMove);
    window.addEventListener('pointerup', this.handlePointerUpEvent);
    window.addEventListener('pointercancel', this.handlePointerUpEvent);
  }

  protected unbindEvents(): void {
    if (!this.isBound) {
      return;
    }

    this.isBound = false;

    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
    this.container.removeEventListener('contextmenu', this.handleContextMenu);

    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUpEvent);
    window.removeEventListener('pointercancel', this.handlePointerUpEvent);
  }

  // todo: хочется общую реализацию для каждой кнопки
  protected handleContextMenu(event: MouseEvent): void {}
  protected handleDoubleClick(event: MouseEvent): void {}
  protected handlePointerDown(event: PointerEvent): void {}
  protected handlePointerMove(event: PointerEvent): void {}
  protected handlePointerUp(event: PointerEvent): void {}

  protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
  protected abstract getGeometry(): unknown; // todo: make proper type
  protected abstract getTimeAxisSegments(): AxisSegment[];
  protected abstract getPriceAxisSegments(): AxisSegment[];
  protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
  protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;

  private bindInteraction(): void {
    if (this.isInteractionBound) {
      return;
    }

    this.isInteractionBound = true;

    this.subscriptions.add(
      this.interaction.selected$.subscribe(() => {
        this.render();
      }),
    );

    this.subscriptions.add(
      this.interaction.locked$.subscribe((isLocked) => {
        if (isLocked) {
          this.showCrosshair();
        }

        this.render();
      }),
    );
  }

  private handlePointerDownEvent = (event: PointerEvent): void => {
    if (this.isLocked() && !this.isCreationPending() && event.button === 0) {
      const point = this.getEventPoint(event);
      const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;

      if (isDrawingHit) {
        this.select();

        return;
      }

      if (this.isSelected()) {
        this.deselect();
      }

      return;
    }

    if (this.shouldBeginChange(event)) {
      this.interaction.beginChange();
      this.isChangeStarted = true;
    }

    this.handlePointerDown(event);
  };

  private handlePointerUpEvent = (event: PointerEvent): void => {
    this.handlePointerUp(event);

    if (!this.isChangeStarted) {
      return;
    }

    this.isChangeStarted = false;
    this.interaction.commitChange();
  };

  private shouldBeginChange(event: PointerEvent): boolean {
    if (
      event.button !== 0 ||
      this.isLocked() ||
      this.isCreationPending() ||
      !this.isSelected()
    ) {
      return false;
    }

    const point = this.getEventPoint(event);

    return this.getHoveredItem(point.x, point.y) !== null;
  }
}


import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';

import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Hotkeys, Keys } from '@core/Hotkeys';

import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';

import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { DrawingSnapshot } from '@core/Drawings/snapshot';

type IDrawing = DOMObject;

interface DrawingParams extends DOMObjectParams {
  drawingName: DrawingsNames;
  lwcChart: IChartApi;
  mainSeries: SeriesStrategies;
  onDelete: (id: string) => void;
  construct: (
    chart: IChartApi,
    series: ISeriesApi<SeriesType>,
    interaction: DrawingInteraction,
  ) => ISeriesDrawing;
  selected$: Observable<boolean>;
  isSelected: () => boolean;
  select: () => void;
  deselect: () => void;
  beginChange: () => void;
  commitChange: () => void;
  isLocked?: boolean;
  hotkeys: Hotkeys;
  setCopyPasteBuffer: (snapshot: DrawingSnapshot) => void;
  resetActiveTool: () => void;
}

export class Drawing extends DOMObject implements IDrawing {
  private lwcDrawing: ISeriesDrawing;
  private mainSeries: SeriesStrategies;
  private drawingName: DrawingsNames;
  private hotkeys: Hotkeys;
  private lockedSubject: BehaviorSubject<boolean>;
  private settingsSubject: BehaviorSubject<SettingsValues>;
  private subscriptions = new Subscription();

  private escapeUnregisterHash: string | null = null;
  private deleteUnregisterHash: string | null = null;
  private copyUnregisterHash: string | null = null;

  constructor({
    lwcChart,
    name,
    mainSeries,
    drawingName,
    id,
    onDelete,
    zIndex,
    moveUp,
    moveDown,
    construct,
    selected$,
    isSelected,
    select,
    deselect,
    beginChange,
    commitChange,
    isLocked = false,
    paneId,
    hotkeys,
    setCopyPasteBuffer,
    resetActiveTool,
  }: DrawingParams) {
    super({
      id,
      name,
      zIndex,
      onDelete,
      moveUp,
      moveDown,
      paneId,
    });

    this.hotkeys = hotkeys;
    this.mainSeries = mainSeries;
    this.drawingName = drawingName;
    this.lockedSubject = new BehaviorSubject(isLocked);

    const interaction: DrawingInteraction = {
      selected$,
      locked$: this.lockedSubject.asObservable(),
      isSelected,
      isLocked: () => this.lockedSubject.value,
      select,
      deselect,
      beginChange,
      commitChange,
    };

    this.lwcDrawing = construct(lwcChart, mainSeries, interaction);
    this.settingsSubject = new BehaviorSubject(this.lwcDrawing.getSettings());

    this.subscriptions.add(
      this.lwcDrawing.subscribeSettings((settings) => {
        this.settingsSubject.next(settings);
      }),
    );

    this.onDelete = onDelete;

    this.escapeUnregisterHash = hotkeys.register({
      keys: [Keys.escape],
      callback: () => {
        this.delete();
        resetActiveTool();
      },
    });

    this.subscriptions.add(
      selected$.subscribe((isSelectedDrawing) => {
        if (isSelectedDrawing) {
          this.registerSelectedDrawingHotkeys(setCopyPasteBuffer);

          return;
        }

        this.unregisterSelectedDrawingHotkeys();
      }),
    );

    this.afterCreation(() => {
      hotkeys.unregister({
        keys: [Keys.escape],
        hash: this.escapeUnregisterHash,
      });

      this.escapeUnregisterHash = null;
    });
  }

  public delete(): void {
    super.delete();
  }

  public getDrawingName(): DrawingsNames {
    return this.drawingName;
  }

  public getLwcDrawing(): ISeriesDrawing {
    return this.lwcDrawing;
  }

  public show(): void {
    this.lwcDrawing.show();
    super.show();
  }

  public hide(): void {
    this.lwcDrawing.hide();
    super.hide();
  }

  public rebind = (nextMainSeries: SeriesStrategies): void => {
    this.lwcDrawing.rebind(nextMainSeries);
    this.mainSeries = nextMainSeries;
  };

  public isCreationPending(): boolean {
    return this.lwcDrawing.isCreationPending();
  }

  public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
    return this.lockedSubject.subscribe(callback);
  }

  public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
    return this.settingsSubject.subscribe(callback);
  }

  public isLocked(): boolean {
    return this.lockedSubject.value;
  }

  public setLocked(isLocked: boolean): void {
    if (this.lockedSubject.value === isLocked) {
      return;
    }

    this.lockedSubject.next(isLocked);
  }

  public toggleLock(): void {
    this.setLocked(!this.isLocked());
  }

  public async waitForCreation(): Promise<void> {
    return this.lwcDrawing.waitTillReady();
  }

  public shouldShowInObjectTree(): boolean {
    return this.lwcDrawing.shouldShowInObjectTree();
  }

  public getState(): unknown {
    return this.lwcDrawing.getState();
  }

  public setState(state: unknown): void {
    this.lwcDrawing.setState(state);

    const hidden = getHiddenState(state);

    if (hidden !== null) {
      this.hidden.next(hidden);
    }

    this.settingsSubject.next(this.lwcDrawing.getSettings());
  }

  public getSnapshot(): DrawingSnapshot {
    return {
      id: this.id,
      drawingName: this.drawingName,
      state: this.getState(),
      isLocked: this.isLocked(),
      zIndex: this.zIndex,
    };
  }

  public applySnapshot(snapshot: DrawingSnapshot): void {
    this.setState(snapshot.state);
    this.setLocked(snapshot.isLocked ?? false);

    if (snapshot.zIndex !== undefined) {
      this.setZIndex(snapshot.zIndex);
    }
  }

  public getSettings(): SettingsValues {
    return this.lwcDrawing.getSettings();
  }

  public updateSettings(settings: SettingsValues): void {
    this.lwcDrawing.updateSettings(settings);
  }

  public getSettingsTabs(): SettingsTab[] {
    return this.lwcDrawing.getSettingsTabs();
  }

  public getToolbarSettings(): ToolbarSettingField[] {
    return this.getSettingsTabs()
      .flatMap((tab) => tab.fields)
      .filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
  }

  public hasSettings(): boolean {
    return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
  }

  public destroy(): void {
    this.subscriptions.unsubscribe();
    this.unregisterSelectedDrawingHotkeys();

    this.hotkeys.unregister({
      keys: [Keys.escape],
      hash: this.escapeUnregisterHash,
    });

    this.escapeUnregisterHash = null;

    this.lockedSubject.complete();
    this.settingsSubject.complete();

    this.mainSeries.detachPrimitive(this.lwcDrawing);
    this.lwcDrawing.destroy();
  }

  private registerSelectedDrawingHotkeys(
    setCopyPasteBuffer: (snapshot: DrawingSnapshot) => void,
  ): void {
    this.unregisterSelectedDrawingHotkeys();

    this.deleteUnregisterHash = this.hotkeys.register({
      keys: [Keys.delete],
      callback: () => {
        this.delete();
      },
    });

    this.copyUnregisterHash = this.hotkeys.register({
      keys: [Keys.mod, Keys.c],
      callback: () => {
        if (!this.isCreationPending()) {
          setCopyPasteBuffer(this.getSnapshot());
        }
      },
    });
  }

  private unregisterSelectedDrawingHotkeys(): void {
    this.hotkeys.unregister({
      keys: [Keys.delete],
      hash: this.deleteUnregisterHash,
    });

    this.hotkeys.unregister({
      keys: [Keys.mod, Keys.c],
      hash: this.copyUnregisterHash,
    });

    this.deleteUnregisterHash = null;
    this.copyUnregisterHash = null;
  }

  private async afterCreation(callback: () => void): Promise<void> {
    await this.lwcDrawing.waitTillReady();
    callback();
  }
}

function getHiddenState(state: unknown): boolean | null {
  if (
    typeof state !== 'object' ||
    state === null ||
    !('hidden' in state)
  ) {
    return null;
  }

  const { hidden } = state as { hidden?: unknown };

  return typeof hidden === 'boolean' ? hidden : null;
}



import { BehaviorSubject, map, Observable } from 'rxjs';

import { DOM } from '@components/DOM';
import { IDOMObject } from '@core/DOMObject';
import { ModalRenderer } from '@core/ModalRenderer';
import { t } from '@src/translations';

interface DOMModelParams {
  modalRenderer: ModalRenderer;
}

/**
 * Абстракция над библиотекой для построения графиков
 */
export class DOMModel {
  // ∈ symbol&pane
  private modalRenderer: ModalRenderer;
  private lastZIndex = 0;

  private entities: BehaviorSubject<IDOMObject[]> = new BehaviorSubject<IDOMObject[]>([]); // drawings/indicators/series
  // private panes: Panes[]; // todo: пока что на каждый пейн будет один objectTree

  constructor({ modalRenderer }: DOMModelParams) {
    this.modalRenderer = modalRenderer;
  }

  public removeEntity = <T extends IDOMObject>(entity: T): void => {
    this.entities.next(
      this.entities.value.filter((item) => item.id !== entity.id),
    );
  };

  public setEntity = <T extends IDOMObject>(
    callback: (
      zIndex: number,
      moveUp: (id: string) => void,
      moveDown: (id: string) => void,
    ) => T,
    zIndex?: number,
  ): T => {
    const entityZIndex = zIndex ?? this.lastZIndex;

    this.lastZIndex = Math.max(
      this.lastZIndex,
      entityZIndex + 1,
    );

    const entity = callback(
      entityZIndex,
      this.moveUp,
      this.moveDown,
    );

    this.entities.next(
      [...this.entities.value, entity].sort(
        (left, right) => left.zIndex - right.zIndex,
      ),
    );

    return entity;
  };

  private moveUp = (id: string): void => {
    this.moveEntity(id, 1);
  };

  private moveDown = (id: string): void => {
    this.moveEntity(id, -1);
  };

  private moveEntity(id: string, direction: -1 | 1): void {
    const entities = [...this.entities.value].sort(
      (left, right) => left.zIndex - right.zIndex,
    );

    const currentIndex = entities.findIndex(
      (entity) => entity.id === id,
    );

    if (currentIndex === -1) {
      return;
    }

    const targetIndex = currentIndex + direction;
    const target = entities[targetIndex];

    if (!target) {
      return;
    }

    const current = entities[currentIndex];
    const currentZIndex = current.zIndex;

    current.setZIndex(target.zIndex);
    target.setZIndex(currentZIndex);

    this.entities.next(
      entities.sort(
        (left, right) => left.zIndex - right.zIndex,
      ),
    );
  }

  public getEntities = (): Observable<IDOMObject[]> => {
    return this.entities.pipe(
      map((entities) =>
        entities.filter((entity) =>
          entity.shouldShowInObjectTree(),
        ),
      ),
    );
  };

  public refreshEntities = (): void => {
    this.entities.next([...this.entities.value]);
  };

  public toggleDOM = () => {
    this.modalRenderer.renderComponent(
      <DOM elementsObs={this.getEntities()} />,
      {
        title: t('DOM tree'),
        onSave: () => console.warn('dom state saved'),
        acceptLabel: '',
        rejectLabel: '',
      },
    );
  };

  public destroy(): void {
    // todo implement
  }
}


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',
  'y' = 'keyy',
  '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 mainKeys = keys.filter(
    (key) => !isModifier(key),
  );

  if (
    mainKeys.length !== 1 ||
    mainKeys[0] !== normalizeCode(event.code)
  ) {
    return false;
  }

  const hasMod = keys.includes(Keys.mod);

  if (hasMod) {
    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
  );
}