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 [resizingId, setResizingId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// Resize: track mousedown → mousemove → mouseup
const handleResizeStart = (e: React.MouseEvent, widgetId: string) => {
@@ -235,45 +236,54 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
document.addEventListener("mouseup", onUp);
};
// Drag: free-form grid positioning
const handleDragStart = (e: React.DragEvent, widgetId: string) => {
// Mouse-based drag: mousedown on grip → mousemove → mouseup
const handleDragMouseDown = (e: React.MouseEvent, widgetId: string) => {
e.preventDefault();
e.stopPropagation();
if (!gridRef.current) return;
setDraggingId(widgetId);
e.dataTransfer.setData("text/plain", widgetId);
e.dataTransfer.effectAllowed = "move";
const widget = widgets.find((w) => w.id === widgetId);
if (!widget) return;
const unitSize = gridRef.current.offsetWidth / 12;
const startX = e.clientX;
const startY = e.clientY;
const startGridX = widget.position.x;
const startGridY = widget.position.y;
// Store offset from widget origin to mouse for visual tracking
const widgetEl = (e.target as HTMLElement).closest('[data-widget-id]') as HTMLElement;
if (widgetEl) {
const rect = widgetEl.getBoundingClientRect();
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
}
const onMove = (ev: MouseEvent) => {
const dx = Math.round((ev.clientX - startX) / unitSize);
const dy = Math.round((ev.clientY - startY) / unitSize);
const newX = Math.max(0, Math.min(12 - widget.position.w, startGridX + dx));
const newY = Math.max(0, startGridY + dy);
setWidgets((prev) =>
prev.map((w) =>
w.id === widgetId
? { ...w, position: { ...w.position, x: newX, y: newY } }
: w
)
);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleGridDrop = (e: React.DragEvent) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData("text/plain");
if (!sourceId) { setDraggingId(null); return; }
if (!gridRef.current) { 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; }
// 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
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDraggingId(null);
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 } };
});
const updated = current.find((w) => w.id === widgetId);
if (!updated) return current;
// Push overlapping widgets down (skip the moved widget)
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
// Push overlapping widgets down
for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false;
for (let i = 0; i < resolved.length; i++) {
@@ -282,8 +292,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
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 moveIdx = resolved[i].id === widgetId ? j : i;
const fixedIdx = moveIdx === i ? j : i;
resolved[moveIdx] = {
...resolved[moveIdx],
@@ -295,7 +304,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
if (!hasOverlap) break;
}
// Persist
// Persist changed positions
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)) {
@@ -305,12 +314,10 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
return resolved;
});
setDraggingId(null);
};
const handleDragEnd = () => {
setDraggingId(null);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
@@ -448,26 +455,24 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
<div
ref={gridRef}
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) => (
<div
key={widget.id}
data-widget-id={widget.id}
className={cn(
"group relative",
editMode && "cursor-grab",
draggingId === widget.id && "opacity-50",
"group relative transition-none",
draggingId === widget.id && "z-10",
resizingId === widget.id && "select-none",
)}
style={widgetGridStyle(widget.position)}
draggable={editMode}
onDragStart={(e) => editMode && handleDragStart(e, widget.id)}
onDragEnd={handleDragEnd}
>
{/* Drag handle */}
{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" />
</div>
)}