Fix module drag-and-drop: replace useDroppable/collision with elementFromPoint

Completely removes dnd-kit's useDroppable and collision detection for rack
slot targeting. Uses onDragMove + document.elementFromPoint() with data-rack-id
/ data-u-pos HTML attributes on RackSlot elements to resolve the hovered slot
independently of dnd-kit's SortableContext interference. Adds pointer-events-none
to ModuleBlock when isDragging so the invisible element doesn't block hit testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 08:41:03 -05:00
parent d381f8b720
commit c9aed96400
4 changed files with 109 additions and 60 deletions

View File

@@ -119,7 +119,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
'relative w-full border-l-4 select-none overflow-hidden transition-opacity', 'relative w-full border-l-4 select-none overflow-hidden transition-opacity',
colors.bg, colors.bg,
colors.border, 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', !isDragging && hovered && 'brightness-110',
previewUSize !== null && 'ring-1 ring-white/30' previewUSize !== null && 'ring-1 ring-white/30'
)} )}

View File

@@ -14,9 +14,11 @@ interface RackColumnProps {
rack: Rack; rack: Rack;
/** ID of the module currently being dragged — render its slots as droppable ghosts. */ /** ID of the module currently being dragged — render its slots as droppable ghosts. */
draggingModuleId?: string | null; 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 { deleteRack } = useRackStore();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -97,17 +99,29 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
if (renderedModuleIds.has(moduleId)) return null; if (renderedModuleIds.has(moduleId)) return null;
renderedModuleIds.add(moduleId); 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) { if (moduleId === draggingModuleId) {
return ( return (
<RackSlot key={`ghost-${u}`} rackId={rack.id} uPosition={u} /> <RackSlot
key={`ghost-${u}`}
rackId={rack.id}
uPosition={u}
isOver={hoverSlot?.rackId === rack.id && hoverSlot?.uPosition === u}
/>
); );
} }
return <ModuleBlock key={module.id} module={module} />; return <ModuleBlock key={module.id} module={module} />;
} }
return <RackSlot key={u} rackId={rack.id} uPosition={u} />; return (
<RackSlot
key={u}
rackId={rack.id}
uPosition={u}
isOver={hoverSlot?.rackId === rack.id && hoverSlot?.uPosition === u}
/>
);
})} })}
</div> </div>

View File

@@ -3,13 +3,12 @@ import {
DndContext, DndContext,
DragOverlay, DragOverlay,
PointerSensor, PointerSensor,
pointerWithin,
closestCenter, closestCenter,
useSensor, useSensor,
useSensors, useSensors,
type DragStartEvent, type DragStartEvent,
type DragEndEvent, type DragEndEvent,
type CollisionDetection, type DragMoveEvent,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -30,6 +29,11 @@ interface PendingDrop {
type: ModuleType; type: ModuleType;
} }
interface HoverSlot {
rackId: string;
uPosition: number;
}
function DragOverlayItem({ type }: { type: ModuleType }) { function DragOverlayItem({ type }: { type: ModuleType }) {
const colors = MODULE_TYPE_COLORS[type]; const colors = MODULE_TYPE_COLORS[type];
return ( 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 * Strategy: elementFromPoint at the current pointer coordinates.
* are ~1800px tall; individual slots are 44px. Default closestCenter picks the * - ModuleBlock has pointer-events:none when isDragging, so it is transparent.
* column centre over any slot centre. pointerWithin returns multiple matches * - DragOverlay has pointer-events:none natively (dnd-kit).
* sorted by registration order (columns register before their child slots), so * - RackSlot divs carry data-rack-id / data-u-pos attributes that we read here.
* the rack column still wins.
* *
* Fix: * This is intentionally independent of dnd-kit's collision detection, which
* 1. First try pointerWithin restricted to ONLY elements whose data has * cannot reliably distinguish 44px slot elements from large (~1800px) rack
* dropType === 'slot'. This is an exact hit-test against the 44px slot rects. * column sortable containers that share the same DndContext.
* 2. Fall back to closestCenter over ALL droppables so rack-header reorder
* (which needs the sortable rack targets) still works.
*/ */
const slotFirstCollision: CollisionDetection = (args) => { function resolveSlotFromPoint(clientX: number, clientY: number): HoverSlot | null {
// droppableContainers is a custom NodeMap (not a plain Array) — it only const el = document.elementFromPoint(clientX, clientY);
// implements [Symbol.iterator], so .filter() doesn't exist on it. if (!el) return null;
// Convert to Array first before filtering.
const allContainers = Array.from(args.droppableContainers);
const slotContainers = allContainers.filter( const slotEl = el.closest('[data-rack-id][data-u-pos]') as HTMLElement | null;
(c) => c.data.current?.dropType === 'slot' if (!slotEl) return null;
);
if (slotContainers.length > 0) { const rackId = slotEl.dataset.rackId;
const slotHits = pointerWithin({ ...args, droppableContainers: slotContainers as typeof args.droppableContainers }); const uPos = parseInt(slotEl.dataset.uPos ?? '', 10);
if (slotHits.length > 0) return slotHits; if (!rackId || isNaN(uPos)) return null;
}
// Nothing hit a slot — use full closestCenter for rack reorder return { rackId, uPosition: uPos };
return closestCenter(args); }
};
export function RackPlanner() { export function RackPlanner() {
const { racks, loading, fetchRacks, moveModule, updateRack } = useRackStore(); const { racks, loading, fetchRacks, moveModule } = useRackStore();
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// Drag state // Drag state
@@ -97,6 +93,17 @@ export function RackPlanner() {
const [draggingModuleId, setDraggingModuleId] = useState<string | null>(null); const [draggingModuleId, setDraggingModuleId] = useState<string | null>(null);
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(null); const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(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<HoverSlot | null>(null);
const hoverSlotRef = useRef<HoverSlot | null>(null);
function updateHoverSlot(slot: HoverSlot | null) {
hoverSlotRef.current = slot;
setHoverSlot(slot);
}
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }) useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
); );
@@ -113,40 +120,57 @@ export function RackPlanner() {
setDraggingModuleId(data.moduleId as string); setDraggingModuleId(data.moduleId as string);
setActiveDragModuleLabel(data.label as string); setActiveDragModuleLabel(data.label as string);
} }
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) { async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event; const { active, over } = event;
// Capture hoverSlot BEFORE resetting state
const slot = hoverSlotRef.current;
setActivePaletteType(null); setActivePaletteType(null);
setActiveDragModuleLabel(null); setActiveDragModuleLabel(null);
setDraggingModuleId(null); setDraggingModuleId(null);
updateHoverSlot(null);
if (!over) return;
const dragData = active.data.current as Record<string, unknown>; const dragData = active.data.current as Record<string, unknown>;
const dropData = over.data.current as Record<string, unknown> | undefined;
// --- Palette → slot: open AddModuleModal pre-filled --- // --- Palette → slot: open AddModuleModal pre-filled ---
if (dragData?.dragType === 'palette' && dropData?.dropType === 'slot') { if (dragData?.dragType === 'palette' && slot) {
setPendingDrop({ setPendingDrop({
type: dragData.type as ModuleType, type: dragData.type as ModuleType,
rackId: dropData.rackId as string, rackId: slot.rackId,
uPosition: dropData.uPosition as number, uPosition: slot.uPosition,
}); });
return; return;
} }
// --- Module → slot: move the module --- // --- Module → slot: move the module ---
if (dragData?.dragType === 'module' && dropData?.dropType === 'slot') { if (dragData?.dragType === 'module' && slot) {
const moduleId = dragData.moduleId as string; 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 // 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 { try {
await moveModule(moduleId, targetRackId, targetUPosition); await moveModule(moduleId, slot.rackId, slot.uPosition);
toast.success('Module moved'); toast.success('Module moved');
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed'); toast.error(err instanceof Error ? err.message : 'Move failed');
@@ -155,24 +179,26 @@ export function RackPlanner() {
} }
// --- Rack header → rack header: reorder racks --- // --- 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<string, unknown> | undefined;
if (dragData?.dragType === 'rack' && dropData?.dragType === 'rack') {
const oldIndex = racks.findIndex((r) => r.id === active.id); const oldIndex = racks.findIndex((r) => r.id === active.id);
const newIndex = racks.findIndex((r) => r.id === over.id); const newIndex = racks.findIndex((r) => r.id === over.id);
if (oldIndex === newIndex) return; if (oldIndex === newIndex) return;
const reordered = arrayMove(racks, oldIndex, newIndex); const reordered = arrayMove(racks, oldIndex, newIndex);
// Persist new displayOrder values
try { try {
await Promise.all( await Promise.all(
reordered.map((rack, idx) => 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(); await fetchRacks();
} catch { } catch {
toast.error('Failed to save rack order'); 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); const rackIds = racks.map((r) => r.id);
return ( return (
<DndContext sensors={sensors} collisionDetection={slotFirstCollision} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-screen bg-[#0f1117]"> <div className="flex flex-col h-screen bg-[#0f1117]">
<RackToolbar rackCanvasRef={canvasRef} /> <RackToolbar rackCanvasRef={canvasRef} />
@@ -214,7 +246,12 @@ export function RackPlanner() {
style={{ background: '#0f1117' }} style={{ background: '#0f1117' }}
> >
{racks.map((rack) => ( {racks.map((rack) => (
<RackColumn key={rack.id} rack={rack} draggingModuleId={draggingModuleId} /> <RackColumn
key={rack.id}
rack={rack}
draggingModuleId={draggingModuleId}
hoverSlot={hoverSlot}
/>
))} ))}
</div> </div>
</SortableContext> </SortableContext>

View File

@@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { U_HEIGHT_PX } from '../../lib/constants'; import { U_HEIGHT_PX } from '../../lib/constants';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
@@ -8,20 +7,19 @@ import { AddModuleModal } from '../modals/AddModuleModal';
interface RackSlotProps { interface RackSlotProps {
rackId: string; rackId: string;
uPosition: number; 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 [addModuleOpen, setAddModuleOpen] = useState(false);
const { setNodeRef, isOver } = useDroppable({
id: `slot-${rackId}-${uPosition}`,
data: { dropType: 'slot', rackId, uPosition },
});
return ( return (
<> <>
<div <div
ref={setNodeRef} // Data attributes let RackPlanner's onDragMove identify this slot via elementFromPoint
data-rack-id={rackId}
data-u-pos={String(uPosition)}
className={cn( className={cn(
'w-full border border-dashed transition-colors group cursor-pointer flex items-center justify-between px-2', 'w-full border border-dashed transition-colors group cursor-pointer flex items-center justify-between px-2',
isOver isOver