diff --git a/client/src/components/rack/ModuleBlock.tsx b/client/src/components/rack/ModuleBlock.tsx index f429907..d3bc8a3 100644 --- a/client/src/components/rack/ModuleBlock.tsx +++ b/client/src/components/rack/ModuleBlock.tsx @@ -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', diff --git a/client/src/components/rack/RackPlanner.tsx b/client/src/components/rack/RackPlanner.tsx index b195c1f..b96f74d 100644 --- a/client/src/components/rack/RackPlanner.tsx +++ b/client/src/components/rack/RackPlanner.tsx @@ -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(null); const hoverSlotRef = useRef(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; 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; - // 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} >
diff --git a/client/src/index.css b/client/src/index.css index 0e61adc..73d2076 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; +}