39948-vm/frontend/src/hooks/useDraggable.ts
2026-03-29 16:03:25 +04:00

201 lines
5.5 KiB
TypeScript

/**
* useDraggable Hook
*
* Generic draggable panel management with pointer tracking.
* Used in constructor.tsx for draggable controls, menu, and editor panels.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
interface Position {
x: number;
y: number;
}
interface DragState {
pointerOffsetX: number;
pointerOffsetY: number;
}
interface UseDraggableOptions {
/** Initial position */
initialPosition?: Position;
/** Minimum x position (default: 0) */
minX?: number;
/** Minimum y position (default: 0) */
minY?: number;
/** Maximum x position (calculated from window if not provided) */
maxX?: number;
/** Maximum y position (calculated from window if not provided) */
maxY?: number;
/** Element width for calculating max bounds */
elementWidth?: number;
/** Element height for calculating max bounds */
elementHeight?: number;
}
interface UseDraggableResult {
/** Current position */
position: Position;
/** Set position directly */
setPosition: (position: Position) => void;
/** Whether currently dragging */
isDragging: boolean;
/** Handler to attach to drag handle's onMouseDown */
onDragStart: (event: React.MouseEvent) => void;
/** Handler to attach to drag handle (alternative that ignores button clicks) */
onDragStartIgnoreButtons: (event: React.MouseEvent) => void;
}
/**
* Clamp a value between min and max
*/
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
/**
* Hook for making elements draggable with mouse.
* Handles pointer tracking and bounds clamping.
*
* @example
* const { position, onDragStart, isDragging } = useDraggable({
* initialPosition: { x: 20, y: 20 },
* elementWidth: 400,
* });
*
* return (
* <div style={{ left: position.x, top: position.y }}>
* <div onMouseDown={onDragStart}>Drag Handle</div>
* <div>Content</div>
* </div>
* );
*/
export function useDraggable({
initialPosition = { x: 0, y: 0 },
minX = 0,
minY = 0,
maxX,
maxY,
elementWidth = 0,
elementHeight = 0,
}: UseDraggableOptions = {}): UseDraggableResult {
const [position, setPosition] = useState<Position>(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<DragState | null>(null);
// Calculate max bounds
const getMaxX = useCallback(() => {
if (maxX !== undefined) return maxX;
if (typeof window === 'undefined') return Infinity;
return Math.max(window.innerWidth - elementWidth, 0);
}, [maxX, elementWidth]);
const getMaxY = useCallback(() => {
if (maxY !== undefined) return maxY;
if (typeof window === 'undefined') return Infinity;
return Math.max(window.innerHeight - elementHeight, 0);
}, [maxY, elementHeight]);
// Pointer move handler
useEffect(() => {
const onPointerMove = (event: MouseEvent) => {
if (!dragRef.current) return;
const nextX = clamp(
event.clientX - dragRef.current.pointerOffsetX,
minX,
getMaxX(),
);
const nextY = clamp(
event.clientY - dragRef.current.pointerOffsetY,
minY,
getMaxY(),
);
setPosition({ x: nextX, y: nextY });
};
const onPointerUp = () => {
if (dragRef.current) {
dragRef.current = null;
setIsDragging(false);
}
};
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
return () => {
window.removeEventListener('mousemove', onPointerMove);
window.removeEventListener('mouseup', onPointerUp);
};
}, [minX, minY, getMaxX, getMaxY]);
// Initialize position from window dimensions and clamp to bounds
useEffect(() => {
if (typeof window === 'undefined') return;
// Clamp initial position to viewport bounds
const clampedX = clamp(initialPosition.x, minX, getMaxX());
const clampedY = clamp(initialPosition.y, minY, getMaxY());
// Update if position needs adjustment
if (position.x !== clampedX || position.y !== clampedY) {
setPosition({ x: clampedX, y: clampedY });
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onDragStart = useCallback((event: React.MouseEvent) => {
const targetRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect();
dragRef.current = {
pointerOffsetX: event.clientX - targetRect.left,
pointerOffsetY: event.clientY - targetRect.top,
};
setIsDragging(true);
}, []);
const onDragStartIgnoreButtons = useCallback(
(event: React.MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('button')) return;
onDragStart(event);
},
[onDragStart],
);
return {
position,
setPosition,
isDragging,
onDragStart,
onDragStartIgnoreButtons,
};
}
/**
* Create multiple draggable instances with shared pointer tracking.
* More efficient than multiple useDraggable hooks when only one can drag at a time.
*/
export function useMultipleDraggables(
configs: Record<string, UseDraggableOptions>,
): Record<string, UseDraggableResult> {
// This is a convenience wrapper - in practice, constructor.tsx
// manages multiple drag refs manually for efficiency.
// Individual useDraggable hooks work fine for most cases.
const results: Record<string, UseDraggableResult> = {};
for (const [key, config] of Object.entries(configs)) {
// eslint-disable-next-line react-hooks/rules-of-hooks
results[key] = useDraggable(config);
}
return results;
}
export default useDraggable;