201 lines
5.5 KiB
TypeScript
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;
|