Загрузка данных
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import {
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import {
GearIcon,
LockIcon,
LockOpenIcon,
TrashIcon,
} from '@src/components/Icon';
import styles from './index.module.scss';
import type { Drawing } from '@core/Drawings';
import type {
PointerEvent as ReactPointerEvent,
SyntheticEvent,
} from 'react';
import type { Observable } from 'rxjs';
interface FloatingDrawingToolbarProps {
selectedDrawing$: Observable<Drawing | null>;
onToggleLock: () => void;
onOpenSettings: () => void;
onDelete: () => void;
}
interface Position {
x: number;
y: number;
}
interface DragState {
pointerId: number;
startX: number;
startY: number;
initialX: number;
initialY: number;
}
const TOOLBAR_TOP_OFFSET = 12;
export function FloatingDrawingToolbar({
selectedDrawing$,
onToggleLock,
onOpenSettings,
onDelete,
}: FloatingDrawingToolbarProps) {
const toolbarRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const positionRef = useRef<Position | null>(null);
const [selectedDrawing, setSelectedDrawing] =
useState<Drawing | null>(null);
const [isLocked, setIsLocked] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState<Position | null>(null);
useEffect(() => {
const subscription =
selectedDrawing$.subscribe(setSelectedDrawing);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing$]);
useEffect(() => {
if (!selectedDrawing) {
setIsLocked(false);
return;
}
const subscription =
selectedDrawing.subscribeIsLocked(setIsLocked);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing]);
useLayoutEffect(() => {
if (!selectedDrawing) {
return;
}
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (!toolbar || !container) {
return;
}
const currentPosition = positionRef.current;
if (currentPosition) {
updatePosition(
clampPosition(
currentPosition.x,
currentPosition.y,
toolbar,
container,
),
);
return;
}
updatePosition({
x: Math.round(
(container.clientWidth - toolbar.offsetWidth) / 2,
),
y: TOOLBAR_TOP_OFFSET,
});
}, [selectedDrawing]);
const handleDragStart = (
event: ReactPointerEvent<HTMLButtonElement>,
): void => {
const currentPosition = positionRef.current;
if (event.button !== 0 || !currentPosition) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
initialX: currentPosition.x,
initialY: currentPosition.y,
};
setIsDragging(true);
};
const handleDrag = (
event: ReactPointerEvent<HTMLButtonElement>,
): void => {
const dragState = dragStateRef.current;
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (
!dragState ||
dragState.pointerId !== event.pointerId ||
!toolbar ||
!container
) {
return;
}
event.preventDefault();
updatePosition(
clampPosition(
dragState.initialX +
event.clientX -
dragState.startX,
dragState.initialY +
event.clientY -
dragState.startY,
toolbar,
container,
),
);
};
const handleDragEnd = (
event: ReactPointerEvent<HTMLButtonElement>,
): void => {
if (dragStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
setIsDragging(false);
};
const stopPropagation = (event: SyntheticEvent): void => {
event.stopPropagation();
};
function updatePosition(nextPosition: Position): void {
positionRef.current = nextPosition;
setPosition((currentPosition) => {
if (
currentPosition?.x === nextPosition.x &&
currentPosition.y === nextPosition.y
) {
return currentPosition;
}
return nextPosition;
});
}
if (!selectedDrawing) {
return null;
}
return (
<div
ref={toolbarRef}
className={styles.toolbar}
style={{
visibility: position ? 'visible' : 'hidden',
transform: `translate3d(
${position?.x ?? 0}px,
${position?.y ?? 0}px,
0
)`,
}}
onClick={stopPropagation}
onContextMenu={stopPropagation}
onDoubleClick={stopPropagation}
onPointerCancel={stopPropagation}
onPointerDown={stopPropagation}
onPointerMove={stopPropagation}
onPointerUp={stopPropagation}
>
<button
type="button"
className={classNames(styles.toolbar_handle, {
[styles.dragging]: isDragging,
})}
onPointerCancel={handleDragEnd}
onPointerDown={handleDragStart}
onPointerMove={handleDrag}
onPointerUp={handleDragEnd}
>
<span />
<span />
<span />
<span />
<span />
<span />
</button>
{selectedDrawing.hasSettings() && (
<Button
size="sm"
className={styles.button}
onClick={onOpenSettings}
label={<GearIcon />}
/>
)}
<Button
size="sm"
className={classNames(styles.button, {
[styles.pressed]: isLocked,
})}
onClick={onToggleLock}
label={isLocked ? <LockIcon /> : <LockOpenIcon />}
/>
<Button
size="sm"
className={styles.button}
onClick={onDelete}
label={<TrashIcon />}
/>
</div>
);
}
function clampPosition(
x: number,
y: number,
toolbar: HTMLElement,
container: HTMLElement,
): Position {
return {
x: Math.max(
0,
Math.min(
Math.round(x),
container.clientWidth - toolbar.offsetWidth,
),
),
y: Math.max(
0,
Math.min(
Math.round(y),
container.clientHeight - toolbar.offsetHeight,
),
),
};
}