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:
@@ -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'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user