Загрузка данных
private handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getPointTarget(point);
const isNearLine = this.isPointNearLine(point);
const isDrawingHit = pointTarget !== null || isNearLine;
if (this.isLocked()) {
if (isDrawingHit && !this.isActive.value) {
this.isActive.next(true);
this.render();
}
if (!isDrawingHit && this.isActive.value) {
this.isActive.next(false);
this.render();
}
return;
}
if (!this.isActive.value) {
if (!isDrawingHit) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'end') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-end', point, event.pointerId);
return;
}
if (isNearLine) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.isActive.next(false);
this.render();
};
public getToolbarAnchor(): Point | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
x: (geometry.startPoint.x + geometry.endPoint.x) / 2,
y: (geometry.startPoint.y + geometry.endPoint.y) / 2,
};
}
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';
import { useEffect, 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 { Point } from '@core/Drawings/types';
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 INITIAL_POSITION: Position = {
x: 12,
y: 12,
};
const TOOLBAR_GAP = 8;
export function FloatingDrawingToolbar({
selectedDrawing$,
onToggleLock,
onOpenSettings,
onDelete,
}: FloatingDrawingToolbarProps) {
const toolbarRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const hasManualPositionRef = useRef(false);
const [selectedDrawing, setSelectedDrawing] = useState<Drawing | null>(null);
const [isLocked, setIsLocked] = useState(false);
const [position, setPosition] = useState<Position>(INITIAL_POSITION);
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 || hasManualPositionRef.current) {
return;
}
let frameId: number;
const updatePosition = (): void => {
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
const anchor = selectedDrawing.getToolbarAnchor();
if (toolbar && container && anchor) {
const nextPosition = getAutoPosition(anchor, toolbar, container);
setPosition((currentPosition) => {
if (currentPosition.x === nextPosition.x && currentPosition.y === nextPosition.y) {
return currentPosition;
}
return nextPosition;
});
}
frameId = requestAnimationFrame(updatePosition);
};
frameId = requestAnimationFrame(updatePosition);
return () => {
cancelAnimationFrame(frameId);
};
}, [selectedDrawing]);
const clampPosition = (x: number, y: number): Position => {
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (!toolbar || !container) {
return { x, y };
}
return {
x: Math.max(0, Math.min(x, container.clientWidth - toolbar.offsetWidth)),
y: Math.max(0, Math.min(y, container.clientHeight - toolbar.offsetHeight)),
};
};
const handleDragStart = (event: ReactPointerEvent<HTMLButtonElement>): void => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
hasManualPositionRef.current = true;
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
initialX: position.x,
initialY: position.y,
};
};
const handleDrag = (event: ReactPointerEvent<HTMLButtonElement>): void => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
setPosition(
clampPosition(
dragState.initialX + event.clientX - dragState.startX,
dragState.initialY + event.clientY - dragState.startY,
),
);
};
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;
};
const stopPropagation = (event: SyntheticEvent): void => {
event.stopPropagation();
};
if (!selectedDrawing) {
return null;
}
return (
<div
ref={toolbarRef}
className={styles.toolbar}
style={{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
}}
onClick={stopPropagation}
onContextMenu={stopPropagation}
onDoubleClick={stopPropagation}
onPointerCancel={stopPropagation}
onPointerDown={stopPropagation}
onPointerMove={stopPropagation}
onPointerUp={stopPropagation}
>
<button
type="button"
className={styles.toolbar_handle}
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 getAutoPosition(
anchor: Point,
toolbar: HTMLElement,
container: HTMLElement,
): Position {
const centeredX = anchor.x - toolbar.offsetWidth / 2;
const positionAbove = anchor.y - toolbar.offsetHeight - TOOLBAR_GAP;
const positionBelow = anchor.y + TOOLBAR_GAP;
const x = Math.max(
0,
Math.min(
Math.round(centeredX),
container.clientWidth - toolbar.offsetWidth,
),
);
const y = positionAbove >= 0
? positionAbove
: positionBelow;
return {
x,
y: Math.max(
0,
Math.min(
Math.round(y),
container.clientHeight - toolbar.offsetHeight,
),
),
};
}