kanban-dnd

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

When to Use This Skill

何时使用该技能

Use when:
  • Building a kanban/pipeline board with drag-and-drop
  • Implementing card movement between columns
  • Need proper collision detection that prioritizes columns over cards
  • Want Linear/Notion-style drag UX with cursor-following overlay
适用于以下场景:
  • 构建具备拖拽功能的看板/流程面板时
  • 实现卡片在列之间的移动时
  • 需要优先识别列而非卡片的精准碰撞检测时
  • 想要实现Linear/Notion风格的拖拽用户体验,包含跟随光标覆盖层时

Tech Stack

技术栈

PackageVersionPurpose
@dnd-kit/core
^6.xCore DnD context and hooks
@dnd-kit/modifiers
^9.xCursor snapping modifiers
@dnd-kit/utilities
^3.xCSS transform utilities
包名版本用途
@dnd-kit/core
^6.x核心拖拽上下文与钩子
@dnd-kit/modifiers
^9.x光标吸附修饰器
@dnd-kit/utilities
^3.xCSS变换工具类

Installation

安装步骤

bash
bun add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/utilities
bash
bun add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/utilities

Architecture Overview

架构概览

┌─────────────────────────────────────────────────────────────┐
│  DndContext (sensors, collision detection, event handlers) │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │
│  │ Column  │  │ Column  │  │ Column  │  │ Column  │        │
│  │(droppa- │  │(droppa- │  │(droppa- │  │(droppa- │        │
│  │  ble)   │  │  ble)   │  │  ble)   │  │  ble)   │        │
│  │ ┌─────┐ │  │ ┌─────┐ │  │ ┌─────┐ │  │         │        │
│  │ │Card │ │  │ │Card │ │  │ │Card │ │  │  Empty  │        │
│  │ │drag │ │  │ │drag │ │  │ │drag │ │  │         │        │
│  │ └─────┘ │  │ └─────┘ │  │ └─────┘ │  │         │        │
│  │ ┌─────┐ │  │ ┌─────┐ │  │         │  │         │        │
│  │ │Card │ │  │ │Card │ │  │         │  │         │        │
│  │ └─────┘ │  │ └─────┘ │  │         │  │         │        │
│  └─────────┘  └─────────┘  └─────────┘  └─────────┘        │
├─────────────────────────────────────────────────────────────┤
│  DragOverlay (follows cursor with snapCenterToCursor)       │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│  DndContext (sensors, collision detection, event handlers) │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐        │
│  │ Column  │  │ Column  │  │ Column  │  │ Column  │        │
│  │(droppa- │  │(droppa- │  │(droppa- │  │(droppa- │        │
│  │  ble)   │  │  ble)   │  │  ble)   │  │  ble)   │        │
│  │ ┌─────┐ │  │ ┌─────┐ │  │ ┌─────┐ │  │         │        │
│  │ │Card │ │  │ │Card │ │  │ │Card │ │  │  Empty  │        │
│  │ │drag │ │  │ │drag │ │  │ │drag │ │  │         │        │
│  │ └─────┘ │  │ └─────┘ │  │ └─────┘ │  │         │        │
│  │ ┌─────┐ │  │ ┌─────┐ │  │         │  │         │        │
│  │ │Card │ │  │ │Card │ │  │         │  │         │        │
│  │ └─────┘ │  │ └─────┘ │  │         │  │         │        │
│  └─────────┘  └─────────┘  └─────────┘  └─────────┘        │
├─────────────────────────────────────────────────────────────┤
│  DragOverlay (follows cursor with snapCenterToCursor)       │
└─────────────────────────────────────────────────────────────┘

Critical Patterns

核心实现模式

1. Custom Collision Detection (MOST IMPORTANT)

1. 自定义碰撞检测(最重要)

Problem: Default collision detection can return card IDs when dropping on a card instead of the column ID.
Solution: Custom collision detection that prioritizes column droppables:
typescript
// lib/dnd/collision-detection.ts
import {
  pointerWithin,
  rectIntersection,
  type CollisionDetection,
} from "@dnd-kit/core";

export const columnPriorityCollision: CollisionDetection = (args) => {
  // First, check pointer intersections
  const pointerCollisions = pointerWithin(args);
  
  // Filter to only column collisions (columns have data.type === 'column')
  const columnCollisions = pointerCollisions.filter(
    (collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
  );

  if (columnCollisions.length > 0) {
    return columnCollisions;
  }

  // Fallback to rect intersection
  const rectCollisions = rectIntersection(args);
  const columnRectCollisions = rectCollisions.filter(
    (collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
  );

  if (columnRectCollisions.length > 0) {
    return columnRectCollisions;
  }

  return rectCollisions;
};
问题: 默认的碰撞检测在卡片上释放时,可能返回卡片ID而非列ID。
解决方案: 实现优先识别列容器的自定义碰撞检测:
typescript
// lib/dnd/collision-detection.ts
import {
  pointerWithin,
  rectIntersection,
  type CollisionDetection,
} from "@dnd-kit/core";

export const columnPriorityCollision: CollisionDetection = (args) => {
  // First, check pointer intersections
  const pointerCollisions = pointerWithin(args);
  
  // Filter to only column collisions (columns have data.type === 'column')
  const columnCollisions = pointerCollisions.filter(
    (collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
  );

  if (columnCollisions.length > 0) {
    return columnCollisions;
  }

  // Fallback to rect intersection
  const rectCollisions = rectIntersection(args);
  const columnRectCollisions = rectCollisions.filter(
    (collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
  );

  if (columnRectCollisions.length > 0) {
    return columnRectCollisions;
  }

  return rectCollisions;
};

2. Column Setup with Data Type

2. 配置带数据类型的列

Columns must include
data.type
for collision detection filtering:
typescript
const { setNodeRef } = useDroppable({
  id: column.id,
  data: {
    type: "column",  // CRITICAL: Enables collision filtering
    columnId: column.id,
  },
});
列必须包含
data.type
字段,以便碰撞检测过滤:
typescript
const { setNodeRef } = useDroppable({
  id: column.id,
  data: {
    type: "column",  // 关键:启用碰撞过滤
    columnId: column.id,
  },
});

3. Card Setup with useDraggable (NOT useSortable)

3. 使用useDraggable配置卡片(而非useSortable)

For cross-column moves without within-column reordering, use
useDraggable
:
typescript
const {
  attributes,
  listeners,
  setNodeRef,
  isDragging,
} = useDraggable({
  id: card.id,
  data: {
    type: "card",
    card,
  },
});

// Apply to entire card for drag-anywhere behavior
<Card
  ref={setNodeRef}
  {...attributes}
  {...listeners}
  className="cursor-grab active:cursor-grabbing touch-none select-none"
>
如果仅需要跨列移动,不需要列内重新排序,请使用
useDraggable
typescript
const {
  attributes,
  listeners,
  setNodeRef,
  isDragging,
} = useDraggable({
  id: card.id,
  data: {
    type: "card",
    card,
  },
});

// 应用到整个卡片,实现任意位置拖拽
<Card
  ref={setNodeRef}
  {...attributes}
  {...listeners}
  className="cursor-grab active:cursor-grabbing touch-none select-none"
>

4. Resolving Drop Target to Column ID

4. 将释放目标解析为列ID

Handle both column drops and card drops:
typescript
const findTargetColumnId = useCallback(
  (overId: UniqueIdentifier | undefined): string | null => {
    if (!overId) return null;
    
    const overIdStr = String(overId);
    
    // Check if it's a column ID directly
    if (columnIds.has(overIdStr)) {
      return overIdStr;
    }
    
    // Otherwise it's a card ID - find which column that card is in
    const columnId = cardColumnMap.get(overIdStr);
    return columnId || null;
  },
  [columnIds, cardColumnMap]
);
处理释放到列或卡片上的两种情况:
typescript
const findTargetColumnId = useCallback(
  (overId: UniqueIdentifier | undefined): string | null => {
    if (!overId) return null;
    
    const overIdStr = String(overId);
    
    // 检查是否直接是列ID
    if (columnIds.has(overIdStr)) {
      return overIdStr;
    }
    
    // 否则是卡片ID - 查找该卡片所属的列
    const columnId = cardColumnMap.get(overIdStr);
    return columnId || null;
  },
  [columnIds, cardColumnMap]
);

5. DragOverlay with Cursor Snapping

5. 带光标吸附的DragOverlay

Problem: Default DragOverlay appears offset from cursor.
Solution: Use
snapCenterToCursor
modifier and disable drop animation:
typescript
import { snapCenterToCursor } from "@dnd-kit/modifiers";

<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={null}  // Prevents fly-out effect on drop
>
  {activeCard ? (
    <div className="w-[272px] pointer-events-none">
      <Card card={activeCard} isDragging />
    </div>
  ) : null}
</DragOverlay>
问题: 默认的DragOverlay会与光标位置偏移。
解决方案: 使用
snapCenterToCursor
修饰器并禁用释放动画:
typescript
import { snapCenterToCursor } from "@dnd-kit/modifiers";

<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={null}  // 防止释放时的飞出效果
>
  {activeCard ? (
    <div className="w-[272px] pointer-events-none">
      <Card card={activeCard} isDragging />
    </div>
  ) : null}
</DragOverlay>

6. Sensor Configuration

6. 传感器配置

typescript
const sensors = useSensors(
  useSensor(PointerSensor, {
    activationConstraint: {
      distance: 5,  // 5px movement before drag activates
    },
  }),
  useSensor(KeyboardSensor, {
    coordinateGetter: sortableKeyboardCoordinates,
  })
);
typescript
const sensors = useSensors(
  useSensor(PointerSensor, {
    activationConstraint: {
      distance: 5,  // 移动5px后触发拖拽
    },
  }),
  useSensor(KeyboardSensor, {
    coordinateGetter: sortableKeyboardCoordinates,
  })
);

7. Visual Feedback States

7. 视觉反馈状态

typescript
// Board tracks active states
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeOverColumn, setActiveOverColumn] = useState<string | null>(null);

// Pass to columns for visual feedback
<Column
  isOver={activeOverColumn === column.id}
  isDragging={!!activeCard}
/>
typescript
// 面板跟踪激活状态
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeOverColumn, setActiveOverColumn] = useState<string | null>(null);

// 传递给列组件以提供视觉反馈
<Column
  isOver={activeOverColumn === column.id}
  isDragging={!!activeCard}
/>

Complete Event Handlers

完整事件处理函数

typescript
const handleDragStart = useCallback((event: DragStartEvent) => {
  const { active } = event;
  const card = cards?.find((c) => c.id === active.id);
  if (card) {
    setActiveCard(card);
    setActiveOverColumn(card.columnId);
  }
}, [cards]);

const handleDragOver = useCallback((event: DragOverEvent) => {
  const { over } = event;
  if (!over) {
    setActiveOverColumn(null);
    return;
  }
  const targetColumnId = findTargetColumnId(over.id);
  setActiveOverColumn(targetColumnId);
}, [findTargetColumnId]);

const handleDragEnd = useCallback((event: DragEndEvent) => {
  const { active, over } = event;
  
  setActiveCard(null);
  setActiveOverColumn(null);

  if (!over) return;

  const cardId = String(active.id);
  const targetColumnId = findTargetColumnId(over.id);

  if (!targetColumnId) return;

  const card = cards?.find((c) => c.id === cardId);
  if (!card || card.columnId === targetColumnId) return;

  // Optimistic update via mutation
  updateCard.mutate({
    cardId,
    data: { columnId: targetColumnId },
  });
}, [cards, findTargetColumnId, updateCard]);

const handleDragCancel = useCallback(() => {
  setActiveCard(null);
  setActiveOverColumn(null);
}, []);
typescript
const handleDragStart = useCallback((event: DragStartEvent) => {
  const { active } = event;
  const card = cards?.find((c) => c.id === active.id);
  if (card) {
    setActiveCard(card);
    setActiveOverColumn(card.columnId);
  }
}, [cards]);

const handleDragOver = useCallback((event: DragOverEvent) => {
  const { over } = event;
  if (!over) {
    setActiveOverColumn(null);
    return;
  }
  const targetColumnId = findTargetColumnId(over.id);
  setActiveOverColumn(targetColumnId);
}, [findTargetColumnId]);

const handleDragEnd = useCallback((event: DragEndEvent) => {
  const { active, over } = event;
  
  setActiveCard(null);
  setActiveOverColumn(null);

  if (!over) return;

  const cardId = String(active.id);
  const targetColumnId = findTargetColumnId(over.id);

  if (!targetColumnId) return;

  const card = cards?.find((c) => c.id === cardId);
  if (!card || card.columnId === targetColumnId) return;

  // 通过mutation实现乐观更新
  updateCard.mutate({
    cardId,
    data: { columnId: targetColumnId },
  });
}, [cards, findTargetColumnId, updateCard]);

const handleDragCancel = useCallback(() => {
  setActiveCard(null);
  setActiveOverColumn(null);
}, []);

Styling Patterns

样式实现模式

Card States

卡片状态

typescript
// Original card being dragged
isCurrentlyDragging && "opacity-30"

// DragOverlay card
isDragging && "shadow-xl ring-2 ring-primary/20 rotate-[1deg] cursor-grabbing"

// Entire card is draggable
"cursor-grab active:cursor-grabbing touch-none select-none"
typescript
// 正在被拖拽的原卡片
isCurrentlyDragging && "opacity-30"

// DragOverlay中的卡片
isDragging && "shadow-xl ring-2 ring-primary/20 rotate-[1deg] cursor-grabbing"

// 整个卡片可拖拽
"cursor-grab active:cursor-grabbing touch-none select-none"

Column States

列状态

typescript
// Column during any drag
isDragging && "bg-muted/30"

// Column being hovered
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"
typescript
// 任意拖拽操作中的列
isDragging && "bg-muted/30"

// 被悬停的列
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"

Empty Column State

空列状态

typescript
<div className={cn(
  "border-2 border-dashed rounded-lg",
  isOver ? "border-primary/40 bg-primary/5" : "border-border/50"
)}>
  <p>{isDragging ? "Drop here" : "Drag items here"}</p>
</div>
typescript
<div className={cn(
  "border-2 border-dashed rounded-lg",
  isOver ? "border-primary/40 bg-primary/5" : "border-border/50"
)}>
  <p>{isDragging ? "Drop here" : "Drag items here"}</p>
</div>

Common Mistakes to Avoid

需要避免的常见错误

❌ Using useSortable for cross-column moves

❌ 使用useSortable实现跨列移动

typescript
// WRONG - useSortable is for within-list reordering
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });
typescript
// 错误 - useSortable适用于列表内重新排序
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });

✅ Use useDraggable for cross-column moves

✅ 使用useDraggable实现跨列移动

typescript
// CORRECT - useDraggable for simple drag to droppable
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });
typescript
// 正确 - useDraggable适用于拖拽到容器的场景
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });

❌ Assuming over.id is always a column ID

❌ 假设over.id始终是列ID

typescript
// WRONG - over.id could be a card ID if you drop on a card
const handleDragEnd = (event) => {
  const newColumnId = over.id as string;  // Might be a card ID!
};
typescript
// 错误 - over.id可能是卡片ID,如果释放到卡片上
const handleDragEnd = (event) => {
  const newColumnId = over.id as string;  // 可能是卡片ID!
};

✅ Resolve over.id to column ID

✅ 将over.id解析为列ID

typescript
// CORRECT - Handle both column and card drop targets
const targetColumnId = findTargetColumnId(over.id);
typescript
// 正确 - 处理释放到列或卡片的两种情况
const targetColumnId = findTargetColumnId(over.id);

❌ Using drop animation with snapCenterToCursor

❌ 结合snapCenterToCursor使用释放动画

typescript
// WRONG - Causes fly-out effect on drop
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={{ duration: 200, easing: "ease-out" }}
>
typescript
// 错误 - 会导致释放时的飞出效果
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={{ duration: 200, easing: "ease-out" }}
>

✅ Disable drop animation

✅ 禁用释放动画

typescript
// CORRECT - Instant disappear on drop
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={null}
>
typescript
// 正确 - 释放时立即消失
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={null}
>

❌ Separate drag handle

❌ 使用单独的拖拽手柄

typescript
// WRONG - Creates offset issues, worse UX
<Card>
  <div {...listeners}>
    <GripIcon /> {/* Only this area is draggable */}
  </div>
  <Content />
</Card>
typescript
// 错误 - 会导致位置偏移,用户体验更差
<Card>
  <div {...listeners}>
    <GripIcon /> {/* 仅该区域可拖拽 */}
  </div>
  <Content />
</Card>

✅ Entire card draggable

✅ 整个卡片可拖拽

typescript
// CORRECT - Linear-style drag anywhere
<Card {...listeners} {...attributes} className="cursor-grab">
  <Content />
</Card>
typescript
// 正确 - Linear风格的任意位置拖拽
<Card {...listeners} {...attributes} className="cursor-grab">
  <Content />
</Card>

Optimistic Updates with React Query

使用React Query实现乐观更新

typescript
export function useUpdateCard(teamId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ cardId, data }) => updateCard(teamId, cardId, data),

    // Optimistic update
    onMutate: async ({ cardId, data }) => {
      await queryClient.cancelQueries({ queryKey: cardKeys.lists() });

      const previousCards = queryClient.getQueriesData<Card[]>({
        queryKey: cardKeys.lists(),
      });

      queryClient.setQueriesData<Card[]>(
        { queryKey: cardKeys.lists() },
        (old) => old?.map((card) =>
          card.id === cardId ? { ...card, ...data } : card
        )
      );

      return { previousCards };
    },

    // Rollback on error
    onError: (_err, _variables, context) => {
      context?.previousCards?.forEach(([queryKey, data]) => {
        queryClient.setQueryData(queryKey, data);
      });
    },

    // Refetch after settle
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: cardKeys.all });
    },
  });
}
typescript
export function useUpdateCard(teamId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ cardId, data }) => updateCard(teamId, cardId, data),

    // 乐观更新
    onMutate: async ({ cardId, data }) => {
      await queryClient.cancelQueries({ queryKey: cardKeys.lists() });

      const previousCards = queryClient.getQueriesData<Card[]>({
        queryKey: cardKeys.lists(),
      });

      queryClient.setQueriesData<Card[]>(
        { queryKey: cardKeys.lists() },
        (old) => old?.map((card) =>
          card.id === cardId ? { ...card, ...data } : card
        )
      );

      return { previousCards };
    },

    // 错误时回滚
    onError: (_err, _variables, context) => {
      context?.previousCards?.forEach(([queryKey, data]) => {
        queryClient.setQueryData(queryKey, data);
      });
    },

    // 完成后重新获取数据
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: cardKeys.all });
    },
  });
}

File Structure

文件结构

src/
├── components/
│   └── kanban/
│       ├── kanban-board.tsx       # DndContext wrapper
│       ├── kanban-column.tsx      # Droppable column
│       ├── kanban-card.tsx        # Draggable card
│       └── kanban-skeleton.tsx    # Loading state
├── lib/
│   └── dnd/
│       └── collision-detection.ts # Custom collision detection
└── hooks/
    └── use-cards.ts               # React Query with optimistic updates
src/
├── components/
│   └── kanban/
│       ├── kanban-board.tsx       # DndContext包装组件
│       ├── kanban-column.tsx      # 可释放的列组件
│       ├── kanban-card.tsx        # 可拖拽的卡片组件
│       └── kanban-skeleton.tsx    # 加载状态组件
├── lib/
│   └── dnd/
│       └── collision-detection.ts # 自定义碰撞检测
└── hooks/
    └── use-cards.ts               # 带乐观更新的React Query钩子

Related Asset Files

相关资源文件

AssetDescription
assets/lib/collision-detection.ts
Column-priority collision detection
assets/components/kanban-board.tsx
Complete board implementation
assets/components/kanban-column.tsx
Droppable column with visual feedback
assets/components/kanban-card.tsx
Draggable card component
资源描述
assets/lib/collision-detection.ts
优先识别列的碰撞检测实现
assets/components/kanban-board.tsx
完整的看板实现
assets/components/kanban-column.tsx
带视觉反馈的可释放列组件
assets/components/kanban-card.tsx
可拖拽卡片组件

Checklist

检查清单

  • Installed
    @dnd-kit/core
    ,
    @dnd-kit/modifiers
    ,
    @dnd-kit/utilities
  • Custom collision detection prioritizes columns
  • Columns use
    useDroppable
    with
    data.type: "column"
  • Cards use
    useDraggable
    (NOT
    useSortable
    )
  • findTargetColumnId
    resolves both column and card drop targets
  • DragOverlay uses
    snapCenterToCursor
    modifier
  • DragOverlay has
    dropAnimation={null}
  • DragOverlay wrapper has fixed width matching card width
  • Cards have
    touch-none select-none cursor-grab
    classes
  • Visual feedback for
    isOver
    and
    isDragging
    states
  • Optimistic updates with rollback on error
  • 已安装
    @dnd-kit/core
    ,
    @dnd-kit/modifiers
    ,
    @dnd-kit/utilities
  • 自定义碰撞检测优先识别列
  • 列使用
    useDroppable
    并设置
    data.type: "column"
  • 卡片使用
    useDraggable
    (而非
    useSortable
  • findTargetColumnId
    可解析列和卡片两种释放目标
  • DragOverlay使用
    snapCenterToCursor
    修饰器
  • DragOverlay设置
    dropAnimation={null}
  • DragOverlay容器宽度与卡片宽度匹配
  • 卡片添加
    touch-none select-none cursor-grab
  • 实现
    isOver
    isDragging
    状态的视觉反馈
  • 实现带错误回滚的乐观更新