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