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

@@ -116,7 +116,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
{...listeners}
{...attributes}
className={cn(
'relative w-full border-l-4 select-none overflow-hidden transition-opacity',
'module-block relative w-full border-l-4 select-none overflow-hidden transition-opacity',
colors.bg,
colors.border,
isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing',

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]">

View File

@@ -28,3 +28,15 @@
height: 1.75rem; /* 28px per U */
}
}
/*
* During any rack drag, make every module-block and ALL of its children
* transparent to pointer-events so that document.elementFromPoint() can
* "see through" them to the RackSlot elements underneath.
* The `!important` is necessary because individual elements (port buttons,
* resize handle) carry their own pointer-events values.
*/
body.rack-dragging .module-block,
body.rack-dragging .module-block * {
pointer-events: none !important;
}