From 6a277f9c3655de29450efd12e21059a6adb19e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 14:18:00 +0200 Subject: [PATCH] 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 --- web/src/app/dashboards/[id]/page.tsx | 76 ++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/web/src/app/dashboards/[id]/page.tsx b/web/src/app/dashboards/[id]/page.tsx index 5f7be6e..d70eb2c 100644 --- a/web/src/app/dashboards/[id]/page.tsx +++ b/web/src/app/dashboards/[id]/page.tsx @@ -235,7 +235,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string document.addEventListener("mouseup", onUp); }; - // Drag: HTML5 DnD + // Drag: free-form grid positioning const handleDragStart = (e: React.DragEvent, widgetId: string) => { setDraggingId(widgetId); e.dataTransfer.setData("text/plain", widgetId); @@ -247,29 +247,65 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string e.dataTransfer.dropEffect = "move"; }; - const handleDrop = async (e: React.DragEvent, targetId: string) => { + const handleGridDrop = (e: React.DragEvent) => { e.preventDefault(); 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 target = widgets.find((w) => w.id === targetId); - if (!source || !target) { setDraggingId(null); return; } + const gridRect = gridRef.current.getBoundingClientRect(); + const unitSize = gridRect.width / 12; + // 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 - const sourcePos = { ...source.position }; - const targetPos = { ...target.position }; + // Snap to nearest grid unit, clamp to bounds + const newX = Math.max(0, Math.min(12 - sourceWidget.position.w, Math.round(relX / unitSize))); + const newY = Math.max(0, Math.round(relY / (unitSize * 0.6))); // rows are shorter - setWidgets((prev) => - prev.map((w) => { - if (w.id === sourceId) return { ...w, position: targetPos }; - if (w.id === targetId) return { ...w, position: sourcePos }; - return w; - }) - ); + setWidgets((current) => { + let resolved = current.map((w) => { + if (w.id === sourceId) { + return { ...w, position: { ...w.position, x: newX, y: newY } }; + } + 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); }; @@ -412,6 +448,8 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
{widgets.map((widget) => (
editMode && handleDragStart(e, widget.id)} - onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, widget.id)} onDragEnd={handleDragEnd} > {/* Drag handle */}