Fix Kanban drag jitter: use DragOverlay + ghost placeholder
Root causes of the jitter: 1. No DragOverlay — card transformed in-place via translate(), causing parent column layout reflow as siblings shifted around the gap. 2. transition-all on cards fought with drag transforms on slow moves. 3. closestCorners collision bounced rapidly between column boundaries. Fixes: - DragOverlay renders a floating copy of the card above everything, with a subtle 2deg rotation and shadow for visual feedback. - Original card becomes a ghost placeholder (accent border, 40% opacity) so the column layout stays stable during drag. - Switched to closestCenter collision detection (less boundary bounce). - Increased PointerSensor distance from 5px to 8px to reduce accidental drag activation. - Removed transition-all from card styles (no more CSS vs drag fight). - dropAnimation: null for instant snap on release. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7eac213c20
commit
e0a5f4855f
@ -1,13 +1,16 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCorners,
|
closestCenter,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
type DragStartEvent,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
useDraggable,
|
useDraggable,
|
||||||
|
DragOverlay,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import type { ProjectTask } from '@/types';
|
import type { ProjectTask } from '@/types';
|
||||||
@ -43,11 +46,13 @@ function KanbanColumn({
|
|||||||
column,
|
column,
|
||||||
tasks,
|
tasks,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
|
draggingId,
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
}: {
|
}: {
|
||||||
column: (typeof COLUMNS)[0];
|
column: (typeof COLUMNS)[0];
|
||||||
tasks: ProjectTask[];
|
tasks: ProjectTask[];
|
||||||
selectedTaskId: number | null;
|
selectedTaskId: number | null;
|
||||||
|
draggingId: number | null;
|
||||||
onSelectTask: (taskId: number) => void;
|
onSelectTask: (taskId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||||
@ -78,6 +83,7 @@ function KanbanColumn({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
isSelected={selectedTaskId === task.id}
|
isSelected={selectedTaskId === task.id}
|
||||||
|
isDragSource={draggingId === task.id}
|
||||||
onSelect={() => onSelectTask(task.id)}
|
onSelect={() => onSelectTask(task.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -86,41 +92,19 @@ function KanbanColumn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanCard({
|
// Card content — shared between in-place card and drag overlay
|
||||||
task,
|
function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) {
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
task: ProjectTask;
|
|
||||||
isSelected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
|
||||||
id: task.id,
|
|
||||||
data: { task },
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = transform
|
|
||||||
? {
|
|
||||||
transform: `translate(${transform.x}px, ${transform.y}px)`,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
|
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
|
||||||
const totalSubtasks = task.subtasks?.length ?? 0;
|
const totalSubtasks = task.subtasks?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
className={`rounded-md border p-3 ${
|
||||||
style={style}
|
ghost
|
||||||
{...listeners}
|
? 'border-accent/20 bg-accent/5 opacity-40'
|
||||||
{...attributes}
|
: isSelected
|
||||||
onClick={onSelect}
|
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
|
||||||
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${
|
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
|
||||||
isSelected
|
|
||||||
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
|
|
||||||
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
|
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
|
||||||
@ -141,7 +125,6 @@ function KanbanCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Assignee avatars */}
|
|
||||||
{task.assignments && task.assignments.length > 0 && (
|
{task.assignments && task.assignments.length > 0 && (
|
||||||
<div className="flex justify-end mt-2">
|
<div className="flex justify-end mt-2">
|
||||||
<AssigneeAvatars assignments={task.assignments} max={2} />
|
<AssigneeAvatars assignments={task.assignments} max={2} />
|
||||||
@ -151,6 +134,35 @@ function KanbanCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KanbanCard({
|
||||||
|
task,
|
||||||
|
isSelected,
|
||||||
|
isDragSource,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
task: ProjectTask;
|
||||||
|
isSelected: boolean;
|
||||||
|
isDragSource: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||||
|
id: task.id,
|
||||||
|
data: { task },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
onClick={onSelect}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CardContent task={task} isSelected={isSelected} ghost={isDragSource} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function KanbanBoard({
|
export default function KanbanBoard({
|
||||||
tasks,
|
tasks,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
@ -159,16 +171,24 @@ export default function KanbanBoard({
|
|||||||
onStatusChange,
|
onStatusChange,
|
||||||
onBackToAllTasks,
|
onBackToAllTasks,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
|
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
,
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
|
|
||||||
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
|
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
|
||||||
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
|
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null;
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
setDraggingId(event.active.id as number);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
setDraggingId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
@ -179,7 +199,11 @@ export default function KanbanBoard({
|
|||||||
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
||||||
onStatusChange(taskId, newStatus);
|
onStatusChange(taskId, newStatus);
|
||||||
}
|
}
|
||||||
};
|
}, [activeTasks, onStatusChange]);
|
||||||
|
|
||||||
|
const handleDragCancel = useCallback(() => {
|
||||||
|
setDraggingId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const tasksByStatus = COLUMNS.map((col) => ({
|
const tasksByStatus = COLUMNS.map((col) => ({
|
||||||
column: col,
|
column: col,
|
||||||
@ -206,8 +230,10 @@ export default function KanbanBoard({
|
|||||||
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
||||||
@ -216,10 +242,20 @@ export default function KanbanBoard({
|
|||||||
column={column}
|
column={column}
|
||||||
tasks={colTasks}
|
tasks={colTasks}
|
||||||
selectedTaskId={selectedTaskId}
|
selectedTaskId={selectedTaskId}
|
||||||
|
draggingId={draggingId}
|
||||||
onSelectTask={onSelectTask}
|
onSelectTask={onSelectTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Floating overlay — renders above everything, no layout impact */}
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{draggingTask ? (
|
||||||
|
<div className="w-[200px] opacity-95 shadow-lg shadow-black/30 rotate-[2deg]">
|
||||||
|
<CardContent task={draggingTask} isSelected={false} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user