mcpField
K
kanban-dnd

kanban-dnd

github.com/blink-new设计与 UI70 次安装
代码仓库

关于 kanban-dnd

kanban-dnd 是使用 @dnd-kit 构建看板和流程板拖放交互的技能,重点处理跨列移动、碰撞检测及桌面和触屏操作细节。

主要功能

  1. 01优先识别列的碰撞检测
  2. 02支持卡片跨列移动
  3. 03使拖动覆盖层对齐光标
  4. 04配置鼠标与触屏传感器
  5. 05支持可撤销的拖放操作

适用场景

  1. 01构建可拖动卡片的看板
  2. 02避免跨列移动时误判放置目标
  3. 03实现即时鼠标拖动并兼容触屏滚动
  4. 04为移动和排序添加撤销与重做

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/blink-new/claude --skill kanban-dnd

安装后在 Claude Code 中输入/kanban-dnd确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

assets/components/
kanban-board.tsx
kanban-card.tsx
kanban-column.tsx
assets/lib/
collision-detection.ts

详细介绍

何时使用此技能

适用于:

  • 构建支持拖放的看板/流水线面板
  • 实现卡片在列之间移动
  • 需要正确的碰撞检测,并优先匹配列而非卡片
  • 希望实现类似 Linear/Notion 的拖拽体验,叠加层跟随光标移动

技术栈

软件包版本用途
@dnd-kit/core^6.x核心 DnD 上下文和钩子
@dnd-kit/modifiers^9.x光标吸附修饰符
@dnd-kit/utilities^3.xCSS 变换工具

安装

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

架构概览

┌─────────────────────────────────────────────────────────────┐
│  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)       │
└─────────────────────────────────────────────────────────────┘

关键模式

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

问题: 默认碰撞检测在放下卡片时,可能返回卡片 ID,而非列 ID。

解决方案: 使用自定义碰撞检测,优先匹配可放置的列:

// 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. 使用数据类型设置列

列必须包含 data.type,以便碰撞检测进行筛选:

const { setNodeRef } = useDroppable({
  id: column.id,
  data: {
    type: "column",  // CRITICAL: Enables collision filtering
    columnId: column.id,
  },
});
3. 使用 useDraggable 设置卡片(而非 useSortable)

如果需要在不同列之间移动卡片,而不需要在列内重新排序,请使用 useDraggable:

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"
>
4. 将放置目标解析为列 ID

同时处理放置到列和放置到卡片的情况:

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]
);
5. 使用光标吸附的 DragOverlay

问题: 默认的 DragOverlay 与光标之间会有偏移。

解决方案: 使用 snapCenterToCursor 修饰符,并禁用放置动画:

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>
6. 传感器配置——拖动即时响应,兼顾移动端滚动

使用 distance 约束,绝不要使用 delay。 distance 约束会在指针移动几 px 时立即开始拖动——拖动体验响应迅速,普通点击(不移动)仍会触发卡片的 onClick。delay 约束会迫使用户在卡片变为可拖动状态前按住不放,而且如果在延迟结束前快速滑动,拖动会被完全取消——卡片根本不会移动。这是一个非常常见的“必须先按住才能拖动”的问题。

// ✅ Instant on mouse, still scrolls on touch.
// MouseSensor (mouse only) + TouchSensor (touch only) beats a single
// PointerSensor, which can't both start instantly on mouse AND let a finger
// swipe scroll the board on mobile.
const sensors = useSensors(
  useSensor(MouseSensor, {
    activationConstraint: { distance: 4 }, // drag starts after ~4px, no hold
  }),
  useSensor(TouchSensor, {
    // A short press-and-hold on touch so a vertical swipe scrolls the board
    // instead of grabbing a card. Touch is the one place a small delay is right.
    activationConstraint: { delay: 200, tolerance: 8 },
  }),
  useSensor(KeyboardSensor, {
    coordinateGetter: sortableKeyboardCoordinates,
  })
);

如果不需要支持移动端滚动,单独使用带有 { distance: 4 } 的 PointerSensor 即可,响应同样即时。关键规则是:使用 distance,而不是 delay。

7. 视觉反馈状态
// 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}
/>
8. 列内重新排序与可排序列(高级)

上面的模式介绍了如何使用 useDraggable 在列之间移动项目。如果你还需要在列内重新排列卡片,和/或重新排列列本身,请改用 useSortable + 嵌套的 SortableContext(全部放在同一个 DndContext 中):

<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
  {columns.map((col) => <Column key={col.id} column={col} />)}
</SortableContext>

// Inside each Column, its cards get their own vertical context:
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
  {cards.map((c) => <Card key={c.id} card={c} />)}
</SortableContext>

在 onDragEnd 中,根据 active.data.current.type("column" 或 "card")分支,以判断你是在重新排列列还是移动卡片。

注意事项——可排序列本身也是放置目标;不要重复注册。 useSortable 已经让节点具备可放置能力。如果你还在同一列上调用 useDroppable({ id: column.id }),就会注册两个 ID 相同的可放置项,从而破坏碰撞检测(卡片将无法可靠地放置)。只使用 useSortable 即可——它会提供 setNodeRef(放置目标)、attributes/listeners(拖动手柄)和 isOver:

// ✅ One registration is both the card drop target and the column drag source
const { setNodeRef, attributes, listeners, transform, transition, isOver } =
  useSortable({ id: column.id, data: { type: "column", columnId: column.id } });
// ❌ Do NOT also do: useDroppable({ id: column.id })

放置指示器:在悬停的卡片上显示线条,而不是持续显示空隙。 在指针当前悬停的那张卡片(isOver)上绘制一条细插入线,并根据被拖动项目的索引与该卡片的索引,决定将线放在卡片上方还是下方。不要只依赖可排序项目之间不断移动的空隙——清晰的线条易于辨认,而且只会出现在相关位置。

const { isOver, active, index } = useSortable({ id: card.id, data: { ... } });
const showIndicator = isOver && active?.id !== card.id;
let above = false, below = false;
if (showIndicator) {
  if (active?.data.current?.columnId !== card.columnId) {
    above = true; // cross-column: insert before the hovered card
  } else {
    const activeIndex = active?.data.current?.sortable?.index ?? -1;
    above = activeIndex > index;             // dragging up → line above
    below = activeIndex < index && activeIndex !== -1; // dragging down → below
  }
}
// Card root must be `relative`. Lines are absolutely positioned so DOM order
// doesn't matter:
{above && <div className="absolute -top-1 left-0 right-0 h-0.5 rounded-full bg-primary z-10" />}
{below && <div className="absolute -bottom-1 left-0 right-0 h-0.5 rounded-full bg-primary z-10" />}
// Dragged original is just dimmed in place:
className={cn("relative", isDragging && "opacity-30 z-50")}

“卡片先复位,再在松开时跳到位”闪动,几乎总是因为 DragOverlay 缺少 dropAnimation={null}(参见 #5)。默认的放置动画会先让浮动副本飞回原始位置,然后列表才会稳定到新的顺序。务必设置 dropAnimation={null}。

悬停时显示拖动手柄——但要收缩它的_宽度_,而不只是透明度。 常驻的抓握图标(⠿)会造成视觉干扰,所以只在悬停时显示它,并在列根节点上使用 group/col。问题在于:单独使用 opacity-0 group-hover/col:opacity-100 仍会让手柄占据布局空间——它会保留自身宽度,旁边的 flex gap 也会保留——因此即使抓握图标不可见,标题仍会因为幽灵间距而一直向内缩进。(绝对定位手柄只会转移问题:现在它会与标题重叠,或超出列的边缘。)

你真正想要的是:

idle  → [no space][Title]            handle reserves nothing, title flush
hover → [⠿ space ][Title]            handle expands in-flow, title slides right

实现方法是:空闲时将手柄收缩至零宽度,悬停时再展开;同时移除标题栏上的 flex gap(即使相邻子项宽度为零,gap 仍会生效)——让手柄自身控制外边距,这样它就只会在悬停时出现:

{/* header: NO `gap` — the handle manages its own spacing */}
<div className="flex items-center px-1">
  <button
    {...attributes} {...listeners}
    className="flex w-0 shrink-0 items-center overflow-hidden opacity-0
               transition-all duration-150
               group-hover/col:mr-1 group-hover/col:w-4 group-hover/col:opacity-100
               cursor-grab active:cursor-grabbing touch-none"
  >
    <GripVertical className="h-4 w-4 shrink-0" />
  </button>
  <span className="font-semibold">{name}</span>
  <span className="ml-2 …">{count}</span>   {/* space the rest explicitly */}
</div>

通过动画过渡 w-0 → w-4(加上 mr),标题会随着抓握图标淡入而平滑地向右滑动,空闲状态则会与下方的卡片完全对齐。

拖动列时,在覆盖层中渲染整个列——卡片使用_静态_副本。 覆盖层应预览标题栏和卡片,让用户拖动的是完整副本,而不只是标题。但不要在覆盖层中渲染真实的 useSortable 卡片组件:卡片仍挂载在列表中,因此会注册重复的可排序 ID,并破坏测量和碰撞检测。请渲染轻量的静态副本(外观与卡片相同的普通 div),并限制其高度不超过列的最大高度:

<DragOverlay dropAnimation={null}>
  {activeColumn ? (
    <div className="w-[272px] rotate-[2deg] rounded-xl border bg-muted/95 p-2.5 shadow-xl ...">
      <Header name={activeColumn.name} count={cards.length} />
      <div className="flex max-h-[440px] flex-col gap-2 overflow-hidden">
        {cards.map((c) => (
          <div key={c.id} className="rounded-lg border bg-card px-3 py-2.5">
            {c.title}            {/* static clone — NOT <SortableCard/> */}
          </div>
        ))}
      </div>
    </div>
  ) : activeCard ? (
    <Card card={activeCard} isDragging />
  ) : null}
</DragOverlay>
9. 在 onDragEnd 中从缓存读取列项目(注意事项)

为了计算放置时的新顺序,你通常会从 React Query 缓存中读取列的项目。不要重建完全相同的查询键——该列很可能是使用额外参数(limit、排序、includeX 等)获取的,手动构建的键将无法匹配,因此 getQueryData 会返回 undefined,重新排序也就不会有任何效果(这是“有些列可以拖动,有些列不行”这一经典问题的成因)。请以不依赖缓存键的方式读取——扫描所有列表查询,并保留作用域属于该列的查询:

// ❌ Misses the cache if the real query was { columnId, limit: 200 }
const items = queryClient.getQueryData(itemKeys.list({ columnId }));

// ✅ Match by the column field, ignore other filter params
function getColumnItems(columnId: string): Item[] {
  const byId = new Map<string, Item>();
  for (const [key, data] of queryClient.getQueriesData<Item[]>({
    queryKey: itemKeys.lists(),
  })) {
    const filters = (key as unknown[])[2] as { columnId?: string } | undefined;
    if (data && filters?.columnId === columnId) {
      for (const it of data) if (!byId.has(it.id)) byId.set(it.id, it);
    }
  }
  return [...byId.values()];
}
10. 可撤销的拖动操作

要让拖动操作支持 Cmd+Z 撤销,每次成功移动或重新排序时都要记录一个逆向命令。一个容易忽略的细节是:**跨列移动必须恢复所有受影响分组之前的顺序——包括源列和目标列——而不只是目标列。**如果只恢复目标列,卡片仍会留在新列中,因此撤销操作看起来什么也没做。

// Capture the *pre-move* order of every affected bucket: the destination
// plus each source column a card moved out of.
record({
  label: "Move card",
  undo: async () => {
    for (const [columnId, order] of Object.entries(prevOrders)) {
      // Restoring a source column (whose saved order still includes the moved
      // card) sends the card home; restoring the destination drops it.
      await api.reorder({ columnId, itemIds: order });
    }
  },
  redo: async () => api.reorder({ columnId: destColumnId, itemIds: newOrder }),
});

在变更钩子中记录,而不是在每个调用位置分别记录,这样所有入口(拖动、键盘快捷键、菜单)都能通过同一个位置支持撤销。撤销/重做时直接调用 API(不要调用记录钩子),以免应用历史记录时又记录新的条目。

完整事件处理器

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);
}, []);

样式模式

卡片状态
// 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"
列状态
// Column during any drag
isDragging && "bg-muted/30"

// Column being hovered
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"
空列状态

仅在拖动进行中时渲染放置占位符。空列处于空闲状态时不应占用任何空间——不要预留一个常驻的“拖到这里”区域;列自身的“添加”入口就足够了。持续显示占位符很碍眼,会让空闲的看板显得杂乱。

{/* ✅ Only while a card is being dragged. Idle empty column collapses. */}
{isDragging && (
  <div className={cn(
    "rounded-lg border-2 border-dashed px-2.5 py-4 text-center",
    isOver ? "border-primary/40 bg-primary/5" : "border-border/50"
  )}>
    Drop here
  </div>
)}

{/* ❌ Don't: a permanent box that reserves space when nothing is dragging */}
// <div className="border-2 border-dashed ...">Drag items here</div>

应避免的常见错误

❌ 使用 useSortable 进行跨列移动
// WRONG - useSortable is for within-list reordering
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });
✅ 使用 useDraggable 进行跨列移动
// CORRECT - useDraggable for simple drag to droppable
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });
❌ 假设 over.id 始终是列 ID
// 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!
};
✅ 将 over.id 解析为列 ID
// CORRECT - Handle both column and card drop targets
const targetColumnId = findTargetColumnId(over.id);
❌ 使用 snapCenterToCursor 时启用放置动画
// WRONG - Causes fly-out effect on drop
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={{ duration: 200, easing: "ease-out" }}
>
✅ 禁用放置动画
// CORRECT - Instant disappear on drop
<DragOverlay
  modifiers={[snapCenterToCursor]}
  dropAnimation={null}
>
❌ 使用单独的拖动手柄
// WRONG - Creates offset issues, worse UX
<Card>
  <div {...listeners}>
    <GripIcon /> {/* Only this area is draggable */}
  </div>
  <Content />
</Card>
✅ 让整张卡片都可拖动
// CORRECT - Linear-style drag anywhere
<Card {...listeners} {...attributes} className="cursor-grab">
  <Content />
</Card>
❌ 在鼠标上使用基于 delay 的激活方式
// WRONG - the card only drags after a hold, and a quick flick is cancelled
useSensor(PointerSensor, { activationConstraint: { delay: 100, tolerance: 5 } })
✅ 在鼠标上使用基于 distance 的激活方式
// CORRECT - drag starts instantly on a few px of movement; click still works
useSensor(MouseSensor, { activationConstraint: { distance: 4 } })
❌ 放置时重建精确的查询键来读取列项目
// WRONG - won't match a cache fetched with extra params (limit/sort/…)
const items = queryClient.getQueryData(itemKeys.list({ columnId })); // undefined
✅ 通过匹配所有列表查询中的列字段来读取缓存

参见模式 #9(getColumnItems)——重新排序无声失败,几乎总是由这个问题导致。

❌ 在 useSortable 列上单独注册 useDroppable
// WRONG - two droppables share column.id, breaking collision detection
useSortable({ id: column.id, ... });
useDroppable({ id: column.id }); // duplicate id!
❌ 在也会处理 Enter/Space 的可聚焦卡片上使用 KeyboardSensor

鼠标拖动后,dnd-kit 会将 DOM 焦点留在被拖动的卡片上。KeyboardSensor 的默认激活键是空格/Enter,因此它会将焦点所在卡片上的下一个空格/Enter 视为“开始拖动”——按 Enter 执行其他快捷操作时,会意外地再次抓起卡片并使其进入拖动状态。如果卡片可以聚焦,而且应用会在其他地方绑定 Enter/Space,请移除 KeyboardSensor,改用显式快捷键提供键盘重新排序功能(上移/下移、延后等),或者重新映射它的 keyboardCodes。

// ✅ Mouse + touch only; no accidental Enter/Space drags
const sensors = useSensors(
  useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
  useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
);

使用 React Query 进行乐观更新

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 });
    },
  });
}

文件结构

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

相关资源文件

资源描述
assets/lib/collision-detection.ts列优先碰撞检测
assets/components/kanban-board.tsx完整的看板实现
assets/components/kanban-column.tsx带视觉反馈的可放置列
assets/components/kanban-card.tsx可拖动卡片组件

检查清单

  • 已安装 @dnd-kit/core、@dnd-kit/modifiers、@dnd-kit/utilities
  • 传感器使用 distance 激活约束,绝不使用 delay(立即拖动)
  • 如果看板必须支持在移动设备上滚动,则使用 MouseSensor(distance)+ TouchSensor(delay)
  • 自定义碰撞检测优先匹配列
  • 列使用 useDroppable,并设置 data.type: "column"(如果列可重新排序,则使用 useSortable)
  • 卡片仅在跨列拖动时使用 useDraggable;如果支持列内重新排序,则使用 useSortable + SortableContext
  • useSortable 列不会同时注册单独的 useDroppable(避免 ID 重复)
  • findTargetColumnId 能解析列和卡片拖放目标
  • 在 onDragEnd 中,列项以不依赖缓存键的方式从缓存读取(而不是重建缓存键)
  • DragOverlay 使用 snapCenterToCursor modifier
  • DragOverlay 设置 dropAnimation={null}(否则松开时卡片会先“复位”,再跳到目标位置)
  • 列内拖放时,在悬停卡片上显示插入线(isOver),而非持久占位间隙
  • 空列占位符仅在拖动期间渲染——空闲状态下的空列不占空间
  • DragOverlay 包装元素的宽度固定,并与卡片宽度一致
  • 卡片使用 touch-none select-none cursor-grab 类
  • 为 isOver 和 isDragging 状态提供视觉反馈
  • 出错时回滚乐观更新
  • 可撤销的移动会恢复所有受影响分组之前的顺序(源分组 + 目标分组)

评论

支持 Markdown