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


import { Observable, skip } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getPriceRangeInContainer,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { SliderPaneView } from './paneView';

import {
  createDefaultSettings,
  getSliderPositionSettingsTabs,
  SliderPositionSettings,
  SliderPositionStyle,
  SliderPositionTextStyle,
} from './settings';

import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
  IPrimitivePaneView,
  MouseEventHandler,
  MouseEventParams,
  PrimitiveHoveredItem,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';

interface SliderPositionParams extends BaseDrawingParams {
  side: SliderSide;
  resetTriggers?: Observable<unknown>[];
}

interface SliderPositionState {
  hidden: boolean;
  mode: SliderMode;
  startTime: Time | null;
  endTime: Time | null;
  entryPrice: number | null;
  stopPrice: number | null;
  targetPrice: number | null;
  riskRewardRatio: number;
  amount: number;
  tickSize: number;
  settings: SliderPositionSettings;
}

interface SliderGeometry {
  startX: number;
  endX: number;
  leftX: number;
  rightX: number;
  entryY: number;
  stopY: number;
  targetY: number;
  entryPrice: number;
  stopPrice: number;
  targetPrice: number;
  profitTop: number;
  profitBottom: number;
  lossTop: number;
  lossBottom: number;
}

export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
  targetText: string;
  centerText: string;
  stopText: string;
  centerBoxColor: string;
  showFill: boolean;
  showHandles: boolean;
  showLabels: boolean;
}

const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;

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

  protected settings: SliderPositionSettings = createDefaultSettings();

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

  protected mode: SliderMode = 'idle';

  private side: SliderSide;

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private entryPrice: number | null = null;
  private stopPrice: number | null = null;
  private targetPrice: number | null = null;

  private activeDragTarget: DragTarget = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: SliderPositionState | null = null;

  private defaultRiskRewardRatio = 1;
  private amount = 1000;
  private tickSize = 1;

  private clickHandler: MouseEventHandler<Time>;
  private paneView: SliderPaneView;
  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;
  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;
  private targetPriceAxisView: CustomPriceAxisView;
  private entryPriceAxisView: CustomPriceAxisView;
  private stopPriceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    side,
    container,
    interaction,
    formatObservable,
    resetTriggers = [],
    removeSelf,
    openSettings,
    initialEvent,
  }: SliderPositionParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

    this.side = side;
    this.tickSize = this.getSeriesTickSize() ?? this.tickSize;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.clickHandler = (params) => this.handleChartClick(params);

    this.paneView = new SliderPaneView(this);

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

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

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

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

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

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

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

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

    resetTriggers.forEach((trigger) => {
      this.subscriptions.add(
        trigger.pipe(skip(1)).subscribe(() => {
          this.removeSelf?.();
        }),
      );
    });

    this.series.attachPrimitive(this);

    if (initialEvent) {
      this.handleChartClick(initialEvent);
    }
  }

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

  public getState(): SliderPositionState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      riskRewardRatio: this.getCurrentRiskRewardRatio(),
      amount: this.amount,
      tickSize: this.tickSize,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }

    const next = state as Partial<SliderPositionState>;

    this.hidden = next.hidden ?? this.hidden;
    this.mode = next.mode ?? this.mode;

    this.startTime = next.startTime ?? this.startTime;
    this.endTime = next.endTime ?? this.endTime;
    this.entryPrice = next.entryPrice ?? this.entryPrice;
    this.stopPrice = next.stopPrice ?? this.stopPrice;
    this.amount = next.amount ?? this.amount;

    if (typeof next.tickSize === 'number' && next.tickSize > 0) {
      this.tickSize = next.tickSize;
    }

    this.tickSize = this.getSeriesTickSize() ?? this.tickSize;

    if (next.targetPrice !== undefined) {
      this.targetPrice = next.targetPrice;
    } else if (
      this.entryPrice !== null &&
      this.stopPrice !== null &&
      typeof next.riskRewardRatio === 'number' &&
      next.riskRewardRatio >= 0
    ) {
      const risk = Math.abs(this.entryPrice - this.stopPrice);

      this.targetPrice =
        this.side === 'long'
          ? this.entryPrice + risk * next.riskRewardRatio
          : this.entryPrice - risk * next.riskRewardRatio;
    }

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

    this.render();
  }

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

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.endTimeAxisView,
      this.targetPriceAxisView,
      this.entryPriceAxisView,
      this.stopPriceAxisView,
    ]);
  }

  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.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews() {
    return [this.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const qty = reward > 0 ? this.amount / reward : 0;

    const selectedPrice = this.getPriceAtTime(this.endTime);
    const pnl =
      selectedPrice === null
        ? 0
        : this.side === 'long'
          ? (selectedPrice - geometry.entryPrice) * qty
          : (geometry.entryPrice - selectedPrice) * qty;

    const { colors } = getThemeStore();

    return {
      ...geometry,
      targetText: this.getTargetText(geometry),
      centerText: this.getCenterText(qty, pnl),
      stopText: this.getStopText(geometry),
      centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
      showFill: true,
      showHandles: this.shouldShowHandles(),
      showLabels: this.isSelected(),
      ...this.settings,
    };
  }

  public getTimeBounds(): { left: number; right: number } | null {
    const start = this.getTimeCoordinate('start');
    const end = this.getTimeCoordinate('end');

    if (start === null || end === null) {
      return null;
    }

    return {
      left: Math.min(start, end),
      right: Math.max(start, end),
    };
  }

  public getTimeCoordinate(kind: TimeLabelKind): number | null {
    const time = kind === 'start' ? this.startTime : this.endTime;

    if (time === null) {
      return null;
    }

    const coordinate = getXCoordinateFromTime(this.chart, time, this.series);

    return coordinate === null ? null : Number(coordinate);
  }

  public getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'start' ? this.startTime : this.endTime;

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

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

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

    if (!geometry) {
      return null;
    }

    switch (kind) {
      case 'target':
        return geometry.targetY;
      case 'entry':
        return geometry.entryY;
      case 'stop':
        return geometry.stopY;
      default:
        return null;
    }
  }

  public getPriceText(kind: PriceLabelKind): string {
    const geometry = this.getGeometry();

    if (!geometry) {
      return '';
    }

    switch (kind) {
      case 'target':
        return formatPrice(geometry.targetPrice) ?? '';
      case 'entry':
        return formatPrice(geometry.entryPrice) ?? '';
      case 'stop':
        return formatPrice(geometry.stopPrice) ?? '';
      default:
        return '';
    }
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    const point = { x, y };

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

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

    const dragTarget = this.getHandleTarget(point);

    if (!dragTarget) {
      return null;
    }

    let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';

    if (dragTarget === 'target' || dragTarget === 'stop') {
      cursorStyle = 'ns-resize';
    }

    if (dragTarget === 'end') {
      cursorStyle = 'ew-resize';
    }

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

  protected getTimeAxisSegments(): AxisSegment[] {
    if (!this.isSelected()) {
      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.isSelected()) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.profitTop,
        to: geometry.profitBottom,
        color: colors.axisMarkerAreaFill,
      },
      {
        from: geometry.lossTop,
        to: geometry.lossBottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.isSelected() || (kind !== 'start' && kind !== 'end')) {
      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 (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
      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();

    let backgroundColor = colors.axisMarkerLabelDefaultFill;

    if (labelKind === 'target') {
      backgroundColor = colors.axisMarkerLabelPositiveFill;
    }

    if (labelKind === 'stop') {
      backgroundColor = colors.axisMarkerLabelNegativeFill;
    }

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

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

    super.bindEvents();
    this.chart.subscribeClick(this.clickHandler);
  }

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

    this.chart.unsubscribeClick(this.clickHandler);
    super.unbindEvents();
  }

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

    const point = this.getEventPoint(event as PointerEvent);

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

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

    this.openSettings?.();
  };

  private handleChartClick(params: MouseEventParams<Time>): void {
    // todo: привести к общему виду дровингов как handleChartClick => startDrawing
    if (this.hidden || !params.point || this.mode !== 'idle') {
      return;
    }

    const anchor = this.createAnchor(params);

    if (!anchor) {
      return;
    }

    const distance = this.getInitialZoneDistance(anchor.price);
    const stopDirection = this.side === 'long' ? -1 : 1;
    const targetDirection = -stopDirection;

    this.startTime = anchor.time;
    this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
    this.entryPrice = anchor.price;
    this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection, distance);
    this.targetPrice = this.normalizeTarget(
      anchor.price,
      anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
      distance,
    );

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

    this.render();
  }

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

    const point = this.getEventPoint(event);

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

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

      this.select();
      return;
    }

    const dragTarget = this.getHandleTarget(point);

    if (!dragTarget) {
      this.deselect();
      return;
    }

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

    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();
    this.mode = 'dragging';
  };

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

    event.preventDefault();

    this.applyDrag(this.getEventPoint(event));
    this.render();
  };

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

    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;

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

    this.render();
  };

  private applyDrag(point: Point): void {
    const snapshot = this.dragStateSnapshot;

    if (!snapshot) {
      return;
    }

    switch (this.activeDragTarget) {
      case 'body':
        this.moveWhole(snapshot, point);
        break;
      case 'entry':
        this.moveEntry(snapshot, point);
        break;
      case 'target':
        this.moveTarget(snapshot, point);
        break;
      case 'stop':
        this.moveStop(snapshot, point);
        break;
      case 'end':
        this.resizeEnd(snapshot, point);
        break;
      default:
        break;
    }
  }

  private moveEntry(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
      return;
    }

    const nextPrice = getPriceFromYCoordinate(this.series, point.y);

    if (nextPrice === null) {
      return;
    }

    const minDistance = this.getStopMinimumDistance();
    const low = this.side === 'long' ? snapshot.stopPrice + minDistance : snapshot.targetPrice;
    const high = this.side === 'long' ? snapshot.targetPrice : snapshot.stopPrice - minDistance;

    if (low > high) {
      return;
    }

    this.entryPrice = Math.max(low, Math.min(nextPrice, high));
  }

  private moveWhole(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.startTime === null || snapshot.endTime === null) {
      return;
    }

    if (snapshot.entryPrice === null || snapshot.stopPrice === null || snapshot.targetPrice === null) {
      return;
    }

    if (!this.dragStartPoint) {
      return;
    }

    const timeOffset = point.x - this.dragStartPoint.x;
    const priceOffset = this.getPriceDelta(this.dragStartPoint.y, point.y);

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

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

    let nextEntryPrice = snapshot.entryPrice + priceOffset;
    let nextStopPrice = snapshot.stopPrice + priceOffset;
    let nextTargetPrice = snapshot.targetPrice + priceOffset;

    const range = this.getPriceScaleRange();

    if (range) {
      const minValue = Math.min(nextEntryPrice, nextStopPrice, nextTargetPrice);
      const maxValue = Math.max(nextEntryPrice, nextStopPrice, nextTargetPrice);

      if (minValue < range.min) {
        const shift = range.min - minValue;

        nextEntryPrice += shift;
        nextStopPrice += shift;
        nextTargetPrice += shift;
      }

      if (maxValue > range.max) {
        const shift = maxValue - range.max;

        nextEntryPrice -= shift;
        nextStopPrice -= shift;
        nextTargetPrice -= shift;
      }
    }

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.entryPrice = nextEntryPrice;
    this.stopPrice = nextStopPrice;
    this.targetPrice = nextTargetPrice;
  }

  private moveStop(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.entryPrice === null) {
      return;
    }

    const nextPrice = getPriceFromYCoordinate(this.series, point.y);

    if (nextPrice === null) {
      return;
    }

    this.stopPrice = this.normalizeStop(
      snapshot.entryPrice,
      this.clampPriceToRange(nextPrice),
      this.getStopMinimumDistance(),
    );
  }

  private moveTarget(snapshot: SliderPositionState, point: Point): void {
    if (snapshot.entryPrice === null) {
      return;
    }

    const nextPrice = getPriceFromYCoordinate(this.series, point.y);

    if (nextPrice === null) {
      return;
    }

    this.targetPrice = this.normalizeTarget(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
  }

  private resizeEnd(snapshot: SliderPositionState, point: Point): void {
    const nextEndTime = getTimeFromXCoordinate(this.chart, point.x);

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

    this.startTime = snapshot.startTime;
    this.endTime = nextEndTime;
  }

  private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
    if (!params.point || params.time === undefined) {
      return null;
    }

    const price = getPriceFromYCoordinate(this.series, params.point.y);

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

    return {
      time: params.time,
      price,
    };
  }

  private normalizeStop(entryPrice: number, rawPrice: number, minDistance = this.getStopMinimumDistance()): number {
    const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);

    return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
  }

  private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = 0): number {
    const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);

    return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
  }

  private getAllowedMinimumDistance(entryPrice: number, kind: 'stop' | 'target', minDistance: number): number {
    const range = this.getPriceScaleRange();

    if (!range) {
      return minDistance;
    }

    const availableDistance =
      this.side === 'long'
        ? kind === 'stop'
          ? Math.max(entryPrice - range.min, 0)
          : Math.max(range.max - entryPrice, 0)
        : kind === 'stop'
          ? Math.max(range.max - entryPrice, 0)
          : Math.max(entryPrice - range.min, 0);

    return Math.min(minDistance, availableDistance);
  }

  private clampPriceToRange(price: number): number {
    const range = this.getPriceScaleRange();

    if (!range) {
      return price;
    }

    return Math.max(range.min, Math.min(price, range.max));
  }

  private getInitialZoneDistance(entryPrice: number): number {
    const fallback = Math.max(Math.abs(entryPrice) * 0.0075, this.tickSize * 3, MIN_DISTANCE);
    const range = this.getPriceScaleRange();

    if (!range) {
      return fallback;
    }

    const size = range.max - range.min;

    if (size <= 0) {
      return fallback;
    }

    return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
  }

  private getStopMinimumDistance(): number {
    return Math.max(this.tickSize, MIN_DISTANCE);
  }

  private getSeriesTickSize(): number | null {
    const priceFormat = this.series.options().priceFormat;

    if (!priceFormat || !('minMove' in priceFormat)) {
      return null;
    }

    return Number.isFinite(priceFormat.minMove) && priceFormat.minMove > 0 ? priceFormat.minMove : null;
  }

  private getPriceScaleRange(): { min: number; max: number } | null {
    return getPriceRangeInContainer(this.series, this.container);
  }

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

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

  private getCurrentRiskRewardRatio(): number {
    if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
      return this.defaultRiskRewardRatio;
    }

    const risk = Math.abs(this.entryPrice - this.stopPrice);

    if (!risk) {
      return this.defaultRiskRewardRatio;
    }

    return Math.abs(this.targetPrice - this.entryPrice) / risk;
  }

  private getPriceAtTime(time: Time | null): number | null {
    if (typeof time !== 'number') {
      return null;
    }

    const data = this.series.data() ?? [];
    let lastPrice: number | null = null;

    for (const item of data) {
      if (typeof item.time !== 'number') {
        continue;
      }

      if (item.time > time) {
        break;
      }

      if ('close' in item && typeof item.close === 'number') {
        lastPrice = item.close;
        continue;
      }

      if ('value' in item && typeof item.value === 'number') {
        lastPrice = item.value;
      }
    }

    return lastPrice;
  }

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

    if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
      return null;
    }

    const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
    const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
    const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
    const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
    const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);

    if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
      return null;
    }

    const start = Number(startX);
    const end = Number(endX);
    const entry = Number(entryY);
    const stop = Number(stopY);
    const target = Number(targetY);

    return {
      startX: start,
      endX: end,
      leftX: Math.min(start, end),
      rightX: Math.max(start, end),
      entryY: entry,
      stopY: stop,
      targetY: target,
      entryPrice: this.entryPrice,
      stopPrice: this.stopPrice,
      targetPrice: this.targetPrice,
      profitTop: Math.min(target, entry),
      profitBottom: Math.max(target, entry),
      lossTop: Math.min(stop, entry),
      lossBottom: Math.max(stop, entry),
    };
  }

  private getTargetText(geometry: SliderGeometry): string {
    const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = this.tickSize > 0 ? diff / this.tickSize : 0;

    const formattedDiff = formatPrice(diff) ?? '0';
    const formattedTicks = formatPrice(ticks) ?? '0';
    const formattedAmount = formatPrice(this.amount) ?? '0';

    return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedAmount}`;
  }

  private getCenterText(qty: number, pnl: number): string {
    const formattedQty = formatPrice(qty) ?? '0';
    const formattedRatio = formatPrice(this.getCurrentRiskRewardRatio()) ?? '0';

    return `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}\n${t('Risk/Reward Ratio')}: ${formattedRatio}`;
  }

  private getStopText(geometry: SliderGeometry): string {
    const stopDiff = Math.abs(geometry.stopPrice - geometry.entryPrice);
    const rewardDiff = Math.abs(geometry.targetPrice - geometry.entryPrice);
    const percent = geometry.entryPrice !== 0 ? (stopDiff / Math.abs(geometry.entryPrice)) * 100 : 0;
    const ticks = this.tickSize > 0 ? stopDiff / this.tickSize : 0;
    const qty = rewardDiff > 0 ? this.amount / rewardDiff : 0;
    const stopAmount = stopDiff * qty;

    const formattedStopDiff = formatPrice(stopDiff) ?? '0';
    const formattedTicks = formatPrice(ticks) ?? '0';
    const formattedStopAmount = formatPrice(stopAmount) ?? '0';

    return `${t('Stop')}: ${formattedStopDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedStopAmount}`;
  }

  private getHandleTarget(point: Point): DragTarget {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
      return 'entry';
    }

    if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
      return 'target';
    }

    if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
      return 'stop';
    }

    if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
      return 'end';
    }

    const minY = Math.min(geometry.targetY, geometry.stopY);
    const maxY = Math.max(geometry.targetY, geometry.stopY);

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: minY,
      bottom: maxY,
    };

    if (isPointInBounds(point, bounds)) {
      return 'body';
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    const minY = Math.min(geometry.targetY, geometry.stopY);
    const maxY = Math.max(geometry.targetY, geometry.stopY);

    const bounds: Bounds = {
      left: geometry.leftX,
      right: geometry.rightX,
      top: minY,
      bottom: maxY,
    };

    return isPointInBounds(point, bounds, HIT_TOLERANCE);
  }
}




import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitiveAxisView,
  PrimitivePaneViewZOrder,
} from 'lightweight-charts';

import type { AxisLabel, AxisSegment } from '@core/Drawings/types';

interface CustomPriceAxisPaneViewParams {
  getAxisSegments(): AxisSegment[];
  zOrder?: PrimitivePaneViewZOrder;
}

interface CustomPriceAxisViewParams {
  getAxisLabel(labelKind: string): AxisLabel | null;
  labelKind: string;
}

export class CustomPriceAxisPaneView implements IPrimitivePaneView {
  private readonly getAxisSegments: () => AxisSegment[];
  private readonly paneZOrder: PrimitivePaneViewZOrder;

  constructor({ getAxisSegments, zOrder = 'bottom' }: CustomPriceAxisPaneViewParams) {
    this.getAxisSegments = getAxisSegments;
    this.paneZOrder = zOrder;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new CustomPriceAxisPaneRenderer({
      getAxisSegments: this.getAxisSegments,
    });
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return this.paneZOrder;
  }
}

class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
  private readonly getAxisSegments: () => AxisSegment[];

  constructor({ getAxisSegments }: CustomPriceAxisPaneViewParams) {
    this.getAxisSegments = getAxisSegments;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const axisSegments = this.getAxisSegments();

    if (!axisSegments.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, verticalPixelRatio, bitmapSize }) => {
      context.save();

      for (const axisSegment of axisSegments) {
        const segmentStart = Math.min(axisSegment.from, axisSegment.to) * verticalPixelRatio;
        const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * verticalPixelRatio;

        context.fillStyle = axisSegment.color;
        context.fillRect(0, segmentStart, bitmapSize.width, segmentEnd - segmentStart);
      }

      context.restore();
    });
  }
}

export class CustomPriceAxisView implements ISeriesPrimitiveAxisView {
  private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
  private readonly labelKind: string;

  constructor({ getAxisLabel, labelKind }: CustomPriceAxisViewParams) {
    this.getAxisLabel = getAxisLabel;
    this.labelKind = labelKind;
  }

  public update(): void {}

  public visible(): boolean {
    return Boolean(this.getCurrentLabel()?.text);
  }

  public tickVisible(): boolean {
    return false;
  }

  public coordinate(): number {
    return this.getCurrentLabel()?.coordinate ?? 0;
  }

  public text(): string {
    return this.getCurrentLabel()?.text ?? '';
  }

  public textColor(): string {
    return this.getCurrentLabel()?.textColor ?? '';
  }

  public backColor(): string {
    return this.getCurrentLabel()?.backgroundColor ?? '';
  }

  private getCurrentLabel(): AxisLabel | null {
    return this.getAxisLabel(this.labelKind);
  }
}


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

import { layoutPriceAxisLabels } from './PriceAxisLabelsLayout';
import { getAxisSideBySeries, getContrastTextColor } from './utils';

import type { LaidOutPriceAxisLabel, MeasuredPriceAxisLabel, PriceAxisLabel, PriceAxisSide } from './types';

import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
  ChartOptions,
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitive,
  PrimitivePaneViewZOrder,
  SeriesAttachedParameter,
  Time,
} from 'lightweight-charts';

type ChartLayoutOptions = Readonly<ChartOptions['layout']>;

interface PriceAxisLabelsRenderState {
  labels: PriceAxisLabel[];
  reservedCoordinate: number | null;
  chart: SeriesAttachedParameter<Time>['chart'] | null;
  series: SeriesAttachedParameter<Time>['series'] | null;
}

interface LabelGeometry {
  label: LaidOutPriceAxisLabel;
  left: number;
  top: number;
  textX: number;
  textY: number;
}

const HORIZONTAL_PADDING_RATIO = 7.25 / 12;
const VERTICAL_PADDING_RATIO = 3.75 / 12;
const TEXT_VERTICAL_OFFSET_RATIO = 0.5 / 12;
const LABEL_EDGE_GAP = 1;

function getFont(layout: ChartLayoutOptions): string {
  return `${layout.fontSize}px ${layout.fontFamily}`;
}

function getLabelTextColor(label: PriceAxisLabel): string {
  if (label.style === 'outlined') {
    return label.color;
  }

  return getContrastTextColor(label.color);
}

function areLabelsEqual(currentLabels: PriceAxisLabel[], nextLabels: PriceAxisLabel[]): boolean {
  return (
    currentLabels.length === nextLabels.length &&
    currentLabels.every((currentLabel, index) => {
      const nextLabel = nextLabels[index];

      return (
        currentLabel.id === nextLabel.id &&
        currentLabel.desiredCoordinate === nextLabel.desiredCoordinate &&
        currentLabel.text === nextLabel.text &&
        currentLabel.color === nextLabel.color &&
        currentLabel.style === nextLabel.style &&
        currentLabel.priority === nextLabel.priority
      );
    })
  );
}

function measureLabels(
  context: CanvasRenderingContext2D,
  labels: PriceAxisLabel[],
  layout: ChartLayoutOptions,
): MeasuredPriceAxisLabel[] {
  const horizontalPadding = layout.fontSize * HORIZONTAL_PADDING_RATIO;
  const verticalPadding = layout.fontSize * VERTICAL_PADDING_RATIO;

  return labels.map((label) => {
    const textMetrics = context.measureText(label.text);
    const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent || layout.fontSize;

    return {
      ...label,
      width: Math.ceil(textMetrics.width) + horizontalPadding * 2,
      height: textHeight + verticalPadding * 2,
    };
  });
}

function createLabelGeometries(
  labels: MeasuredPriceAxisLabel[],
  axisWidth: number,
  axisHeight: number,
  side: PriceAxisSide,
  layout: ChartLayoutOptions,
  reservedCoordinate: number | null,
): LabelGeometry[] {
  const reservedHeight = labels.reduce(
    (maximumHeight, label) => Math.max(maximumHeight, label.height),
    layout.fontSize + layout.fontSize * VERTICAL_PADDING_RATIO * 2,
  );

  const laidOutLabels =
    reservedCoordinate === null
      ? layoutPriceAxisLabels(labels, axisHeight)
      : layoutPriceAxisLabels(labels, axisHeight, {
          reservedCoordinate,
          reservedHeight,
        });

  return laidOutLabels.map((label) => {
    const left =
      side === Direction.Right ? LABEL_EDGE_GAP : Math.max(LABEL_EDGE_GAP, axisWidth - label.width - LABEL_EDGE_GAP);

    return {
      label,
      left,
      top: label.coordinate - label.height / 2,
      textX: left + label.width / 2,
      textY: label.coordinate + layout.fontSize * TEXT_VERTICAL_OFFSET_RATIO,
    };
  });
}

function drawLabelBackgrounds(target: CanvasRenderingTarget2D, geometries: LabelGeometry[]): void {
  target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
    const { colors } = getThemeStore();

    context.save();

    geometries.forEach(({ label, left, top }) => {
      const bitmapLeft = Math.round(left * horizontalPixelRatio);
      const bitmapTop = Math.round(top * verticalPixelRatio);
      const bitmapWidth = Math.round(label.width * horizontalPixelRatio);
      const bitmapHeight = Math.round(label.height * verticalPixelRatio);

      context.fillStyle = label.style === 'outlined' ? colors.chartBackground : label.color;
      context.fillRect(bitmapLeft, bitmapTop, bitmapWidth, bitmapHeight);

      if (label.style !== 'outlined') {
        return;
      }

      const borderWidth = Math.max(1, Math.floor(Math.min(horizontalPixelRatio, verticalPixelRatio)));

      context.strokeStyle = label.color;
      context.lineWidth = borderWidth;
      context.strokeRect(
        bitmapLeft + borderWidth / 2,
        bitmapTop + borderWidth / 2,
        Math.max(0, bitmapWidth - borderWidth),
        Math.max(0, bitmapHeight - borderWidth),
      );
    });

    context.restore();
  });
}

function drawLabelTexts(
  target: CanvasRenderingTarget2D,
  geometries: LabelGeometry[],
  layout: ChartLayoutOptions,
): void {
  target.useMediaCoordinateSpace(({ context }) => {
    context.save();
    context.font = getFont(layout);
    context.textAlign = 'center';
    context.textBaseline = 'middle';

    geometries.forEach(({ label, textX, textY }) => {
      context.fillStyle = getLabelTextColor(label);
      context.fillText(label.text, textX, textY);
    });

    context.restore();
  });
}

class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly state: PriceAxisLabelsRenderState) {}

  public draw(target: CanvasRenderingTarget2D): void {
    const { labels, chart, series, reservedCoordinate } = this.state;
    const layout = chart?.options().layout;

    if (!layout || labels.length === 0) {
      return;
    }

    let geometries: LabelGeometry[] = [];

    target.useMediaCoordinateSpace(({ context, mediaSize }) => {
      if (mediaSize.width <= 0 || mediaSize.height <= 0) {
        return;
      }

      context.save();
      context.font = getFont(layout);

      const measuredLabels = measureLabels(context, labels, layout);

      geometries = createLabelGeometries(
        measuredLabels,
        mediaSize.width,
        mediaSize.height,
        getAxisSideBySeries(series),
        layout,
        reservedCoordinate,
      );

      context.restore();
    });

    if (geometries.length === 0) {
      return;
    }

    drawLabelBackgrounds(target, geometries);
    drawLabelTexts(target, geometries, layout);
  }
}

class PriceAxisLabelsPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: PriceAxisLabelsRenderer;

  constructor(private readonly state: PriceAxisLabelsRenderState) {
    this.rendererInstance = new PriceAxisLabelsRenderer(state);
  }

  public renderer(): IPrimitivePaneRenderer | null {
    return this.state.labels.length > 0 ? this.rendererInstance : null;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'bottom';
  }
}

export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
  private readonly state: PriceAxisLabelsRenderState = {
    labels: [],
    reservedCoordinate: null,
    chart: null,
    series: null,
  };

  private readonly priceAxisPaneView = new PriceAxisLabelsPaneView(this.state);
  private readonly priceAxisPaneViewList: IPrimitivePaneView[] = [this.priceAxisPaneView];
  private requestUpdate: (() => void) | null = null;

  public attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
    this.state.chart = chart;
    this.state.series = series;
    this.requestUpdate = requestUpdate;
    this.requestUpdate();
  }

  public detached(): void {
    this.state.chart = null;
    this.state.series = null;
    this.state.labels = [];
    this.state.reservedCoordinate = null;
    this.requestUpdate = null;
  }

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

  public updateAllViews(): void {}

  public setLabels(labels: PriceAxisLabel[], reservedCoordinate: number | null = null): void {
    if (areLabelsEqual(this.state.labels, labels) && this.state.reservedCoordinate === reservedCoordinate) {
      return;
    }

    this.state.labels = labels;
    this.state.reservedCoordinate = reservedCoordinate;
    this.requestUpdate?.();
  }

  public clear(): void {
    if (this.state.labels.length === 0 && this.state.reservedCoordinate === null) {
      return;
    }

    this.state.labels = [];
    this.state.reservedCoordinate = null;
    this.requestUpdate?.();
  }
}




import classNames from 'classnames';
import { Button, Tooltip } from 'exchange-elements/v2';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';

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

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

import type { Drawing } from '@core/Drawings';
import type { SettingsValues } from '@src/types/settings';
import type { PointerEvent, SyntheticEvent } from 'react';
import type { Observable } from 'rxjs';

interface FloatingDrawingToolbarProps {
  selectedDrawing$: Observable<Drawing | null>;
  onUpdateSettings: (settings: SettingsValues) => void;
  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$,
  onUpdateSettings,
  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 [settings, setSettings] = useState<SettingsValues>({});
  const [isLocked, setIsLocked] = useState(false);
  const [isDragging, setIsDragging] = useState(false);
  const [position, setPosition] = useState<Position | null>(null);

  const shouldShowToolbar = selectedDrawing?.hasSettings() ?? false;

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

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

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

      return;
    }

    const subscription = selectedDrawing.subscribeIsLocked(setIsLocked);

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

  useEffect(() => {
    if (!selectedDrawing || !shouldShowToolbar) {
      setSettings({});

      return;
    }

    const subscription = selectedDrawing.subscribeSettings(setSettings);

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

  useLayoutEffect(() => {
    if (!selectedDrawing || !shouldShowToolbar) {
      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, shouldShowToolbar]);

  const handleDragStart = (event: PointerEvent<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: PointerEvent<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: PointerEvent<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 || !shouldShowToolbar) {
    return null;
  }

  const toolbarSettings = selectedDrawing.getToolbarSettings();
  const lockLabel = isLocked ? t('Unlock') : t('Lock');

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

      {toolbarSettings.map((field) => {
        if (field.type !== 'color' || field.toolbar.control !== 'color') {
          return null;
        }

        return (
          <ToolbarColorControl
            key={field.key}
            role={field.toolbar.role}
            value={String(settings[field.key] ?? field.defaultValue)}
            onChange={(value) => {
              onUpdateSettings({
                [field.key]: value,
              });
            }}
          />
        );
      })}

      <Tooltip
        tooltipClassName={styles.toolbar_tooltip}
        showMessageOnFocus
        label={t('Settings')}
        location="top"
      >
        <Button
          size="sm"
          className={styles.button}
          onClick={onOpenSettings}
          label={<GearIcon />}
        />
      </Tooltip>

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

      <Tooltip
        tooltipClassName={styles.toolbar_tooltip}
        showMessageOnFocus
        label={t('Remove')}
        location="top"
      >
        <Button
          size="sm"
          className={styles.button}
          onClick={onDelete}
          label={<TrashIcon />}
        />
      </Tooltip>
    </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)),
  };
}