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


import classNames from 'classnames';
import { Tooltip } from 'exchange-elements/v2';

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';
import type { ComponentType } from 'react';

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

interface ToolbarColorConfig {
  labelKey: 'Line color' | 'Fill color' | 'Text color';
  Icon: ComponentType;
}

const TOOLBAR_COLOR_CONFIG: Record<ToolbarColorRole, ToolbarColorConfig> = {
  line: {
    labelKey: 'Line color',
    Icon: EmptyIcon,
  },
  fill: {
    labelKey: 'Fill color',
    Icon: EmptyIcon,
  },
  text: {
    labelKey: 'Text color',
    Icon: EmptyIcon,
  },
};

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

  return (
    <Dropdown
      position="bottom"
      horizontalAlign="auto"
      renderTrigger={({ isOpen, onToggle }) => {
        const trigger = (
          <button
            type="button"
            className={classNames(styles['toolbar-color__trigger'], {
              [styles['toolbar-color__trigger--open']]: isOpen,
            })}
            aria-label={label}
            onClick={onToggle}
          >
            <span className={styles['toolbar-color__icon']}>
              <Icon />
            </span>
            <span
              className={styles['toolbar-color__preview']}
              style={{ backgroundColor: currentColor }}
            />
          </button>
        );

        if (isOpen) {
          return trigger;
        }

        return (
          <Tooltip
            tooltipClassName={styles['toolbar-color__tooltip']}
            showMessageOnFocus
            label={label}
            location="top"
          >
            {trigger}
          </Tooltip>
        );
      }}
    >
      <ColorPicker
        value={currentColor}
        onChange={onChange}
      />
    </Dropdown>
  );
}

function EmptyIcon() {
  return <span aria-hidden="true" />;
}


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 as ReactPointerEvent, ReactNode, SyntheticEvent } from 'react';
import type { Observable } from 'rxjs';

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

interface ToolbarActionButtonProps {
  label: string;
  icon: ReactNode;
  pressed?: boolean;
  onClick: () => 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();
  const lockLabel = isLocked ? t('Unlock') : t('Lock');

  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['toolbar__handle--dragging']]: isDragging,
        })}
        aria-label={t('Move toolbar')}
        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() && (
        <ToolbarActionButton
          label={t('Settings')}
          icon={<GearIcon />}
          onClick={onOpenSettings}
        />
      )}

      <ToolbarActionButton
        label={lockLabel}
        icon={isLocked ? <LockIcon /> : <LockOpenIcon />}
        pressed={isLocked}
        onClick={onToggleLock}
      />

      <ToolbarActionButton
        label={t('Remove')}
        icon={<TrashIcon />}
        onClick={onDelete}
      />
    </div>
  );
}

function ToolbarActionButton({
  label,
  icon,
  pressed = false,
  onClick,
}: ToolbarActionButtonProps) {
  return (
    <Tooltip
      tooltipClassName={styles['toolbar__tooltip']}
      showMessageOnFocus
      label={label}
      location="top"
    >
      <Button
        size="sm"
        className={classNames(styles['toolbar__button'], {
          [styles['toolbar__button--pressed']]: pressed,
        })}
        aria-label={label}
        aria-pressed={pressed}
        onClick={onClick}
        label={icon}
      />
    </Tooltip>
  );
}

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





Lock: 'Заблокировать',
Unlock: 'Разблокировать',
Remove: 'Удалить',
'Move toolbar': 'Переместить панель',