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,45 +236,54 @@ 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 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) => { const onUp = () => {
e.preventDefault(); document.removeEventListener("mousemove", onMove);
e.dataTransfer.dropEffect = "move"; document.removeEventListener("mouseup", onUp);
}; setDraggingId(null);
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
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 } }));
// Push overlapping widgets down
for (let pass = 0; pass < 10; pass++) { for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false; let hasOverlap = false;
for (let i = 0; i < resolved.length; i++) { 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; 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) { 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; hasOverlap = true;
// Push the widget that wasn't just moved (or the lower one) const moveIdx = resolved[i].id === widgetId ? j : i;
const moveIdx = resolved[i].id === sourceId ? j : i;
const fixedIdx = moveIdx === i ? j : i; const fixedIdx = moveIdx === i ? j : i;
resolved[moveIdx] = { resolved[moveIdx] = {
...resolved[moveIdx], ...resolved[moveIdx],
@@ -295,7 +304,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
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)) {
@@ -305,12 +314,10 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
return resolved; return resolved;
}); });
setDraggingId(null);
}; };
const handleDragEnd = () => { document.addEventListener("mousemove", onMove);
setDraggingId(null); document.addEventListener("mouseup", onUp);
}; };
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>
)} )}