UNPKG

@supunlakmal/hooks

Version:

A collection of reusable React hooks

137 lines 6.67 kB
import { useRef, useState, useEffect, useCallback } from 'react'; /** * Makes a DOM element draggable using pointer events, supporting constraints and callbacks. * * @param ref React ref object attached to the draggable element. * @param options Configuration options for draggable behavior. * @returns State including the element's position and dragging status. */ export const useDraggable = (ref, options = {}) => { const { initialPosition = { x: 0, y: 0 }, boundsRef, bounds: explicitBounds, onDragStart, onDrag, onDragEnd, position: controlledPosition, onPositionChange, } = options; const isControlled = controlledPosition !== undefined && onPositionChange !== undefined; const [internalPosition, setInternalPosition] = useState(initialPosition); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef(null); const elementStartOffsetRef = useRef(null); const position = isControlled ? controlledPosition : internalPosition; const getConstrainedPosition = useCallback((newX, newY) => { const element = ref.current; if (!element) return { x: newX, y: newY }; let currentBounds = explicitBounds; if (!currentBounds && (boundsRef === null || boundsRef === void 0 ? void 0 : boundsRef.current)) { const parentRect = boundsRef.current.getBoundingClientRect(); const elemRect = element.getBoundingClientRect(); // Need current element size // Calculate bounds relative to the parent origin currentBounds = { top: 0, left: 0, right: parentRect.width - elemRect.width, bottom: parentRect.height - elemRect.height, }; } if (currentBounds) { const constrainedX = Math.max(currentBounds.left, Math.min(newX, currentBounds.right)); const constrainedY = Math.max(currentBounds.top, Math.min(newY, currentBounds.bottom)); return { x: constrainedX, y: constrainedY }; } return { x: newX, y: newY }; }, [ref, boundsRef, explicitBounds]); const handlePointerDown = useCallback((event) => { if (!ref.current || event.button !== 0) return; // Only handle left click // Prevent default only if the target is the draggable element itself (or allow specific handles) if (event.target === ref.current) { // Prevent text selection during drag event.preventDefault(); // Capture pointer events to ensure we get pointerup even if cursor leaves the element event.target.setPointerCapture(event.pointerId); } setIsDragging(true); dragStartRef.current = { x: event.clientX, y: event.clientY }; elementStartOffsetRef.current = Object.assign({}, position); // Store position at drag start onDragStart === null || onDragStart === void 0 ? void 0 : onDragStart(position, event); }, [ref, position, onDragStart]); const handlePointerMove = useCallback((event) => { if (!isDragging || !dragStartRef.current || !elementStartOffsetRef.current || !ref.current) return; const dx = event.clientX - dragStartRef.current.x; const dy = event.clientY - dragStartRef.current.y; const newX = elementStartOffsetRef.current.x + dx; const newY = elementStartOffsetRef.current.y + dy; const constrainedPos = getConstrainedPosition(newX, newY); if (isControlled) { // If controlled, check if position actually changed before calling callback if (constrainedPos.x !== position.x || constrainedPos.y !== position.y) { onPositionChange(constrainedPos); onDrag === null || onDrag === void 0 ? void 0 : onDrag(constrainedPos, event); } } else { setInternalPosition(constrainedPos); onDrag === null || onDrag === void 0 ? void 0 : onDrag(constrainedPos, event); } }, [ isDragging, position, isControlled, onPositionChange, getConstrainedPosition, onDrag, ref, ]); const handlePointerUp = useCallback((event) => { if (!isDragging || !ref.current) return; if (event.target.hasPointerCapture(event.pointerId)) { event.target.releasePointerCapture(event.pointerId); } setIsDragging(false); onDragEnd === null || onDragEnd === void 0 ? void 0 : onDragEnd(position, event); dragStartRef.current = null; elementStartOffsetRef.current = null; }, [isDragging, position, onDragEnd, ref]); useEffect(() => { const element = ref.current; if (!element) return; // Use pointer events for better compatibility across devices element.addEventListener('pointerdown', handlePointerDown); // Attach move and up listeners to the window to handle dragging outside the element window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); // Handle cancellations // Apply initial/current position using transform for performance element.style.transform = `translate(${position.x}px, ${position.y}px)`; // Ensure position: relative or absolute is set on the element by the user for transform to work as expected // element.style.position = 'relative'; // Or 'absolute' - uncomment if needed, but better set via CSS element.style.cursor = isDragging ? 'grabbing' : 'grab'; element.style.userSelect = isDragging ? 'none' : 'auto'; // Prevent text selection while dragging return () => { element.removeEventListener('pointerdown', handlePointerDown); window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); // Cleanup inline styles potentially? // element.style.cursor = 'auto'; // element.style.userSelect = 'auto'; }; // Rerun effect if position changes externally (controlled) or if handlers change }, [ ref, position, isDragging, handlePointerDown, handlePointerMove, handlePointerUp, ]); return { position, isDragging, }; }; //# sourceMappingURL=useDraggable.js.map