fix: replace HTML5 DnD with mouse-based drag for smooth widget movement

- Grip handle now uses mousedown/mousemove/mouseup (same as resize)
- Widget position updates in real-time as you drag — no ghost image
- Grid snapping from actual mouse coordinates
- Overlap resolution on mouseup
- Cleaner: no draggable attribute, no dataTransfer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 14:31:46 +02:00
parent 6a277f9c36
commit 4157a7b0af

View File

@@ -160,6 +160,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const [resizingId, setResizingId] = useState<string | null>(null); const [resizingId, setResizingId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null); const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// Resize: track mousedown → mousemove → mouseup // Resize: track mousedown → mousemove → mouseup
const handleResizeStart = (e: React.MouseEvent, widgetId: string) => { const handleResizeStart = (e: React.MouseEvent, widgetId: string) => {
@@ -235,82 +236,88 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
document.addEventListener("mouseup", onUp); document.addEventListener("mouseup", onUp);
}; };
// Drag: free-form grid positioning // Mouse-based drag: mousedown on grip → mousemove → mouseup
const handleDragStart = (e: React.DragEvent, widgetId: string) => { const handleDragMouseDown = (e: React.MouseEvent, widgetId: string) => {
e.preventDefault();
e.stopPropagation();
if (!gridRef.current) return;
setDraggingId(widgetId); setDraggingId(widgetId);
e.dataTransfer.setData("text/plain", widgetId);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent) => { const widget = widgets.find((w) => w.id === widgetId);
e.preventDefault(); if (!widget) return;
e.dataTransfer.dropEffect = "move"; const unitSize = gridRef.current.offsetWidth / 12;
}; const startX = e.clientX;
const startY = e.clientY;
const startGridX = widget.position.x;
const startGridY = widget.position.y;
const handleGridDrop = (e: React.DragEvent) => { // Store offset from widget origin to mouse for visual tracking
e.preventDefault(); const widgetEl = (e.target as HTMLElement).closest('[data-widget-id]') as HTMLElement;
const sourceId = e.dataTransfer.getData("text/plain"); if (widgetEl) {
if (!sourceId) { setDraggingId(null); return; } const rect = widgetEl.getBoundingClientRect();
if (!gridRef.current) { setDraggingId(null); return; } setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
}
const gridRect = gridRef.current.getBoundingClientRect(); const onMove = (ev: MouseEvent) => {
const unitSize = gridRect.width / 12; const dx = Math.round((ev.clientX - startX) / unitSize);
// Calculate grid position from mouse coordinates const dy = Math.round((ev.clientY - startY) / unitSize);
const relX = e.clientX - gridRect.left; const newX = Math.max(0, Math.min(12 - widget.position.w, startGridX + dx));
const relY = e.clientY - gridRect.top; const newY = Math.max(0, startGridY + dy);
const sourceWidget = widgets.find((w) => w.id === sourceId); setWidgets((prev) =>
if (!sourceWidget) { setDraggingId(null); return; } prev.map((w) =>
w.id === widgetId
? { ...w, position: { ...w.position, x: newX, y: newY } }
: w
)
);
};
// Snap to nearest grid unit, clamp to bounds const onUp = () => {
const newX = Math.max(0, Math.min(12 - sourceWidget.position.w, Math.round(relX / unitSize))); document.removeEventListener("mousemove", onMove);
const newY = Math.max(0, Math.round(relY / (unitSize * 0.6))); // rows are shorter document.removeEventListener("mouseup", onUp);
setDraggingId(null);
setWidgets((current) => { setWidgets((current) => {
let resolved = current.map((w) => { const updated = current.find((w) => w.id === widgetId);
if (w.id === sourceId) { if (!updated) return current;
return { ...w, position: { ...w.position, x: newX, y: newY } };
}
return { ...w, position: { ...w.position } };
});
// Push overlapping widgets down (skip the moved widget) let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false; // Push overlapping widgets down
for (let i = 0; i < resolved.length; i++) { for (let pass = 0; pass < 10; pass++) {
for (let j = i + 1; j < resolved.length; j++) { let hasOverlap = false;
const a = resolved[i].position; for (let i = 0; i < resolved.length; i++) {
const b = resolved[j].position; for (let j = i + 1; j < resolved.length; j++) {
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) { const a = resolved[i].position;
hasOverlap = true; const b = resolved[j].position;
// Push the widget that wasn't just moved (or the lower one) if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
const moveIdx = resolved[i].id === sourceId ? j : i; hasOverlap = true;
const fixedIdx = moveIdx === i ? j : i; const moveIdx = resolved[i].id === widgetId ? j : i;
resolved[moveIdx] = { const fixedIdx = moveIdx === i ? j : i;
...resolved[moveIdx], resolved[moveIdx] = {
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h }, ...resolved[moveIdx],
}; position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
};
}
} }
} }
if (!hasOverlap) break;
} }
if (!hasOverlap) break;
}
// Persist // Persist changed positions
for (const w of resolved) { for (const w of resolved) {
const orig = current.find((o) => o.id === w.id); const orig = current.find((o) => o.id === w.id);
if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) { if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) {
updateWidget(id, w.id, { position: w.position }); updateWidget(id, w.id, { position: w.position });
}
} }
}
return resolved; return resolved;
}); });
};
setDraggingId(null); document.addEventListener("mousemove", onMove);
}; document.addEventListener("mouseup", onUp);
const handleDragEnd = () => {
setDraggingId(null);
}; };
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => { const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
@@ -448,26 +455,24 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
<div <div
ref={gridRef} ref={gridRef}
className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4" className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4"
onDragOver={handleDragOver}
onDrop={handleGridDrop}
> >
{widgets.map((widget) => ( {widgets.map((widget) => (
<div <div
key={widget.id} key={widget.id}
data-widget-id={widget.id}
className={cn( className={cn(
"group relative", "group relative transition-none",
editMode && "cursor-grab", draggingId === widget.id && "z-10",
draggingId === widget.id && "opacity-50",
resizingId === widget.id && "select-none", resizingId === widget.id && "select-none",
)} )}
style={widgetGridStyle(widget.position)} style={widgetGridStyle(widget.position)}
draggable={editMode}
onDragStart={(e) => editMode && handleDragStart(e, widget.id)}
onDragEnd={handleDragEnd}
> >
{/* Drag handle */} {/* Drag handle */}
{editMode && ( {editMode && (
<div className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex"> <div
className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex active:cursor-grabbing"
onMouseDown={(e) => handleDragMouseDown(e, widget.id)}
>
<GripIcon className="h-3.5 w-3.5" /> <GripIcon className="h-3.5 w-3.5" />
</div> </div>
)} )}