Fix drag-and-drop hover detection and slot targeting

Two root-cause bugs fixed:

1. Port <button> elements inside ModuleBlock had pointer-events:auto (browser
   default), so document.elementFromPoint() hit them instead of the RackSlot
   behind them whenever the cursor was over an occupied slot. Fixed by toggling
   body.rack-dragging during any drag, which applies a CSS rule that forces
   pointer-events:none !important on .module-block and all descendants.

2. onDragMove pointer-position reconstruction (activatorEvent.clientX + delta.x)
   was slightly off because delta is measured from the initial mousedown, not
   the activation point. Replaced with a native window pointermove listener
   (capture phase) that gives exact clientX/Y — no reconstruction needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 08:57:22 -05:00
parent c9aed96400
commit 55ee1dea93
3 changed files with 46 additions and 24 deletions

View File

@@ -8,7 +8,6 @@ import {
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragMoveEvent,
} from '@dnd-kit/core';
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { toast } from 'sonner';
@@ -61,13 +60,10 @@ function ModuleDragOverlay({ label }: { label: string }) {
* Resolve which rack slot (if any) is under the pointer during a drag.
*
* Strategy: elementFromPoint at the current pointer coordinates.
* - ModuleBlock has pointer-events:none when isDragging, so it is transparent.
* - All ModuleBlocks get pointer-events:none via the body.rack-dragging CSS rule,
* so elementFromPoint sees through them to the slot element beneath.
* - DragOverlay has pointer-events:none natively (dnd-kit).
* - RackSlot divs carry data-rack-id / data-u-pos attributes that we read here.
*
* This is intentionally independent of dnd-kit's collision detection, which
* cannot reliably distinguish 44px slot elements from large (~1800px) rack
* column sortable containers that share the same DndContext.
*/
function resolveSlotFromPoint(clientX: number, clientY: number): HoverSlot | null {
const el = document.elementFromPoint(clientX, clientY);
@@ -99,6 +95,10 @@ export function RackPlanner() {
const [hoverSlot, setHoverSlot] = useState<HoverSlot | null>(null);
const hoverSlotRef = useRef<HoverSlot | null>(null);
// Tracks whether ANY module/palette drag is in progress — used to
// activate the body.rack-dragging CSS class and the pointermove listener.
const isDraggingAnyRef = useRef(false);
function updateHoverSlot(slot: HoverSlot | null) {
hoverSlotRef.current = slot;
setHoverSlot(slot);
@@ -112,36 +112,47 @@ export function RackPlanner() {
fetchRacks().catch(() => toast.error('Failed to load racks'));
}, [fetchRacks]);
/**
* Native pointermove listener registered once on mount.
* Only runs while isDraggingAnyRef is true — gives us the exact cursor
* position without any reconstruction arithmetic, so resolveSlotFromPoint
* is always called with accurate coordinates.
*/
useEffect(() => {
function onPointerMove(e: PointerEvent) {
if (!isDraggingAnyRef.current) return;
const slot = resolveSlotFromPoint(e.clientX, e.clientY);
hoverSlotRef.current = slot;
setHoverSlot(slot);
}
// Capture phase so we get the event before any element can stop propagation.
window.addEventListener('pointermove', onPointerMove, { capture: true });
return () => window.removeEventListener('pointermove', onPointerMove, { capture: true });
}, []);
function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as Record<string, unknown>;
if (data?.dragType === 'palette') {
setActivePaletteType(data.type as ModuleType);
isDraggingAnyRef.current = true;
document.body.classList.add('rack-dragging');
} else if (data?.dragType === 'module') {
setDraggingModuleId(data.moduleId as string);
setActiveDragModuleLabel(data.label as string);
isDraggingAnyRef.current = true;
document.body.classList.add('rack-dragging');
}
updateHoverSlot(null);
}
function handleDragMove(event: DragMoveEvent) {
const data = event.active.data.current as Record<string, unknown>;
// Only track slot hover for module and palette drags
if (data?.dragType !== 'module' && data?.dragType !== 'palette') {
updateHoverSlot(null);
return;
}
// Compute current pointer position from the activating event + accumulated delta
const activatorEvent = event.activatorEvent as PointerEvent;
const clientX = activatorEvent.clientX + event.delta.x;
const clientY = activatorEvent.clientY + event.delta.y;
updateHoverSlot(resolveSlotFromPoint(clientX, clientY));
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
// Stop native hover tracking and remove body class FIRST
isDraggingAnyRef.current = false;
document.body.classList.remove('rack-dragging');
// Capture hoverSlot BEFORE resetting state
const slot = hoverSlotRef.current;
@@ -210,7 +221,6 @@ export function RackPlanner() {
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-screen bg-[#0f1117]">