diff --git a/client/src/components/rack/ModuleBlock.tsx b/client/src/components/rack/ModuleBlock.tsx index 05a45ee..f429907 100644 --- a/client/src/components/rack/ModuleBlock.tsx +++ b/client/src/components/rack/ModuleBlock.tsx @@ -119,7 +119,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) { 'relative w-full border-l-4 select-none overflow-hidden transition-opacity', colors.bg, colors.border, - isDragging ? 'opacity-0' : 'cursor-grab active:cursor-grabbing', + isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing', !isDragging && hovered && 'brightness-110', previewUSize !== null && 'ring-1 ring-white/30' )} diff --git a/client/src/components/rack/RackColumn.tsx b/client/src/components/rack/RackColumn.tsx index 316f8c2..5fac707 100644 --- a/client/src/components/rack/RackColumn.tsx +++ b/client/src/components/rack/RackColumn.tsx @@ -14,9 +14,11 @@ interface RackColumnProps { rack: Rack; /** ID of the module currently being dragged — render its slots as droppable ghosts. */ draggingModuleId?: string | null; + /** Slot currently hovered by a drag — passed down to RackSlot for blue highlight. */ + hoverSlot?: { rackId: string; uPosition: number } | null; } -export function RackColumn({ rack, draggingModuleId }: RackColumnProps) { +export function RackColumn({ rack, draggingModuleId, hoverSlot }: RackColumnProps) { const { deleteRack } = useRackStore(); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -97,17 +99,29 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) { if (renderedModuleIds.has(moduleId)) return null; renderedModuleIds.add(moduleId); - // If this module is being dragged, show empty droppable slot(s) instead + // If this module is being dragged, show empty ghost slot instead if (moduleId === draggingModuleId) { return ( - + ); } return ; } - return ; + return ( + + ); })} diff --git a/client/src/components/rack/RackPlanner.tsx b/client/src/components/rack/RackPlanner.tsx index f22fb61..b195c1f 100644 --- a/client/src/components/rack/RackPlanner.tsx +++ b/client/src/components/rack/RackPlanner.tsx @@ -3,13 +3,12 @@ import { DndContext, DragOverlay, PointerSensor, - pointerWithin, closestCenter, useSensor, useSensors, type DragStartEvent, type DragEndEvent, - type CollisionDetection, + type DragMoveEvent, } from '@dnd-kit/core'; import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; import { toast } from 'sonner'; @@ -30,6 +29,11 @@ interface PendingDrop { type: ModuleType; } +interface HoverSlot { + rackId: string; + uPosition: number; +} + function DragOverlayItem({ type }: { type: ModuleType }) { const colors = MODULE_TYPE_COLORS[type]; return ( @@ -54,41 +58,33 @@ function ModuleDragOverlay({ label }: { label: string }) { } /** - * Collision detection strategy: + * Resolve which rack slot (if any) is under the pointer during a drag. * - * Problem: SortableContext registers each rack column as a droppable. Columns - * are ~1800px tall; individual slots are 44px. Default closestCenter picks the - * column centre over any slot centre. pointerWithin returns multiple matches - * sorted by registration order (columns register before their child slots), so - * the rack column still wins. + * Strategy: elementFromPoint at the current pointer coordinates. + * - ModuleBlock has pointer-events:none when isDragging, so it is transparent. + * - DragOverlay has pointer-events:none natively (dnd-kit). + * - RackSlot divs carry data-rack-id / data-u-pos attributes that we read here. * - * Fix: - * 1. First try pointerWithin restricted to ONLY elements whose data has - * dropType === 'slot'. This is an exact hit-test against the 44px slot rects. - * 2. Fall back to closestCenter over ALL droppables so rack-header reorder - * (which needs the sortable rack targets) still works. + * 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. */ -const slotFirstCollision: CollisionDetection = (args) => { - // droppableContainers is a custom NodeMap (not a plain Array) — it only - // implements [Symbol.iterator], so .filter() doesn't exist on it. - // Convert to Array first before filtering. - const allContainers = Array.from(args.droppableContainers); +function resolveSlotFromPoint(clientX: number, clientY: number): HoverSlot | null { + const el = document.elementFromPoint(clientX, clientY); + if (!el) return null; - const slotContainers = allContainers.filter( - (c) => c.data.current?.dropType === 'slot' - ); + const slotEl = el.closest('[data-rack-id][data-u-pos]') as HTMLElement | null; + if (!slotEl) return null; - if (slotContainers.length > 0) { - const slotHits = pointerWithin({ ...args, droppableContainers: slotContainers as typeof args.droppableContainers }); - if (slotHits.length > 0) return slotHits; - } + const rackId = slotEl.dataset.rackId; + const uPos = parseInt(slotEl.dataset.uPos ?? '', 10); + if (!rackId || isNaN(uPos)) return null; - // Nothing hit a slot — use full closestCenter for rack reorder - return closestCenter(args); -}; + return { rackId, uPosition: uPos }; +} export function RackPlanner() { - const { racks, loading, fetchRacks, moveModule, updateRack } = useRackStore(); + const { racks, loading, fetchRacks, moveModule } = useRackStore(); const canvasRef = useRef(null); // Drag state @@ -97,6 +93,17 @@ export function RackPlanner() { const [draggingModuleId, setDraggingModuleId] = useState(null); const [pendingDrop, setPendingDrop] = useState(null); + // hoverSlot drives the blue highlight on slots during drag. + // hoverSlotRef is the reliable read-path inside async handleDragEnd + // (avoids stale-closure issues with state). + const [hoverSlot, setHoverSlot] = useState(null); + const hoverSlotRef = useRef(null); + + function updateHoverSlot(slot: HoverSlot | null) { + hoverSlotRef.current = slot; + setHoverSlot(slot); + } + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }) ); @@ -113,40 +120,57 @@ export function RackPlanner() { setDraggingModuleId(data.moduleId as string); setActiveDragModuleLabel(data.label as string); } + 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; + + // Capture hoverSlot BEFORE resetting state + const slot = hoverSlotRef.current; + setActivePaletteType(null); setActiveDragModuleLabel(null); setDraggingModuleId(null); - - if (!over) return; + updateHoverSlot(null); const dragData = active.data.current as Record; - const dropData = over.data.current as Record | undefined; // --- Palette → slot: open AddModuleModal pre-filled --- - if (dragData?.dragType === 'palette' && dropData?.dropType === 'slot') { + if (dragData?.dragType === 'palette' && slot) { setPendingDrop({ type: dragData.type as ModuleType, - rackId: dropData.rackId as string, - uPosition: dropData.uPosition as number, + rackId: slot.rackId, + uPosition: slot.uPosition, }); return; } // --- Module → slot: move the module --- - if (dragData?.dragType === 'module' && dropData?.dropType === 'slot') { + if (dragData?.dragType === 'module' && slot) { const moduleId = dragData.moduleId as string; - const targetRackId = dropData.rackId as string; - const targetUPosition = dropData.uPosition as number; // No-op if dropped on own position - if (dragData.fromRackId === targetRackId && dragData.fromUPosition === targetUPosition) return; + if (dragData.fromRackId === slot.rackId && dragData.fromUPosition === slot.uPosition) return; try { - await moveModule(moduleId, targetRackId, targetUPosition); + await moveModule(moduleId, slot.rackId, slot.uPosition); toast.success('Module moved'); } catch (err) { toast.error(err instanceof Error ? err.message : 'Move failed'); @@ -155,24 +179,26 @@ export function RackPlanner() { } // --- Rack header → rack header: reorder racks --- - if (dragData?.dragType === 'rack' && over.data.current?.dragType === 'rack') { + if (!over) return; + const dropData = over.data.current as Record | undefined; + if (dragData?.dragType === 'rack' && dropData?.dragType === 'rack') { const oldIndex = racks.findIndex((r) => r.id === active.id); const newIndex = racks.findIndex((r) => r.id === over.id); if (oldIndex === newIndex) return; const reordered = arrayMove(racks, oldIndex, newIndex); - // Persist new displayOrder values try { await Promise.all( reordered.map((rack, idx) => - rack.displayOrder !== idx ? apiClient.racks.update(rack.id, { displayOrder: idx }) : Promise.resolve(rack) + rack.displayOrder !== idx + ? apiClient.racks.update(rack.id, { displayOrder: idx }) + : Promise.resolve(rack) ) ); - // Refresh store to sync await fetchRacks(); } catch { toast.error('Failed to save rack order'); - await fetchRacks(); // rollback + await fetchRacks(); } } } @@ -180,7 +206,13 @@ export function RackPlanner() { const rackIds = racks.map((r) => r.id); return ( - +
@@ -214,7 +246,12 @@ export function RackPlanner() { style={{ background: '#0f1117' }} > {racks.map((rack) => ( - + ))}
diff --git a/client/src/components/rack/RackSlot.tsx b/client/src/components/rack/RackSlot.tsx index 2dd15b8..afcdf37 100644 --- a/client/src/components/rack/RackSlot.tsx +++ b/client/src/components/rack/RackSlot.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { useDroppable } from '@dnd-kit/core'; import { Plus } from 'lucide-react'; import { U_HEIGHT_PX } from '../../lib/constants'; import { cn } from '../../lib/utils'; @@ -8,20 +7,19 @@ import { AddModuleModal } from '../modals/AddModuleModal'; interface RackSlotProps { rackId: string; uPosition: number; + /** Passed from RackPlanner via RackColumn — true when a drag is hovering this slot */ + isOver?: boolean; } -export function RackSlot({ rackId, uPosition }: RackSlotProps) { +export function RackSlot({ rackId, uPosition, isOver = false }: RackSlotProps) { const [addModuleOpen, setAddModuleOpen] = useState(false); - const { setNodeRef, isOver } = useDroppable({ - id: `slot-${rackId}-${uPosition}`, - data: { dropType: 'slot', rackId, uPosition }, - }); - return ( <>