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:
Kyle 2026-03-17 04:37:56 +08:00
parent 7eac213c20
commit e0a5f4855f

View File

@ -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>
); );