@supunlakmal/hooks
Version:
A collection of reusable React hooks
137 lines • 6.67 kB
JavaScript
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