/** * 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 ( *
*
Drag Handle
*
Content
*
* ); */ export function useDraggable({ initialPosition = { x: 0, y: 0 }, minX = 0, minY = 0, maxX, maxY, elementWidth = 0, elementHeight = 0, }: UseDraggableOptions = {}): UseDraggableResult { const [position, setPosition] = useState(initialPosition); const [isDragging, setIsDragging] = useState(false); const dragRef = useRef(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, ): Record { // 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 = {}; 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;