fix: free-form drag positioning instead of swap-only

Widgets can now be dragged anywhere on the grid, not just swapped.
Drop position is calculated from mouse coordinates relative to the grid,
snapped to grid units. Overlapping widgets are pushed down automatically.

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

View File

@@ -235,7 +235,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
document.addEventListener("mouseup", onUp); document.addEventListener("mouseup", onUp);
}; };
// Drag: HTML5 DnD // Drag: free-form grid positioning
const handleDragStart = (e: React.DragEvent, widgetId: string) => { const handleDragStart = (e: React.DragEvent, widgetId: string) => {
setDraggingId(widgetId); setDraggingId(widgetId);
e.dataTransfer.setData("text/plain", widgetId); e.dataTransfer.setData("text/plain", widgetId);
@@ -247,29 +247,65 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move";
}; };
const handleDrop = async (e: React.DragEvent, targetId: string) => { const handleGridDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
const sourceId = e.dataTransfer.getData("text/plain"); const sourceId = e.dataTransfer.getData("text/plain");
if (!sourceId || sourceId === targetId) { setDraggingId(null); return; } if (!sourceId) { setDraggingId(null); return; }
if (!gridRef.current) { setDraggingId(null); return; }
const source = widgets.find((w) => w.id === sourceId); const gridRect = gridRef.current.getBoundingClientRect();
const target = widgets.find((w) => w.id === targetId); const unitSize = gridRect.width / 12;
if (!source || !target) { setDraggingId(null); return; } // Calculate grid position from mouse coordinates
const relX = e.clientX - gridRect.left;
const relY = e.clientY - gridRect.top;
const sourceWidget = widgets.find((w) => w.id === sourceId);
if (!sourceWidget) { setDraggingId(null); return; }
// Swap positions // Snap to nearest grid unit, clamp to bounds
const sourcePos = { ...source.position }; const newX = Math.max(0, Math.min(12 - sourceWidget.position.w, Math.round(relX / unitSize)));
const targetPos = { ...target.position }; const newY = Math.max(0, Math.round(relY / (unitSize * 0.6))); // rows are shorter
setWidgets((prev) => setWidgets((current) => {
prev.map((w) => { let resolved = current.map((w) => {
if (w.id === sourceId) return { ...w, position: targetPos }; if (w.id === sourceId) {
if (w.id === targetId) return { ...w, position: sourcePos }; return { ...w, position: { ...w.position, x: newX, y: newY } };
return w; }
}) return { ...w, position: { ...w.position } };
); });
// Push overlapping widgets down (skip the moved widget)
for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false;
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i].position;
const b = resolved[j].position;
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) {
hasOverlap = true;
// Push the widget that wasn't just moved (or the lower one)
const moveIdx = resolved[i].id === sourceId ? j : i;
const fixedIdx = moveIdx === i ? j : i;
resolved[moveIdx] = {
...resolved[moveIdx],
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
};
}
}
}
if (!hasOverlap) break;
}
// Persist
for (const w of resolved) {
const orig = current.find((o) => o.id === w.id);
if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) {
updateWidget(id, w.id, { position: w.position });
}
}
return resolved;
});
await updateWidget(id, sourceId, { position: targetPos });
await updateWidget(id, targetId, { position: sourcePos });
setDraggingId(null); setDraggingId(null);
}; };
@@ -412,6 +448,8 @@ 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
@@ -425,8 +463,6 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
style={widgetGridStyle(widget.position)} style={widgetGridStyle(widget.position)}
draggable={editMode} draggable={editMode}
onDragStart={(e) => editMode && handleDragStart(e, widget.id)} onDragStart={(e) => editMode && handleDragStart(e, widget.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, widget.id)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* Drag handle */} {/* Drag handle */}