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 [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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user