Загрузка данных
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': 'Переместить панель',