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:
@@ -116,7 +116,7 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
|||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
className={cn(
|
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.bg,
|
||||||
colors.border,
|
colors.border,
|
||||||
isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing',
|
isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
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';
|
||||||
@@ -61,13 +60,10 @@ function ModuleDragOverlay({ label }: { label: string }) {
|
|||||||
* Resolve which rack slot (if any) is under the pointer during a drag.
|
* Resolve which rack slot (if any) is under the pointer during a drag.
|
||||||
*
|
*
|
||||||
* Strategy: elementFromPoint at the current pointer coordinates.
|
* 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).
|
* - DragOverlay has pointer-events:none natively (dnd-kit).
|
||||||
* - RackSlot divs carry data-rack-id / data-u-pos attributes that we read here.
|
* - 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 {
|
function resolveSlotFromPoint(clientX: number, clientY: number): HoverSlot | null {
|
||||||
const el = document.elementFromPoint(clientX, clientY);
|
const el = document.elementFromPoint(clientX, clientY);
|
||||||
@@ -99,6 +95,10 @@ export function RackPlanner() {
|
|||||||
const [hoverSlot, setHoverSlot] = useState<HoverSlot | null>(null);
|
const [hoverSlot, setHoverSlot] = useState<HoverSlot | null>(null);
|
||||||
const hoverSlotRef = useRef<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) {
|
function updateHoverSlot(slot: HoverSlot | null) {
|
||||||
hoverSlotRef.current = slot;
|
hoverSlotRef.current = slot;
|
||||||
setHoverSlot(slot);
|
setHoverSlot(slot);
|
||||||
@@ -112,36 +112,47 @@ export function RackPlanner() {
|
|||||||
fetchRacks().catch(() => toast.error('Failed to load racks'));
|
fetchRacks().catch(() => toast.error('Failed to load racks'));
|
||||||
}, [fetchRacks]);
|
}, [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) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
const data = event.active.data.current as Record<string, unknown>;
|
const data = event.active.data.current as Record<string, unknown>;
|
||||||
if (data?.dragType === 'palette') {
|
if (data?.dragType === 'palette') {
|
||||||
setActivePaletteType(data.type as ModuleType);
|
setActivePaletteType(data.type as ModuleType);
|
||||||
|
isDraggingAnyRef.current = true;
|
||||||
|
document.body.classList.add('rack-dragging');
|
||||||
} else if (data?.dragType === 'module') {
|
} else if (data?.dragType === 'module') {
|
||||||
setDraggingModuleId(data.moduleId as string);
|
setDraggingModuleId(data.moduleId as string);
|
||||||
setActiveDragModuleLabel(data.label as string);
|
setActiveDragModuleLabel(data.label as string);
|
||||||
|
isDraggingAnyRef.current = true;
|
||||||
|
document.body.classList.add('rack-dragging');
|
||||||
}
|
}
|
||||||
updateHoverSlot(null);
|
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;
|
||||||
|
|
||||||
|
// Stop native hover tracking and remove body class FIRST
|
||||||
|
isDraggingAnyRef.current = false;
|
||||||
|
document.body.classList.remove('rack-dragging');
|
||||||
|
|
||||||
// Capture hoverSlot BEFORE resetting state
|
// Capture hoverSlot BEFORE resetting state
|
||||||
const slot = hoverSlotRef.current;
|
const slot = hoverSlotRef.current;
|
||||||
|
|
||||||
@@ -210,7 +221,6 @@ export function RackPlanner() {
|
|||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragMove={handleDragMove}
|
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-screen bg-[#0f1117]">
|
<div className="flex flex-col h-screen bg-[#0f1117]">
|
||||||
|
|||||||
@@ -28,3 +28,15 @@
|
|||||||
height: 1.75rem; /* 28px per U */
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user