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


import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface RectangleStyle {
  borderColor: string;
  fillColor: string;
}

export interface RectangleTextStyle {
  text: string;
  fontSize: number;
  isBold: boolean;
  isItalic: boolean;
  textColor: string;
}

export type RectangleSettings = RectangleStyle & RectangleTextStyle & SettingsValues;

export function createDefaultSettings(): RectangleSettings {
  const { colors } = getThemeStore();

  return {
    borderColor: colors.rectangleBorderFill,
    fillColor: colors.rectangleAreaFill,
    text: '',
    fontSize: 14,
    isBold: false,
    isItalic: false,
    textColor: colors.rectangleBorderFill,
  };
}

export function getRectangleSettingsTabs(settings: RectangleSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'borderColor',
      label: t('Border color'),
      type: 'color',
      defaultValue: settings.borderColor,
    },
    {
      key: 'fillColor',
      label: t('Background color'),
      type: 'color',
      defaultValue: settings.fillColor,
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'fontSize',
      label: t('Font size'),
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 24,
    },
    {
      key: 'text',
      label: t('Text'),
      type: 'textarea',
      defaultValue: settings.text,
      placeholder: t('Enter text'),
    },
    {
      key: 'isBold',
      label: t('Bold'),
      type: 'boolean',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: t('Italics'),
      type: 'boolean',
      defaultValue: settings.isItalic,
    },
    {
      key: 'textColor',
      label: t('Text color'),
      type: 'color',
      defaultValue: settings.textColor,
    },
  ];

  return [
    {
      key: 'style',
      label: t('Style'),
      fields: styleFields,
    },
    {
      key: 'text',
      label: t('Text'),
      fields: textFields,
    },
  ];
}



import { Observable } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
  normalizeBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RectanglePaneView } from './paneView';
import {
  createDefaultSettings,
  getRectangleSettingsTabs,
  RectangleSettings,
  RectangleStyle,
  RectangleTextStyle,
} from './settings';

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
type RectangleHandleKey = Exclude<RectangleHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';

interface RectangleParams {
  container: HTMLElement;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

interface RectangleState {
  hidden: boolean;
  isActive: boolean;
  mode: RectangleMode;
  startTime: Time | null;
  endTime: Time | null;
  startPrice: number | null;
  endPrice: number | null;
  settings: RectangleSettings;
}

interface RectangleGeometry {
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  handles: Record<RectangleHandleKey, Point>;
}

export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
  showFill: boolean;
  showHandles: boolean;
}

const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;

export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements ISeriesDrawing {
  private removeSelf?: () => void;
  private openSettings?: () => void;

  protected settings: RectangleSettings = createDefaultSettings();

  protected mode: RectangleMode = 'idle';

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private startPrice: number | null = null;
  private endPrice: number | null = null;

  private activeDragTarget: RectangleHandle = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: RectangleState | null = null;
  private dragGeometrySnapshot: RectangleGeometry | null = null;

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  private readonly paneView: RectanglePaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly leftTimeAxisView: CustomTimeAxisView;
  private readonly rightTimeAxisView: CustomTimeAxisView;
  private readonly topPriceAxisView: CustomPriceAxisView;
  private readonly bottomPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { container, formatObservable, removeSelf, openSettings }: RectangleParams,
  ) {
    super({ chart, series, container });
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new RectanglePaneView(this);

    this.timeAxisPaneView = new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
    });

    this.priceAxisPaneView = new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
    });

    this.leftTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'left',
    });

    this.rightTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'right',
    });

    this.topPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'top',
    });

    this.bottomPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'bottom',
    });

    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    this.series.attachPrimitive(this);
  }

  public isCreationPending(): boolean {
    return this.mode === 'idle' || this.mode === 'drawing';
  }

  public getState(): RectangleState {
    return {
      hidden: this.hidden,
      isActive: this.isActive.value,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      startPrice: this.startPrice,
      endPrice: this.endPrice,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    const nextState = state as Partial<RectangleState>;

    if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }

    if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
      this.isActive.next(nextState.isActive);
    }

    if ('mode' in nextState && nextState.mode) {
      this.mode = nextState.mode;
    }

    if ('startTime' in nextState) {
      this.startTime = nextState.startTime ?? null;
    }

    if ('endTime' in nextState) {
      this.endTime = nextState.endTime ?? null;
    }

    if ('startPrice' in nextState) {
      this.startPrice = nextState.startPrice ?? null;
    }

    if ('endPrice' in nextState) {
      this.endPrice = nextState.endPrice ?? null;
    }

    if ('settings' in nextState && nextState.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...nextState.settings,
      };
    }

    this.render();
  }

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

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.leftTimeAxisView,
      this.rightTimeAxisView,
      this.topPriceAxisView,
      this.bottomPriceAxisView,
    ]);
  }

  public paneViews(): readonly IPrimitivePaneView[] {
    return [this.paneView];
  }

  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [this.timeAxisPaneView];
  }

  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [this.priceAxisPaneView];
  }

  public timeAxisViews() {
    return [this.leftTimeAxisView, this.rightTimeAxisView];
  }

  public priceAxisViews() {
    return [this.topPriceAxisView, this.bottomPriceAxisView];
  }

  public getRenderData(): RectangleRenderData | null {
    if (this.hidden) {
      return null;
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showFill: true,
      showHandles: this.shouldShowHandles(),
      ...this.settings,
    };
  }

  public getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
      return null;
    }

    const point = { x, y };

    if (!this.isActive.value) {
      if (!this.containsPoint(point)) {
        return null;
      }

      return {
        cursorStyle: 'pointer',
        externalId: 'rectangle-position',
        zOrder: 'top',
      };
    }

    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return {
        cursorStyle: this.getCursorStyle(handleTarget),
        externalId: 'rectangle-position',
        zOrder: 'top',
      };
    }

    if (!this.containsPoint(point)) {
      return null;
    }

    return {
      cursorStyle: 'grab',
      externalId: 'rectangle-position',
      zOrder: 'top',
    };
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    if (!this.isActive.value) {
      return [];
    }

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.left,
        to: bounds.right,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    if (!this.isActive.value) {
      return [];
    }

    const bounds = this.getPriceBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.top,
        to: bounds.bottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive.value || (kind !== 'left' && kind !== 'right')) {
      return null;
    }

    const labelKind = kind as TimeLabelKind;
    const coordinate = this.getTimeCoordinate(labelKind);
    const text = this.getTimeText(labelKind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive.value || (kind !== 'top' && kind !== 'bottom')) {
      return null;
    }

    const labelKind = kind as PriceLabelKind;
    const coordinate = this.getPriceCoordinate(labelKind);
    const text = this.getPriceText(labelKind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected handleDoubleClick = (event: MouseEvent): void => {
    if (this.hidden || this.mode !== 'ready') {
      return;
    }

    const rect = this.container.getBoundingClientRect();
    const point = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

    if (!this.containsPoint(point) && !this.getHandleTarget(point)) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.openSettings?.();
  };

  protected handlePointerDown = (event: PointerEvent): void => {
    if (this.hidden || event.button !== 0) {
      return;
    }

    const point = this.getEventPoint(event);

    if (this.mode === 'idle') {
      event.preventDefault();
      event.stopPropagation();

      this.startDrawing(point);
      return;
    }

    if (this.mode === 'drawing') {
      event.preventDefault();
      event.stopPropagation();

      this.updateDrawing(point);
      this.finishDrawing();
      return;
    }

    if (this.mode !== 'ready') {
      return;
    }

    if (!this.isActive.value) {
      if (!this.containsPoint(point)) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      this.isActive.next(true);
      this.render();
      return;
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      this.isActive.next(false);
      this.render();
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.startDragging(point, event.pointerId, dragTarget);
  };

  protected handlePointerMove = (event: PointerEvent): void => {
    const point = this.getEventPoint(event);

    if (this.mode === 'drawing') {
      this.updateDrawing(point);
      return;
    }

    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
      return;
    }

    event.preventDefault();

    if (this.activeDragTarget === 'body') {
      this.moveWhole(point);
      this.render();
      return;
    }

    this.resizeRectangle(point);
    this.render();
  };

  protected handlePointerUp = (event: PointerEvent): void => {
    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
      return;
    }

    this.finishDragging();
  };

  private startDrawing(point: Point): void {
    const anchor = this.createAnchor(point);

    if (!anchor) {
      return;
    }

    this.startTime = anchor.time;
    this.endTime = anchor.time;
    this.startPrice = anchor.price;
    this.endPrice = anchor.price;

    this.isActive.next(true);
    this.mode = 'drawing';

    this.render();
  }

  private updateDrawing(point: Point): void {
    const clampedPoint = this.clampPointToContainer(point);
    const anchor = this.createAnchor(clampedPoint);

    if (!anchor) {
      return;
    }

    this.endTime = anchor.time;
    this.endPrice = anchor.price;

    this.render();
  }

  private finishDrawing(): void {
    const geometry = this.getGeometry();

    if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

    this.mode = 'ready';
    this.resolveReady?.();

    this.render();
  }

  private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
    this.mode = 'dragging';

    this.activeDragTarget = dragTarget;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();
    this.dragGeometrySnapshot = this.getGeometry();

    this.render();
  }

  private finishDragging(): void {
    this.mode = 'ready';
    this.resolveReady?.();

    this.clearInteractionState();
    this.render();
  }

  private clearInteractionState(): void {
    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;
    this.dragGeometrySnapshot = null;
  }

  private resetToIdle(): void {
    this.hidden = false;
    this.isActive.next(false);
    this.mode = 'idle';

    this.startTime = null;
    this.endTime = null;
    this.startPrice = null;
    this.endPrice = null;
    this.clearInteractionState();
    this.render();
  }

  private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return handleTarget;
    }

    if (this.containsPoint(point)) {
      return 'body';
    }

    return null;
  }

  private moveWhole(point: Point): void {
    const snapshot = this.dragStateSnapshot;
    const geometry = this.dragGeometrySnapshot;

    if (!snapshot || !geometry || !this.dragStartPoint) {
      return;
    }

    if (
      snapshot.startTime === null ||
      snapshot.endTime === null ||
      snapshot.startPrice === null ||
      snapshot.endPrice === null
    ) {
      return;
    }

    const containerSize = this.getContainerSize();

    const rawOffsetX = point.x - this.dragStartPoint.x;
    const rawOffsetY = point.y - this.dragStartPoint.y;

    const minOffsetX = -geometry.left;
    const maxOffsetX = containerSize.width - geometry.right;
    const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);

    const minOffsetY = -geometry.top;
    const maxOffsetY = containerSize.height - geometry.bottom;
    const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);

    const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
    const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);

    if (nextStartTime === null || nextEndTime === null) {
      return;
    }

    const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.startPrice = snapshot.startPrice + priceOffset;
    this.endPrice = snapshot.endPrice + priceOffset;
  }

  private resizeRectangle(point: Point): void {
    const geometry = this.dragGeometrySnapshot;

    if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
      return;
    }

    const clampedPoint = this.clampPointToContainer(point);

    let { left } = geometry;
    let { right } = geometry;
    let { top } = geometry;
    let { bottom } = geometry;

    switch (this.activeDragTarget) {
      case 'nw':
        left = clampedPoint.x;
        top = clampedPoint.y;
        break;
      case 'n':
        top = clampedPoint.y;
        break;
      case 'ne':
        right = clampedPoint.x;
        top = clampedPoint.y;
        break;
      case 'e':
        right = clampedPoint.x;
        break;
      case 'se':
        right = clampedPoint.x;
        bottom = clampedPoint.y;
        break;
      case 's':
        bottom = clampedPoint.y;
        break;
      case 'sw':
        left = clampedPoint.x;
        bottom = clampedPoint.y;
        break;
      case 'w':
        left = clampedPoint.x;
        break;
      default:
        return;
    }

    this.setRectangleBounds(left, right, top, bottom);
  }

  private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
    const bounds = normalizeBounds(left, right, top, bottom, this.container);

    const startTime = getTimeFromXCoordinate(this.chart, bounds.left);
    const endTime = getTimeFromXCoordinate(this.chart, bounds.right);
    const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
    const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);

    if (startTime === null || endTime === null || startPrice === null || endPrice === null) {
      return false;
    }

    this.startTime = startTime;
    this.endTime = endTime;
    this.startPrice = startPrice;
    this.endPrice = endPrice;

    return true;
  }

  private createAnchor(point: Point): { time: Time; price: number } | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

  protected getGeometry(): RectangleGeometry | null {
    if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

    if (startX === null || endX === null || startY === null || endY === null) {
      return null;
    }

    const left = Math.round(Math.min(Number(startX), Number(endX)));
    const right = Math.round(Math.max(Number(startX), Number(endX)));
    const top = Math.round(Math.min(Number(startY), Number(endY)));
    const bottom = Math.round(Math.max(Number(startY), Number(endY)));

    const centerX = (left + right) / 2;
    const centerY = (top + bottom) / 2;

    return {
      left,
      right,
      top,
      bottom,
      width: right - left,
      height: bottom - top,
      handles: {
        nw: { x: left, y: top },
        n: { x: centerX, y: top },
        ne: { x: right, y: top },
        e: { x: right, y: centerY },
        se: { x: right, y: bottom },
        s: { x: centerX, y: bottom },
        sw: { x: left, y: bottom },
        w: { x: left, y: centerY },
      },
    };
  }

  private getTimeBounds(): { left: number; right: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      left: geometry.left,
      right: geometry.right,
    };
  }

  private getPriceBounds(): { top: number; bottom: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      top: geometry.top,
      bottom: geometry.bottom,
    };
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'left' ? geometry.left : geometry.right;
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'top' ? geometry.top : geometry.bottom;
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = this.getTimeValueForLabel(kind);

    if (typeof time !== 'number') {
      return '';
    }

    return formatDate(
      time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }

  private getPriceText(kind: PriceLabelKind): string {
    const price = this.getPriceValueForLabel(kind);

    if (price === null) {
      return '';
    }

    return formatPrice(price) ?? '';
  }

  private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);

    if (startX === null || endX === null) {
      return kind === 'left' ? this.startTime : this.endTime;
    }

    const startIsLeft = Number(startX) <= Number(endX);

    if (kind === 'left') {
      return startIsLeft ? this.startTime : this.endTime;
    }

    return startIsLeft ? this.endTime : this.startTime;
  }

  private getPriceValueForLabel(kind: PriceLabelKind): number | null {
    if (this.startPrice === null || this.endPrice === null) {
      return null;
    }

    const startY = getYCoordinateFromPrice(this.series, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

    if (startY === null || endY === null) {
      return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
    }

    const startIsTop = Number(startY) <= Number(endY);

    if (kind === 'top') {
      return startIsTop ? this.startPrice : this.endPrice;
    }

    return startIsTop ? this.endPrice : this.startPrice;
  }

  private getHandleTarget(point: Point): RectangleHandleKey | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];

    for (const handleName of handleOrder) {
      const handle = geometry.handles[handleName];

      if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
        return handleName;
      }
    }

    return null;
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
  }

  private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
    switch (handle) {
      case 'nw':
      case 'se':
        return 'nwse-resize';
      case 'ne':
      case 'sw':
        return 'nesw-resize';
      case 'n':
      case 's':
        return 'ns-resize';
      case 'e':
      case 'w':
        return 'ew-resize';
      case 'body':
        return 'grab';
      default:
        return 'default';
    }
  }

  private shiftTime(time: Time, offsetX: number): Time | null {
    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
  }

  private getPriceDelta(fromY: number, toY: number): number {
    return getPriceDeltaFromCoordinates(this.series, fromY, toY);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

  private clampPointToContainer(point: Point): Point {
    return clampPointToContainerInElement(point, this.container);
  }
}



import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { getThemeStore } from '@src/theme';

import { Rectangle } from './rectangle';

const UI = {
  borderWidth: 1,
  handleSize: 10,
  handleBorderWidth: 1,
  textOffset: 4,
  textLineHeightMultiplier: 1.2,
};

export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
  private readonly rectangle: Rectangle;

  constructor(rectangle: Rectangle) {
    this.rectangle = rectangle;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.rectangle.getRenderData();

    if (!data) {
      return;
    }

    const { colors } = getThemeStore();

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);

      const left = data.left * horizontalPixelRatio;
      const right = data.right * horizontalPixelRatio;
      const top = data.top * verticalPixelRatio;
      const bottom = data.bottom * verticalPixelRatio;

      context.save();

      if (data.showFill) {
        context.fillStyle = data.fillColor;
        context.fillRect(left, top, right - left, bottom - top);
      }

      context.lineWidth = UI.borderWidth * pixelRatio;
      context.strokeStyle = data.borderColor;
      context.strokeRect(left, top, right - left, bottom - top);

      drawRectangleText(context, {
        left,
        right,
        top,
        text: data.text,
        fontSize: data.fontSize,
        isBold: data.isBold,
        isItalic: data.isItalic,
        textColor: data.textColor,
        pixelRatio,
      });

      if (data.showHandles) {
        for (const handle of Object.values(data.handles)) {
          drawHandle(
            context,
            handle.x * horizontalPixelRatio,
            handle.y * verticalPixelRatio,
            horizontalPixelRatio,
            verticalPixelRatio,
            colors.chartLineColor,
            colors.chartBackground,
          );
        }
      }

      context.restore();
    });
  }
}

function drawRectangleText(
  context: CanvasRenderingContext2D,
  params: {
    left: number;
    right: number;
    top: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    pixelRatio: number;
  },
): void {
  const { left, top, text, fontSize, isBold, isItalic, textColor, pixelRatio } = params;

  if (!text.trim()) {
    return;
  }

  const lines = text.split('\n');
  const safeFontSize = Math.max(1, fontSize);
  const fontSizePx = safeFontSize * pixelRatio;
  const lineHeight = safeFontSize * UI.textLineHeightMultiplier * pixelRatio;
  const fontWeight = isBold ? '700 ' : '';
  const fontStyle = isItalic ? 'italic ' : '';
  const textOffset = UI.textOffset * pixelRatio;

  const textX = left;
  const blockHeight = lines.length * lineHeight;
  const firstLineY = top - textOffset - blockHeight + lineHeight / 2;

  context.save();

  context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
  context.fillStyle = textColor;
  context.textAlign = 'left';
  context.textBaseline = 'middle';

  lines.forEach((line, index) => {
    context.fillText(line, textX, firstLineY + index * lineHeight);
  });

  context.restore();
}

function drawHandle(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  strokeColor: string,
  fillColor: string,
): void {
  const width = UI.handleSize * horizontalPixelRatio;
  const height = UI.handleSize * verticalPixelRatio;
  const left = x - width / 2;
  const top = y - height / 2;

  context.save();
  context.fillStyle = fillColor;
  context.strokeStyle = strokeColor;
  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.beginPath();
  context.rect(left, top, width, height);
  context.fill();
  context.stroke();

  context.restore();
}



import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';

import { Subscription } from 'rxjs';

import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { ISeriesDrawing } from '@core/Drawings/common';
import { DrawingSnapshotItem } from '@core/DrawingsManager';
import { Hotkeys, Keys } from '@core/Hotkeys';

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

type IDrawing = DOMObject;

interface DrawingParams extends DOMObjectParams {
  drawingName: DrawingsNames;
  lwcChart: IChartApi;
  mainSeries: SeriesStrategies;
  onDelete: (id: string) => void;
  construct: (chart: IChartApi, series: ISeriesApi<SeriesType>) => ISeriesDrawing;
  hotkeys: Hotkeys;
  setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
  resetActiveTool: () => void;
}

export class Drawing extends DOMObject implements IDrawing {
  private lwcDrawing: ISeriesDrawing;
  private mainSeries: SeriesStrategies;
  private drawingName: DrawingsNames;
  private hotkeys: Hotkeys;

  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,
    paneId,
    hotkeys,
    setCopyPasteBuffer,
    resetActiveTool,
  }: DrawingParams) {
    super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });

    this.hotkeys = hotkeys;
    this.lwcDrawing = construct(lwcChart, mainSeries);
    this.onDelete = onDelete;

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

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

        this.copyUnregisterHash = hotkeys.register({
          keys: [Keys.control, Keys.c],
          callback: () => {
            if (!this.isCreationPending()) {
              const copiedObject = {
                id: this.id,
                drawingName: this.getDrawingName(),
                state: this.getState(),
                isLocked: this.isLocked(),
              };

              setCopyPasteBuffer(copiedObject);
            }
          },
        });
      } else {
        hotkeys.unregister({
          keys: [Keys.delete],
          hash: this.deleteUnregisterHash,
        });

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

    this.mainSeries = mainSeries;
    this.drawingName = drawingName;

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

  public delete(): void {
    this.destroy();
    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.lwcDrawing.subscribeIsLocked(callback);
  }

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

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

  public setLocked(isLocked: boolean): void {
    this.lwcDrawing.setLocked(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);
  }

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

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

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

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

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

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

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

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

  private async afterCreation(callback: () => void): Promise<void> {
    await this.lwcDrawing.waitTillReady();
    callback();
  }
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';

import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys, Keys } from '@core/Hotkeys';

import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool } from '@src/types';

interface DrawingsManagerParams {
  eventManager: EventManager;
  mainSeries$: Observable<SeriesStrategies | null>;
  lwcChart: IChartApi;
  DOM: DOMModel;
  container: HTMLElement;
  modalRenderer: ModalRenderer;
  paneId: number;
  hotkeys: Hotkeys;
}

export interface DrawingSnapshotItem {
  id: string;
  drawingName: DrawingsNames;
  state: unknown;
  isLocked?: boolean;
}

interface CreateDrawingOptions {
  id?: string;
  state?: unknown;
  isLocked?: boolean;
  shouldUpdateDrawingsList?: boolean;
}

export type DrawingsManagerSnapshot = DrawingSnapshotItem[];

export class DrawingsManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private DOM: DOMModel;
  private container: HTMLElement;
  private modalRenderer: ModalRenderer;

  private mainSeries: SeriesStrategies | null = null;
  private subscriptions = new Subscription();
  private drawings$ = new BehaviorSubject<Drawing[]>([]);
  private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null);
  private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
  private endlessMode$ = new BehaviorSubject(false);
  private recreateScheduled = false;
  private pendingSnapshot: DrawingsManagerSnapshot | null = null;
  private paneId: number;

  private hotkeys: Hotkeys;
  private copyPasteBuffer: DrawingSnapshotItem | null = null;
  private escapeUnregisterHash: string | null = null;

  constructor({
    eventManager,
    mainSeries$,
    lwcChart,
    DOM,
    container,
    modalRenderer,
    paneId,
    hotkeys,
  }: DrawingsManagerParams) {
    this.DOM = DOM;
    this.eventManager = eventManager;
    this.paneId = paneId;
    this.lwcChart = lwcChart;
    this.container = container;
    this.modalRenderer = modalRenderer;
    this.hotkeys = hotkeys;

    this.subscriptions.add(
      mainSeries$.subscribe((series) => {
        if (!series) {
          return;
        }

        this.mainSeries = series;
        this.drawings$.value.forEach((drawing) => drawing.rebind(series));

        if (this.pendingSnapshot) {
          const snapshot = this.pendingSnapshot;
          this.pendingSnapshot = null;
          this.setSnapshot(snapshot);
        }
      }),
    );

    window.addEventListener('pointerup', this.handlePointerUp);
    this.container.addEventListener('click', this.handleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);

    // todo: implement ctrl+v
    // hotkeys.register({
    //   keys: [Keys.control, Keys.v],
    //   callback: () => {
    //     const bufferWithAppliedPosition = {
    //       ...this.copyPasteBuffer,
    //       state: {
    //         ...this.copyPasteBuffer?.state,
    //         startAnchor: {
    //           price: 73.36210252637723,
    //           time: 1783679170
    //         }
    //       }
    //     }
    //
    //     this.setSnapshot([
    //       ...this.getSnapshot(),
    //       bufferWithAppliedPosition
    //     ])
    //     // hotkeys.unregister({ // todo: unregister all else ctrl+c's
    //     //   keys: [Keys.control, Keys.c]
    //     // })
    //   }
    // })
  }

  private handlePointerDown = (): void => {
    this.DOM.refreshEntities();
    queueMicrotask(this.updateSelectedDrawing);
  };

  private handlePointerUp = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
    queueMicrotask(this.updateSelectedDrawing);
  };

  private handleClick = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
    this.updateSelectedDrawing();
  };

  private updateSelectedDrawing = (): void => {
    const selectedDrawing =
      this.drawings$.value.find((drawing) => drawing.isSelected() && !drawing.isCreationPending()) ?? null;

    if (this.selectedDrawing$.value === selectedDrawing) {
      return;
    }

    this.selectedDrawing$.next(selectedDrawing);
  };

  private updateActiveTool = (): void => {
    const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());

    if (hasPendingDrawing) {
      return;
    }

    const activeTool = this.activeTool$.value;
    const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;

    if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
      if (this.recreateScheduled) {
        return;
      }

      this.recreateScheduled = true;

      queueMicrotask(() => {
        this.recreateScheduled = false;

        const currentTool = this.activeTool$.value;
        const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());

        if (currentTool === 'crosshair') {
          return;
        }

        if (!this.endlessMode$.value) {
          return;
        }

        if (drawingsMap[currentTool]?.singleInstance) {
          return;
        }

        if (hasPendingAfterTick) {
          return;
        }

        this.createDrawing(currentTool);
      });

      return;
    }

    this.activeTool$.next('crosshair');
  };

  private removeDrawing = (id: string): void => {
    const drawing = this.drawings$.value.find((item) => item.id === id);

    if (!drawing) {
      return;
    }

    this.removeDrawings([drawing]);
  };

  private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
    const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);

    this.removeDrawings(drawingsToRemove, shouldUpdateTool);
  }

  private removePendingDrawings(shouldUpdateTool = true): void {
    const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());

    this.removeDrawings(drawingsToRemove, shouldUpdateTool);
  }

  private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
    if (!drawingsToRemove.length) {
      return;
    }

    const selectedDrawing = this.selectedDrawing$.value;

    if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
      this.selectedDrawing$.next(null);
    }

    drawingsToRemove.forEach((drawing) => {
      drawing.destroy();
      this.DOM.removeEntity(drawing);
    });

    this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));

    if (shouldUpdateTool) {
      this.updateActiveTool();
    }

    this.updateSelectedDrawing();
    this.DOM.refreshEntities();
  }

  public addDrawingForce = (name: DrawingsNames): Promise<void> => {
    this.removePendingDrawings(false);

    if (drawingsMap[name].singleInstance) {
      this.removeDrawingsByName(name, false);
    }

    this.activeTool$.next(name);
    const drawing = this.createDrawing(name);
    this.DOM.refreshEntities();

    return drawing.waitForCreation();
  };

  private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
    if (!this.mainSeries) {
      throw new Error('[Drawings] main series is not defined');
    }

    const { id, state, isLocked = false, shouldUpdateDrawingsList = true } = options;

    const config = drawingsMap[name];
    const drawingId = id ?? crypto.randomUUID();

    let createdDrawing: Drawing | null = null;

    const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) => {
      const paneElement = series.getPane().getHTMLElement();

      if (!paneElement) {
        throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
      }

      const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
      const canvasElement = cells.item(1);

      return config.construct({
        chart,
        series,
        eventManager: this.eventManager,
        container: canvasElement,
        removeSelf: () => this.removeDrawing(drawingId),
        openSettings: () => {
          if (createdDrawing) {
            this.openSettings(createdDrawing);
          }
        },
      });
    };

    const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
      new Drawing({
        lwcChart: this.lwcChart,
        mainSeries: this.mainSeries as SeriesStrategies,
        id: drawingId,
        drawingName: name,
        name: drawingLabelById()[name],
        onDelete: this.removeDrawing,
        zIndex,
        moveDown,
        moveUp,
        construct,
        paneId: this.paneId,
        hotkeys: this.hotkeys,
        setCopyPasteBuffer: (copyPasteBuffer) => {
          this.copyPasteBuffer = copyPasteBuffer;
        },
        resetActiveTool: () => {
          this.activeTool$.next('crosshair');
        },
      });

    const entity = this.DOM.setEntity<Drawing>(drawingFactory);
    createdDrawing = entity;

    if (state !== undefined) {
      entity.setState(state);
    }

    entity.setLocked(isLocked);

    if (shouldUpdateDrawingsList) {
      this.drawings$.next([...this.drawings$.value, entity]);
    }

    return entity;
  }

  public getSnapshot(): DrawingsManagerSnapshot {
    return this.drawings$.value
      .filter((drawing) => !drawing.isCreationPending())
      .map((drawing) => ({
        id: drawing.id,
        drawingName: drawing.getDrawingName(),
        state: drawing.getState(),
        isLocked: drawing.isLocked(),
      }));
  }

  public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
    if (!Array.isArray(snapshot)) {
      return;
    }

    if (!this.mainSeries) {
      this.pendingSnapshot = snapshot;
      return;
    }

    this.removeDrawings(this.drawings$.value, false);

    const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
      if (!drawingsMap[item.drawingName]) {
        return drawings;
      }

      drawings.push(
        this.createDrawing(item.drawingName, {
          id: item.id,
          state: item.state,
          isLocked: item.isLocked,
          shouldUpdateDrawingsList: false,
        }),
      );

      return drawings;
    }, []);

    this.drawings$.next(restoredDrawings);
    this.activeTool$.next('crosshair');
    this.updateSelectedDrawing();
    this.DOM.refreshEntities();
  }

  public setEndlessDrawingMode = (value: boolean): void => {
    if (value) {
      this.escapeUnregisterHash = this.hotkeys.register({
        keys: [Keys.escape],
        callback: () => {
          this.setEndlessDrawingMode(false);
        },
      });
    } else {
      this.hotkeys.unregister({
        keys: [Keys.escape],
        hash: this.escapeUnregisterHash,
      });
      this.escapeUnregisterHash = null;
    }
    this.endlessMode$.next(value);
  };

  public isEndlessDrawingsMode(): Observable<boolean> {
    return this.endlessMode$.asObservable();
  }

  public getActiveTool(): Observable<ActiveDrawingTool> {
    return this.activeTool$.asObservable();
  }

  public activateCrosshair(): void {
    this.removePendingDrawings(false);
    this.activeTool$.next('crosshair');
    this.DOM.refreshEntities();
  }

  public entities(): Observable<Drawing[]> {
    return this.drawings$.asObservable();
  }

  public selectedDrawing(): Observable<Drawing | null> {
    return this.selectedDrawing$.asObservable();
  }

  public openSelectedDrawingSettings(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.openSettings(drawing);
  }

  public deleteSelectedDrawing(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    this.removeDrawing(drawing.id);
  }

  public toggleSelectedDrawingLock(): void {
    const drawing = this.selectedDrawing$.value;

    if (!drawing) {
      return;
    }

    drawing.setLocked(!drawing.isLocked());
  }

  private openSettings = (drawing: Drawing): void => {
    const tabs = drawing.getSettingsTabs();

    if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
      return;
    }

    let settings = drawing.getSettings();

    this.modalRenderer.renderComponent(
      <EntitySettingsModal
        tabs={tabs}
        values={settings}
        onChange={(nextSettings) => {
          settings = nextSettings;
        }}
        initialTabKey={tabs[0]?.key}
      />,
      {
        size: 'sm',
        title: drawing.name,
        onSave: () => drawing.updateSettings(settings),
      },
    );
  };

  public getDrawings(): Drawing[] {
    return this.drawings$.value;
  }

  public hideAll(): void {
    this.selectedDrawing$.next(null);
    this.drawings$.value.forEach((drawing) => drawing.hide());
    this.DOM.refreshEntities();
  }

  public destroy(): void {
    this.hotkeys.unregister({
      keys: [Keys.escape],
      hash: this.escapeUnregisterHash,
    });
    window.removeEventListener('pointerup', this.handlePointerUp);
    this.container.removeEventListener('click', this.handleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);

    this.drawings$.value.forEach((drawing) => drawing.destroy());

    this.subscriptions.unsubscribe();
    this.drawings$.complete();
    this.selectedDrawing$.complete();
    this.activeTool$.complete();
    this.endlessMode$.complete();
  }
}
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { useEffect, useMemo, useState } from 'react';

import {
  CheckboxField,
  ColorField,
  NumberField,
  RangeField,
  SelectField,
  TextAreaField,
  TextField,
} from '@src/components/FormFields';
import { SettingField, SettingsTab, SettingsValues, SettingValue } from '@src/types';

import styles from './index.module.scss';

interface EntitySettingsModalProps<TValues extends SettingsValues, TField extends SettingField = SettingField> {
  tabs: SettingsTab<TField>[];
  values: TValues;
  onChange: (settings: TValues) => void;
  initialTabKey?: string;
}

export const EntitySettingsModal = <TValues extends SettingsValues, TField extends SettingField = SettingField>({
  tabs,
  values,
  onChange,
  initialTabKey,
}: EntitySettingsModalProps<TValues, TField>) => {
  const availableTabs = useMemo(() => tabs.filter((tab) => tab.fields.length > 0), [tabs]);

  const [activeTabKey, setActiveTabKey] = useState(initialTabKey ?? availableTabs[0]?.key ?? '');
  const [form, setForm] = useState<TValues>(values);

  useEffect(() => {
    setForm(values);
  }, [values]);

  useEffect(() => {
    if (!availableTabs.find((tab) => tab.key === activeTabKey)) {
      setActiveTabKey(availableTabs[0]?.key ?? '');
    }
  }, [availableTabs, activeTabKey]);

  const activeTab = availableTabs.find((tab) => tab.key === activeTabKey) ?? availableTabs[0];

  const changeValue = (key: string, value: SettingValue) => {
    setForm((current) => {
      const next = {
        ...current,
        [key]: value,
      } as TValues;

      onChange(next);

      return next;
    });
  };

  const renderField = (field: TField) => {
    const value = form[field.key] ?? field.defaultValue;

    if (field.type === 'select') {
      return (
        <SelectField
          key={field.key}
          label={field.label}
          value={String(value)}
          options={field.options}
          onValueChange={(nextValue) => {
            changeValue(field.key, nextValue);
          }}
        />
      );
    }

    if (field.type === 'color') {
      return (
        <ColorField
          key={field.key}
          label={field.label}
          value={String(value)}
          onChange={(nextValue) => {
            changeValue(field.key, nextValue);
          }}
        />
      );
    }

    if (field.type === 'text') {
      return (
        <TextField
          key={field.key}
          label={field.label}
          value={String(value)}
          placeholder={field.placeholder}
          onValueChange={(nextValue) => {
            changeValue(field.key, nextValue);
          }}
        />
      );
    }

    if (field.type === 'textarea') {
      return (
        <TextAreaField
          key={field.key}
          label={field.label}
          value={String(value)}
          placeholder={field.placeholder}
          onValueChange={(nextValue) => {
            changeValue(field.key, nextValue);
          }}
        />
      );
    }

    if (field.type === 'boolean') {
      return (
        <CheckboxField
          key={field.key}
          label={field.label}
          checked={Boolean(value)}
          onValueChange={(nextValue) => {
            changeValue(field.key, nextValue);
          }}
        />
      );
    }

    if (field.type === 'range') {
      return (
        <RangeField
          key={field.key}
          label={field.label}
          value={typeof value === 'number' ? value : field.defaultValue}
          min={field.min}
          max={field.max}
          step={field.step}
          color={field.color}
          suffix={field.suffix}
          onChange={(event) => {
            changeValue(field.key, Number(event.target.value));
          }}
        />
      );
    }

    return (
      <NumberField
        key={field.key}
        label={field.label}
        value={typeof value === 'number' ? value : field.defaultValue}
        min={field.min}
        max={field.max}
        onValueChange={(nextValue) => {
          changeValue(field.key, nextValue);
        }}
      />
    );
  };

  return (
    <div className={styles.wrapper}>
      {availableTabs.length > 1 && (
        <div className={styles.tabs}>
          {availableTabs.map(({ key, label }) => (
            <Button
              key={key}
              className={classNames(styles.tab, { [styles.tab_active]: key === activeTabKey })}
              onClick={() => setActiveTabKey(key)}
            >
              {label}
            </Button>
          ))}
        </div>
      )}

      {activeTab?.fields.map(renderField)}
    </div>
  );
};
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';

import { GearIcon, LockIcon, LockOpenIcon, TrashIcon } from '@src/components/Icon';

import styles from './index.module.scss';

import type { Drawing } from '@core/Drawings';
import type { PointerEvent as ReactPointerEvent, SyntheticEvent } from 'react';
import type { Observable } from 'rxjs';

interface FloatingDrawingToolbarProps {
  selectedDrawing$: Observable<Drawing | null>;
  onToggleLock: () => void;
  onOpenSettings: () => void;
  onDelete: () => void;
}

interface Position {
  x: number;
  y: number;
}

interface DragState {
  pointerId: number;
  startX: number;
  startY: number;
  initialX: number;
  initialY: number;
}

const TOOLBAR_TOP_OFFSET = 12;

export function FloatingDrawingToolbar({
  selectedDrawing$,
  onToggleLock,
  onOpenSettings,
  onDelete,
}: FloatingDrawingToolbarProps) {
  const toolbarRef = useRef<HTMLDivElement | null>(null);
  const dragStateRef = useRef<DragState | null>(null);
  const positionRef = useRef<Position | null>(null);

  const [selectedDrawing, setSelectedDrawing] = useState<Drawing | null>(null);
  const [isLocked, setIsLocked] = useState(false);
  const [isDragging, setIsDragging] = useState(false);
  const [position, setPosition] = useState<Position | null>(null);

  useEffect(() => {
    const subscription = selectedDrawing$.subscribe(setSelectedDrawing);

    return () => {
      subscription.unsubscribe();
    };
  }, [selectedDrawing$]);

  useEffect(() => {
    if (!selectedDrawing) {
      setIsLocked(false);

      return;
    }

    const subscription = selectedDrawing.subscribeIsLocked(setIsLocked);

    return () => {
      subscription.unsubscribe();
    };
  }, [selectedDrawing]);

  useLayoutEffect(() => {
    if (!selectedDrawing) {
      return;
    }

    const toolbar = toolbarRef.current;
    const container = toolbar?.parentElement;

    if (!toolbar || !container) {
      return;
    }

    const currentPosition = positionRef.current;

    if (currentPosition) {
      updatePosition(clampPosition(currentPosition.x, currentPosition.y, toolbar, container));

      return;
    }

    updatePosition({
      x: Math.round((container.clientWidth - toolbar.offsetWidth) / 2),
      y: TOOLBAR_TOP_OFFSET,
    });
  }, [selectedDrawing]);

  const handleDragStart = (event: ReactPointerEvent<HTMLButtonElement>): void => {
    const currentPosition = positionRef.current;

    if (event.button !== 0 || !currentPosition) {
      return;
    }

    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);

    dragStateRef.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      initialX: currentPosition.x,
      initialY: currentPosition.y,
    };

    setIsDragging(true);
  };

  const handleDrag = (event: ReactPointerEvent<HTMLButtonElement>): void => {
    const dragState = dragStateRef.current;
    const toolbar = toolbarRef.current;
    const container = toolbar?.parentElement;

    if (!dragState || dragState.pointerId !== event.pointerId || !toolbar || !container) {
      return;
    }

    event.preventDefault();

    updatePosition(
      clampPosition(
        dragState.initialX + event.clientX - dragState.startX,
        dragState.initialY + event.clientY - dragState.startY,
        toolbar,
        container,
      ),
    );
  };

  const handleDragEnd = (event: ReactPointerEvent<HTMLButtonElement>): void => {
    if (dragStateRef.current?.pointerId !== event.pointerId) {
      return;
    }

    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }

    dragStateRef.current = null;
    setIsDragging(false);
  };

  const stopPropagation = (event: SyntheticEvent): void => {
    event.stopPropagation();
  };

  function updatePosition(nextPosition: Position): void {
    positionRef.current = nextPosition;

    setPosition((currentPosition) => {
      if (currentPosition?.x === nextPosition.x && currentPosition.y === nextPosition.y) {
        return currentPosition;
      }

      return nextPosition;
    });
  }

  if (!selectedDrawing) {
    return null;
  }

  return (
    <div
      ref={toolbarRef}
      className={styles.toolbar}
      style={{
        visibility: position ? 'visible' : 'hidden',
        transform: `translate3d(
          ${position?.x ?? 0}px,
          ${position?.y ?? 0}px,
          0
        )`,
      }}
      onClick={stopPropagation}
      onContextMenu={stopPropagation}
      onDoubleClick={stopPropagation}
      onPointerCancel={stopPropagation}
      onPointerDown={stopPropagation}
      onPointerMove={stopPropagation}
      onPointerUp={stopPropagation}
    >
      <button
        type="button"
        className={classNames(styles.toolbar_handle, {
          [styles.dragging]: isDragging,
        })}
        onPointerCancel={handleDragEnd}
        onPointerDown={handleDragStart}
        onPointerMove={handleDrag}
        onPointerUp={handleDragEnd}
      >
        <span />
        <span />
        <span />
        <span />
        <span />
        <span />
      </button>

      {selectedDrawing.hasSettings() && (
        <Button
          size="sm"
          className={styles.button}
          onClick={onOpenSettings}
          label={<GearIcon />}
        />
      )}

      <Button
        size="sm"
        className={classNames(styles.button, {
          [styles.pressed]: isLocked,
        })}
        onClick={onToggleLock}
        label={isLocked ? <LockIcon /> : <LockOpenIcon />}
      />

      <Button
        size="sm"
        className={styles.button}
        onClick={onDelete}
        label={<TrashIcon />}
      />
    </div>
  );
}

function clampPosition(x: number, y: number, toolbar: HTMLElement, container: HTMLElement): Position {
  return {
    x: Math.max(0, Math.min(Math.round(x), container.clientWidth - toolbar.offsetWidth)),
    y: Math.max(0, Math.min(Math.round(y), container.clientHeight - toolbar.offsetHeight)),
  };
}