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:
@@ -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,82 +236,88 @@ 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 handleDragOver = (e: React.DragEvent) => {
|
const widget = widgets.find((w) => w.id === widgetId);
|
||||||
e.preventDefault();
|
if (!widget) return;
|
||||||
e.dataTransfer.dropEffect = "move";
|
const unitSize = gridRef.current.offsetWidth / 12;
|
||||||
};
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startGridX = widget.position.x;
|
||||||
|
const startGridY = widget.position.y;
|
||||||
|
|
||||||
const handleGridDrop = (e: React.DragEvent) => {
|
// Store offset from widget origin to mouse for visual tracking
|
||||||
e.preventDefault();
|
const widgetEl = (e.target as HTMLElement).closest('[data-widget-id]') as HTMLElement;
|
||||||
const sourceId = e.dataTransfer.getData("text/plain");
|
if (widgetEl) {
|
||||||
if (!sourceId) { setDraggingId(null); return; }
|
const rect = widgetEl.getBoundingClientRect();
|
||||||
if (!gridRef.current) { setDraggingId(null); return; }
|
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
}
|
||||||
|
|
||||||
const gridRect = gridRef.current.getBoundingClientRect();
|
const onMove = (ev: MouseEvent) => {
|
||||||
const unitSize = gridRect.width / 12;
|
const dx = Math.round((ev.clientX - startX) / unitSize);
|
||||||
// Calculate grid position from mouse coordinates
|
const dy = Math.round((ev.clientY - startY) / unitSize);
|
||||||
const relX = e.clientX - gridRect.left;
|
const newX = Math.max(0, Math.min(12 - widget.position.w, startGridX + dx));
|
||||||
const relY = e.clientY - gridRect.top;
|
const newY = Math.max(0, startGridY + dy);
|
||||||
const sourceWidget = widgets.find((w) => w.id === sourceId);
|
setWidgets((prev) =>
|
||||||
if (!sourceWidget) { setDraggingId(null); return; }
|
prev.map((w) =>
|
||||||
|
w.id === widgetId
|
||||||
|
? { ...w, position: { ...w.position, x: newX, y: newY } }
|
||||||
|
: w
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Snap to nearest grid unit, clamp to bounds
|
const onUp = () => {
|
||||||
const newX = Math.max(0, Math.min(12 - sourceWidget.position.w, Math.round(relX / unitSize)));
|
document.removeEventListener("mousemove", onMove);
|
||||||
const newY = Math.max(0, Math.round(relY / (unitSize * 0.6))); // rows are shorter
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
setDraggingId(null);
|
||||||
|
|
||||||
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 } }));
|
||||||
for (let pass = 0; pass < 10; pass++) {
|
|
||||||
let hasOverlap = false;
|
// Push overlapping widgets down
|
||||||
for (let i = 0; i < resolved.length; i++) {
|
for (let pass = 0; pass < 10; pass++) {
|
||||||
for (let j = i + 1; j < resolved.length; j++) {
|
let hasOverlap = false;
|
||||||
const a = resolved[i].position;
|
for (let i = 0; i < resolved.length; i++) {
|
||||||
const b = resolved[j].position;
|
for (let j = i + 1; j < resolved.length; j++) {
|
||||||
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) {
|
const a = resolved[i].position;
|
||||||
hasOverlap = true;
|
const b = resolved[j].position;
|
||||||
// Push the widget that wasn't just moved (or the lower one)
|
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) {
|
||||||
const moveIdx = resolved[i].id === sourceId ? j : i;
|
hasOverlap = true;
|
||||||
const fixedIdx = moveIdx === i ? j : i;
|
const moveIdx = resolved[i].id === widgetId ? j : i;
|
||||||
resolved[moveIdx] = {
|
const fixedIdx = moveIdx === i ? j : i;
|
||||||
...resolved[moveIdx],
|
resolved[moveIdx] = {
|
||||||
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
|
...resolved[moveIdx],
|
||||||
};
|
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)) {
|
||||||
updateWidget(id, w.id, { position: w.position });
|
updateWidget(id, w.id, { position: w.position });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
setDraggingId(null);
|
document.addEventListener("mousemove", onMove);
|
||||||
};
|
document.addEventListener("mouseup", onUp);
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setDraggingId(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user