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


export type SettingValue = string | number | boolean | any;
export type SettingsValues = Record<string, SettingValue>;

export type ToolbarColorRole = 'line' | 'fill' | 'text';

export interface ToolbarColorControlConfig {
  control: 'color';
  role: ToolbarColorRole;
}

export type ToolbarControlConfig = ToolbarColorControlConfig;

interface BaseSettingField {
  key: string;
  label: string;
  toolbar?: ToolbarControlConfig;
}

export interface NumberSettingField extends BaseSettingField {
  type: 'number';
  defaultValue: number;
  min?: number;
  max?: number;
}

export interface RangeSettingField extends BaseSettingField {
  type: 'range';
  defaultValue: number;
  min?: number;
  max?: number;
  step?: number;
  color?: string;
  suffix?: string;
}

export interface SelectSettingField extends BaseSettingField {
  type: 'select';
  defaultValue: string | number;
  options: { label: string; value: string | number }[];
}

export interface ColorSettingField extends BaseSettingField {
  type: 'color';
  defaultValue: string;
}

export interface TextSettingField extends BaseSettingField {
  type: 'text';
  defaultValue: string;
  placeholder?: string;
}

export interface TextAreaSettingField extends BaseSettingField {
  type: 'textarea';
  defaultValue: string;
  placeholder?: string;
}

export interface BooleanSettingField extends BaseSettingField {
  type: 'boolean';
  defaultValue: boolean;
}

export type SettingField =
  | NumberSettingField
  | RangeSettingField
  | SelectSettingField
  | ColorSettingField
  | TextSettingField
  | TextAreaSettingField
  | BooleanSettingField;

export type ToolbarSettingField = SettingField & {
  toolbar: ToolbarControlConfig;
};

export interface SettingsTab<TField extends SettingField = SettingField> {
  key: string;
  label: string;
  fields: TField[];
}


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

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

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

export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
  show(): void;
  hide(): void;
  rebind(series: ISeriesApi<SeriesType>): void;
  destroy(): void;
  isCreationPending(): boolean;
  waitTillReady(): Promise<void>;
  shouldShowInObjectTree(): boolean;
  getState(): unknown;
  setState(state: unknown): void;
  getSettings(): SettingsValues;
  updateSettings(settings: SettingsValues): void;
  getSettingsTabs(): SettingsTab[];
  subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
  subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription;
  subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription;
  isSelected(): boolean;
  isLocked(): boolean;
  setLocked(isLocked: boolean): void;
  getRenderData(): unknown;
}

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

export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
  protected hidden = false;
  protected chart: IChartApi;
  protected series: SeriesApi;
  protected subscriptions = new Subscription();
  protected abstract mode: unknown;
  protected abstract settings: TSettings;
  protected readonly container: HTMLElement;
  protected isActive = new BehaviorSubject(false);
  protected isBound = false;

  private readonly isLockedSubject = new BehaviorSubject(false);
  private readonly settingsSubject = new Subject<SettingsValues>();

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

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

  public subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription {
    return this.isActive.pipe(distinctUntilChanged()).subscribe(callback);
  }

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

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

    return this.settingsSubject.subscribe(callback);
  }

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

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

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

    this.isLockedSubject.next(isLocked);

    if (isLocked) {
      this.showCrosshair();
    }

    this.render();
  }

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

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

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

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

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

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

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

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

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

    return this.readyPromise;
  }

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

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

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

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

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

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

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

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

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

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

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

  protected shouldShowHandles(): boolean {
    return this.isActive.value && !this.isLocked();
  }

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

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

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

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

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

    this.isBound = true;

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

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

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

    this.isBound = false;

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

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

  protected handleContextMenu(event: MouseEvent): void {}
  protected handleDoubleClick(event: MouseEvent): void {}
  protected handlePointerDown(event: PointerEvent): void {}
  protected handlePointerMove(event: PointerEvent): void {}
  protected handlePointerUp(event: PointerEvent): void {}

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

  private handlePointerDownEvent = (event: PointerEvent): void => {
    if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
      this.handlePointerDown(event);

      return;
    }

    const point = this.getEventPoint(event);
    const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;

    if (this.isActive.value === isDrawingHit) {
      return;
    }

    this.isActive.next(isDrawingHit);
    this.render();
  };
}


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, ToolbarSettingField } 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 subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
    return this.lwcDrawing.subscribeSettings(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 getToolbarSettings(): ToolbarSettingField[] {
    return this.getSettingsTabs()
      .flatMap((tab) => tab.fields)
      .filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
  }

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

  public destroy(): void {
    this.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 { 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,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
    {
      key: 'fillColor',
      label: t('Background color'),
      type: 'color',
      defaultValue: settings.fillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
  ];

  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,
      toolbar: {
        control: 'color',
        role: 'text',
      },
    },
  ];

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


import classNames from 'classnames';

import { ColorPicker, normalizeHexColor } from '@src/components/ColorPicker';
import { Dropdown } from '@src/components/Dropdown';
import { t } from '@src/translations';

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

import type { ToolbarColorRole } from '@src/types/settings';

interface ToolbarColorControlProps {
  role: ToolbarColorRole;
  value: string;
  onChange: (value: string) => void;
}

const LABEL_KEY_BY_ROLE = {
  line: 'Line color',
  fill: 'Fill color',
  text: 'Text color',
} as const;

export function ToolbarColorControl({ role, value, onChange }: ToolbarColorControlProps) {
  const currentColor = normalizeHexColor(value);
  const label = t(LABEL_KEY_BY_ROLE[role]);

  return (
    <Dropdown
      position="bottom"
      horizontalAlign="auto"
      menuClassName={styles['toolbar-color__menu']}
      renderTrigger={({ isOpen, onToggle }) => (
        <button
          type="button"
          className={classNames(styles['toolbar-color__trigger'], {
            [styles['toolbar-color__trigger--open']]: isOpen,
          })}
          aria-label={label}
          data-tooltip={label}
          onClick={onToggle}
        >
          <span
            className={styles['toolbar-color__icon']}
            data-role={role}
            aria-hidden="true"
          />
          <span className={styles['toolbar-color__preview']}>
            <span
              className={styles['toolbar-color__preview-value']}
              style={{ backgroundColor: currentColor }}
            />
          </span>
        </button>
      )}
    >
      <ColorPicker
        value={currentColor}
        onChange={onChange}
      />
    </Dropdown>
  );
}

.toolbar-color {
  &__trigger {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    padding: 4px 6px 7px;
    border: 0;
    background-color: transparent;
    color: inherit;
    cursor: pointer;

    &::before {
      content: attr(data-tooltip);
      position: absolute;
      z-index: 10;
      bottom: calc(100% + 8px);
      left: 50%;
      padding: 4px 8px;
      border-radius: 4px;
      background-color: var(--neutral-12);
      color: var(--neutral-1);
      font-family: var(--font-family);
      font-size: 12px;
      line-height: 16px;
      white-space: nowrap;
      pointer-events: none;
      visibility: hidden;
      opacity: 0;
      transform: translate(-50%, 4px);
      transition:
        opacity 120ms ease,
        transform 120ms ease,
        visibility 120ms ease;
    }

    &:hover,
    &:focus-visible,
    &--open {
      background-color: var(--neutral-3);
    }

    &:hover,
    &:focus-visible {
      &::before {
        visibility: visible;
        opacity: 1;
        transform: translate(-50%, 0);
      }
    }

    &--open {
      &::before {
        display: none;
      }
    }
  }

  &__icon {
    display: block;
    width: 20px;
    height: 20px;
  }

  &__preview {
    position: absolute;
    right: 6px;
    bottom: 3px;
    left: 6px;
    height: 3px;
    overflow: hidden;
    border-radius: 1px;
    background:
      linear-gradient(45deg, var(--neutral-4) 25%, transparent 25%),
      linear-gradient(-45deg, var(--neutral-4) 25%, transparent 25%),
      linear-gradient(45deg, transparent 75%, var(--neutral-4) 75%),
      linear-gradient(-45deg, transparent 75%, var(--neutral-4) 75%);
    background-color: var(--neutral-2);
    background-position:
      0 0,
      0 2px,
      2px -2px,
      -2px 0;
    background-size: 4px 4px;
  }

  &__preview-value {
    display: block;
    width: 100%;
    height: 100%;
  }

  &__menu {
    padding: 0;
  }
}


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 { ToolbarColorControl } from './ToolbarColorControl';

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

import type { Drawing } from '@core/Drawings';
import type { SettingsValues } from '@src/types/settings';
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 [settings, setSettings] = useState<SettingsValues>({});
  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]);

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

      return;
    }

    const subscription = selectedDrawing.subscribeSettings(setSettings);

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

  const toolbarSettings = selectedDrawing.getToolbarSettings();

  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>

      {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) => {
              selectedDrawing.updateSettings({
                [field.key]: value,
              });
            }}
          />
        );
      })}

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


'Line color': 'Цвет линии',
'Fill color': 'Цвет заливки',
'Text color': 'Цвет текста',