关于 drag-and-drop
drag-and-drop 是使用 @atlaskit/pragmatic-drag-and-drop 实现拖放界面的编码技能。它提供 React 模式,涵盖可拖动项、列表作用域、放置指示器和顺序持久化。
主要功能
- 01设置可拖动项和放置目标
- 02用 ref 跟踪易变状态
- 03通过列表实例 ID 限定操作范围
- 04显示基于边缘的放置指示器
- 05以乐观更新方式调整顺序
适用场景
- 01在 React 中构建可排序列表
- 02添加拖动手柄和自定义预览
- 03调整项目顺序并保存位置
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ wodsmith/ thewodapp --skill drag-and-drop安装后在 Claude Code 中输入/drag-and-drop确认已加载。
详细介绍
使用 Pragmatic DnD 拖放
本项目使用 @atlaskit/pragmatic-drag-and-drop 实现拖放功能。
必需的导入
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import {
draggable,
dropTargetForElements,
type ElementDropTargetEventBasePayload,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"
import {
attachClosestEdge,
type Edge,
extractClosestEdge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box"
关键模式:为易变状态使用 Refs
绝不要将易变的拖放状态放入 useEffect 依赖项中。 这样会导致处理程序在每次状态变化时重新注册。
// BAD - re-registers handlers on every edge change
const [closestEdge, setClosestEdge] = useState<Edge | null>(null)
useEffect(() => {
// ...handlers using closestEdge
}, [closestEdge]) // Re-runs on every drag movement!
// GOOD - use ref + useCallback for volatile state
const [closestEdge, setClosestEdge] = useState<Edge | null>(null)
const closestEdgeRef = useRef<Edge | null>(null)
// Wrap in useCallback for lint compliance (exhaustive-deps)
const updateClosestEdge = useCallback((edge: Edge | null) => {
closestEdgeRef.current = edge
setClosestEdge(edge) // Still update state for rendering
}, [])
useEffect(() => {
// ...handlers read closestEdgeRef.current instead
}, [/* stable deps */, updateClosestEdge]) // Include updateClosestEdge
导入 useCallback:
import { useCallback, useEffect, useRef, useState } from "react"
可拖动项目的基本模式
function DraggableItem({ item, index, instanceId, onDrop }) {
const ref = useRef<HTMLDivElement>(null)
const dragHandleRef = useRef<HTMLButtonElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [closestEdge, setClosestEdge] = useState<Edge | null>(null)
const closestEdgeRef = useRef<Edge | null>(null)
const updateClosestEdge = useCallback((edge: Edge | null) => {
closestEdgeRef.current = edge
setClosestEdge(edge)
}, [])
useEffect(() => {
const element = ref.current
const dragHandle = dragHandleRef.current
if (!element || !dragHandle) return
const itemData = { id: item.id, index, instanceId }
return combine(
draggable({
element: dragHandle,
getInitialData: () => itemData,
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "16px", y: "8px" }),
render({ container }) {
const preview = document.createElement("div")
preview.style.cssText = `
background: hsl(var(--background));
border: 2px solid hsl(var(--border));
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
color: hsl(var(--foreground));
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
`
preview.textContent = item.label
container.appendChild(preview)
},
})
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
source.data.instanceId === instanceId && source.data.index !== index,
getData({ input }) {
return attachClosestEdge(itemData, {
element,
input,
allowedEdges: ["top", "bottom"],
})
},
onDrag({ source, self }: ElementDropTargetEventBasePayload) {
if (source.data.index === index) {
updateClosestEdge(null)
return
}
const edge = extractClosestEdge(self.data)
const sourceIndex = source.data.index
if (typeof sourceIndex !== "number") return
// Hide indicator when it would be redundant
const isItemBeforeSource = index === sourceIndex - 1
const isItemAfterSource = index === sourceIndex + 1
const isDropIndicatorHidden =
(isItemBeforeSource && edge === "bottom") ||
(isItemAfterSource && edge === "top")
updateClosestEdge(isDropIndicatorHidden ? null : edge)
},
onDragLeave: () => updateClosestEdge(null),
onDrop({ source }) {
const sourceIndex = source.data.index
if (typeof sourceIndex === "number" && sourceIndex !== index) {
const edge = closestEdgeRef.current // Read from ref!
const targetIndex = edge === "top" ? index : index + 1
const adjustedTargetIndex =
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
onDrop(sourceIndex, adjustedTargetIndex)
}
updateClosestEdge(null)
},
}),
)
}, [item.id, item.label, index, instanceId, onDrop, updateClosestEdge])
return (
<div ref={ref} className="relative">
{closestEdge && <DropIndicator edge={closestEdge} gap="2px" />}
<div className={isDragging ? "opacity-50" : ""}>
<button ref={dragHandleRef} type="button" aria-label="Drag to reorder">
<GripVertical />
</button>
{/* Item content */}
</div>
</div>
)
}
多个列表的实例 ID
使用 Symbol 将拖放操作限定在单个列表中:
function SortableList({ items }) {
const [instanceId] = useState(() => Symbol("list"))
// Pass instanceId to each item
}
重新排序处理程序
const handleDrop = async (sourceIndex: number, targetIndex: number) => {
const newItems = [...items]
const [movedItem] = newItems.splice(sourceIndex, 1)
if (movedItem) {
newItems.splice(targetIndex, 0, movedItem)
const updated = newItems.map((item, i) => ({ ...item, position: i }))
setItems(updated) // Optimistic update
await saveOrder(updated) // Persist
}
}
检查清单
- 为易变状态使用 Refs(closestEdge 等)
- 使用 useCallback 包装 updateClosestEdge(符合 lint 要求)
- 在 useEffect 依赖项中包含 updateClosestEdge
- 使用实例 ID 限定列表范围
- 使用边缘检测显示放置指示器
- 使用自定义拖动预览
- 使用乐观 UI 更新
评论
支持 Markdown