/** * @file Generic hook for handling pointer drag operations with threshold detection * Optimized with useEffectEvent for stable callback references */ import * as React from "react"; import { Position } from "../types/core"; export type PointerDragOptions = { onStart?: (event: PointerEvent, data: T) => void; onMove?: (event: PointerEvent, delta: Position, data: T) => void; onEnd?: (event: PointerEvent, delta: Position, data: T) => void; scale?: number; threshold?: number; disabled?: boolean; }; export type PointerDragState = { isDragging: boolean; dragStarted: boolean; delta: Position; }; /** * Generic hook for handling pointer drag operations * Consolidates drag logic used across nodes, connections, and resize operations * Uses useEffectEvent for stable callback references to avoid re-registering listeners */ export function usePointerDrag( options: PointerDragOptions, ): { startDrag: (event: React.PointerEvent | PointerEvent, data: T) => void; dragState: PointerDragState; } { const { onStart, onMove, onEnd, scale = 1, threshold = 2, disabled = false } = options; const [dragState, setDragState] = React.useState({ isDragging: false, dragStarted: false, delta: { x: 0, y: 0 }, }); const dragDataRef = React.useRef(null); const startPositionRef = React.useRef({ x: 0, y: 0 }); const currentDeltaRef = React.useRef({ x: 0, y: 0 }); const dragStartedRef = React.useRef(false); // Keep dragStartedRef in sync with state for use in event handlers React.useEffect(() => { dragStartedRef.current = dragState.dragStarted; }, [dragState.dragStarted]); // Use useEffectEvent for stable handlers that access latest props const handlePointerMoveEvent = React.useEffectEvent((event: PointerEvent) => { if (!dragDataRef.current) { return; } const delta = { x: (event.clientX - startPositionRef.current.x) / scale, y: (event.clientY - startPositionRef.current.y) / scale, }; currentDeltaRef.current = delta; // Check if we've moved past the threshold if (!dragStartedRef.current) { const distance = Math.sqrt(delta.x * delta.x + delta.y * delta.y); if (distance >= threshold) { dragStartedRef.current = true; setDragState({ isDragging: true, dragStarted: true, delta, }); onStart?.(event, dragDataRef.current); } } else { setDragState((prev) => ({ ...prev, delta })); onMove?.(event, delta, dragDataRef.current); } }); const cleanupListenersRef = React.useRef<(() => void) | null>(null); const handlePointerUpEvent = React.useEffectEvent((event: PointerEvent) => { if (!dragDataRef.current) { return; } const wasDragging = dragStartedRef.current; const finalDelta = currentDeltaRef.current; const data = dragDataRef.current; // Clean up listeners first if (cleanupListenersRef.current) { cleanupListenersRef.current(); cleanupListenersRef.current = null; } // Reset refs immediately dragDataRef.current = null; currentDeltaRef.current = { x: 0, y: 0 }; dragStartedRef.current = false; // Reset state setDragState({ isDragging: false, dragStarted: false, delta: { x: 0, y: 0 }, }); // Call onEnd only if we actually started dragging if (wasDragging) { onEnd?.(event, finalDelta, data); } }); const startDrag = React.useEffectEvent((event: React.PointerEvent | PointerEvent, data: T) => { if (disabled) { return; } // Convert React event to native event if needed const nativeEvent = "nativeEvent" in event ? event.nativeEvent : event; dragDataRef.current = data; startPositionRef.current = { x: nativeEvent.clientX, y: nativeEvent.clientY, }; // Set pointer capture if available const target = nativeEvent.target as HTMLElement; if (target?.setPointerCapture) { target.setPointerCapture(nativeEvent.pointerId); } // Add event listeners document.addEventListener("pointermove", handlePointerMoveEvent); document.addEventListener("pointerup", handlePointerUpEvent); document.addEventListener("pointercancel", handlePointerUpEvent); // Store cleanup function cleanupListenersRef.current = () => { document.removeEventListener("pointermove", handlePointerMoveEvent); document.removeEventListener("pointerup", handlePointerUpEvent); document.removeEventListener("pointercancel", handlePointerUpEvent); }; setDragState({ isDragging: true, dragStarted: false, delta: { x: 0, y: 0 }, }); }); // Clean up on unmount React.useEffect(() => { return () => { if (cleanupListenersRef.current) { cleanupListenersRef.current(); } }; }, []); return { startDrag, dragState, }; }