refactor: migrate sortable (#10204)
* refactor: rename sortable dir * refactor: migrate Sortable to the ui package * feat: add stories for Sortable * refactor: add scroller to the vertical story * refactor: improve hints and width * refactor: simplify item style * fix: lint errors * chore: dependencies * refactor: move hooks * fix: import errors * style: format * style: format
This commit is contained in:
@@ -63,6 +63,7 @@ export { default as ImageToolButton } from './interactive/ImageToolButton'
|
||||
export { default as InfoPopover } from './interactive/InfoPopover'
|
||||
export { default as InfoTooltip } from './interactive/InfoTooltip'
|
||||
export { default as Selector } from './interactive/Selector'
|
||||
export { Sortable } from './interactive/Sortable'
|
||||
export { default as WarnTooltip } from './interactive/WarnTooltip'
|
||||
|
||||
// Composite Components (复合组件)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DraggableSyntheticListeners } from '@dnd-kit/core'
|
||||
import type { Transform } from '@dnd-kit/utilities'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import React, { useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { cn } from '../../../utils'
|
||||
import type { RenderItemType } from './types'
|
||||
|
||||
interface ItemRendererProps<T> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
index?: number
|
||||
item: T
|
||||
renderItem: RenderItemType<T>
|
||||
dragging?: boolean
|
||||
dragOverlay?: boolean
|
||||
ghost?: boolean
|
||||
transform?: Transform | null
|
||||
transition?: string | null
|
||||
listeners?: DraggableSyntheticListeners
|
||||
}
|
||||
|
||||
export function ItemRenderer<T>({
|
||||
ref,
|
||||
index,
|
||||
item,
|
||||
renderItem,
|
||||
dragging,
|
||||
dragOverlay,
|
||||
ghost,
|
||||
transform,
|
||||
transition,
|
||||
listeners,
|
||||
...props
|
||||
}: ItemRendererProps<T>) {
|
||||
useEffect(() => {
|
||||
if (!dragOverlay) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
return () => {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}, [dragOverlay])
|
||||
|
||||
const wrapperStyle = {
|
||||
transition,
|
||||
transform: CSS.Transform.toString(transform ?? null)
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<ItemWrapper ref={ref} data-index={index} className={cn({ dragOverlay: dragOverlay })} style={{ ...wrapperStyle }}>
|
||||
<DraggableItem
|
||||
className={cn({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
|
||||
{...listeners}
|
||||
{...props}>
|
||||
{renderItem(item, { dragging: !!dragging })}
|
||||
</DraggableItem>
|
||||
</ItemWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemWrapper = styled.div`
|
||||
box-sizing: border-box;
|
||||
transform-origin: 0 0;
|
||||
touch-action: manipulation;
|
||||
|
||||
&.dragOverlay {
|
||||
--scale: 1.02;
|
||||
z-index: 999;
|
||||
position: relative;
|
||||
}
|
||||
`
|
||||
|
||||
const DraggableItem = styled.div`
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer; /* default cursor for items */
|
||||
touch-action: manipulation;
|
||||
transform-origin: 50% 50%;
|
||||
transform: scale(var(--scale, 1));
|
||||
|
||||
&.dragging:not(.dragOverlay) {
|
||||
z-index: 0;
|
||||
opacity: 0.25;
|
||||
|
||||
&:not(.ghost) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.dragOverlay {
|
||||
cursor: inherit;
|
||||
animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22);
|
||||
transform: scale(var(--scale));
|
||||
opacity: 1;
|
||||
pointer-events: none; /* prevent pointer events on drag overlay */
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(var(--scale));
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
Active,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
Modifier,
|
||||
Over,
|
||||
UniqueIdentifier
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
restrictToFirstScrollableAncestor,
|
||||
restrictToHorizontalAxis,
|
||||
restrictToVerticalAxis,
|
||||
restrictToWindowEdges
|
||||
} from '@dnd-kit/modifiers'
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { ItemRenderer } from './ItemRenderer'
|
||||
import { SortableItem } from './SortableItem'
|
||||
import type { RenderItemType } from './types'
|
||||
import { PortalSafePointerSensor } from './utils'
|
||||
|
||||
interface SortableProps<T> {
|
||||
/** Array of sortable items */
|
||||
items: T[]
|
||||
/** Function or key to get unique identifier for each item */
|
||||
itemKey: keyof T | ((item: T) => string | number)
|
||||
/** Callback when sorting is complete, receives old and new indices */
|
||||
onSortEnd: (event: { oldIndex: number; newIndex: number }) => void
|
||||
/** Callback when drag starts, will be passed to dnd-kit's onDragStart */
|
||||
onDragStart?: (event: { active: Active }) => void
|
||||
/** Callback when drag ends, will be passed to dnd-kit's onDragEnd */
|
||||
onDragEnd?: (event: { over: Over | null }) => void
|
||||
/** Function to render individual item, receives item data and drag state */
|
||||
renderItem: RenderItemType<T>
|
||||
/** Layout type - 'list' for vertical/horizontal list, 'grid' for grid layout */
|
||||
layout?: 'list' | 'grid'
|
||||
/** Whether sorting is horizontal */
|
||||
horizontal?: boolean
|
||||
/** Whether to use drag overlay
|
||||
* If you want to hide ghost item, set showGhost to false rather than useDragOverlay.
|
||||
*/
|
||||
useDragOverlay?: boolean
|
||||
/** Whether to show ghost item, only works when useDragOverlay is true */
|
||||
showGhost?: boolean
|
||||
/** Item list class name */
|
||||
className?: string
|
||||
/** Item list style */
|
||||
listStyle?: React.CSSProperties
|
||||
/** Item gap */
|
||||
gap?: number | string
|
||||
/** Restrictions, shortcuts for some modifiers */
|
||||
restrictions?: {
|
||||
/** Add modifier restrictToWindowEdges */
|
||||
windowEdges?: boolean
|
||||
/** Add modifier restrictToFirstScrollableAncestor */
|
||||
scrollableAncestor?: boolean
|
||||
}
|
||||
/** Additional modifiers */
|
||||
modifiers?: Modifier[]
|
||||
}
|
||||
|
||||
function Sortable<T>({
|
||||
items,
|
||||
itemKey,
|
||||
onSortEnd,
|
||||
onDragStart: customOnDragStart,
|
||||
onDragEnd: customOnDragEnd,
|
||||
renderItem,
|
||||
layout = 'list',
|
||||
horizontal = false,
|
||||
useDragOverlay = true,
|
||||
showGhost = false,
|
||||
className,
|
||||
listStyle,
|
||||
gap,
|
||||
restrictions,
|
||||
modifiers: customModifiers
|
||||
}: SortableProps<T>) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PortalSafePointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
}
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 5
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
)
|
||||
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as string | number)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
const itemIds = useMemo(() => items.map(getId), [items, getId])
|
||||
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
|
||||
const activeItem = activeId ? items.find((item) => getId(item) === activeId) : null
|
||||
|
||||
const getIndex = (id: UniqueIdentifier) => itemIds.indexOf(id)
|
||||
|
||||
const activeIndex = activeId ? getIndex(activeId) : -1
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
customOnDragStart?.({ active })
|
||||
if (active) {
|
||||
setActiveId(active.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ over }: DragEndEvent) => {
|
||||
setActiveId(null)
|
||||
|
||||
customOnDragEnd?.({ over })
|
||||
if (over) {
|
||||
const overIndex = getIndex(over.id)
|
||||
if (activeIndex !== overIndex) {
|
||||
onSortEnd({ oldIndex: activeIndex, newIndex: overIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
const strategy =
|
||||
layout === 'list' ? (horizontal ? horizontalListSortingStrategy : verticalListSortingStrategy) : rectSortingStrategy
|
||||
|
||||
const { windowEdges = false, scrollableAncestor = false } = restrictions ?? {}
|
||||
|
||||
const modifiers = useMemo<Modifier[]>(
|
||||
() => [
|
||||
...(layout === 'list' ? [horizontal ? restrictToHorizontalAxis : restrictToVerticalAxis] : []),
|
||||
...(windowEdges ? [restrictToWindowEdges] : []),
|
||||
...(scrollableAncestor ? [restrictToFirstScrollableAncestor] : []),
|
||||
...(customModifiers ?? [])
|
||||
],
|
||||
[layout, horizontal, windowEdges, scrollableAncestor, customModifiers]
|
||||
)
|
||||
|
||||
const dropAnimation: DropAnimation = useMemo(
|
||||
() => ({
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: { opacity: showGhost ? '0.25' : '0' }
|
||||
}
|
||||
})
|
||||
}),
|
||||
[showGhost]
|
||||
)
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
modifiers={modifiers}>
|
||||
<SortableContext items={itemIds} strategy={strategy}>
|
||||
<ListWrapper
|
||||
className={className}
|
||||
data-layout={layout}
|
||||
data-direction={horizontal ? 'horizontal' : 'vertical'}
|
||||
$gap={gap}
|
||||
style={listStyle}>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={itemIds[index]}
|
||||
id={itemIds[index]}
|
||||
index={index}
|
||||
item={item}
|
||||
renderItem={renderItem}
|
||||
useDragOverlay={useDragOverlay}
|
||||
showGhost={showGhost}
|
||||
/>
|
||||
))}
|
||||
</ListWrapper>
|
||||
</SortableContext>
|
||||
|
||||
{useDragOverlay
|
||||
? createPortal(
|
||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
const ListWrapper = styled.div<{ $gap?: number | string }>`
|
||||
gap: ${({ $gap }) => $gap};
|
||||
|
||||
&[data-layout='grid'] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-layout='list'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&[data-layout='list'][data-direction='horizontal'] {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&[data-layout='list'][data-direction='vertical'] {
|
||||
flex-direction: column;
|
||||
}
|
||||
`
|
||||
|
||||
export default Sortable
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
|
||||
import { ItemRenderer } from './ItemRenderer'
|
||||
import type { RenderItemType } from './types'
|
||||
|
||||
interface SortableItemProps<T> {
|
||||
item: T
|
||||
id: string | number
|
||||
index: number
|
||||
renderItem: RenderItemType<T>
|
||||
useDragOverlay?: boolean
|
||||
showGhost?: boolean
|
||||
}
|
||||
|
||||
export function SortableItem<T>({
|
||||
item,
|
||||
id,
|
||||
index,
|
||||
renderItem,
|
||||
useDragOverlay = true,
|
||||
showGhost = true
|
||||
}: SortableItemProps<T>) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id
|
||||
})
|
||||
|
||||
return (
|
||||
<ItemRenderer
|
||||
ref={setNodeRef}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
dragging={isDragging}
|
||||
dragOverlay={!useDragOverlay && isDragging}
|
||||
ghost={showGhost && useDragOverlay && isDragging}
|
||||
transform={transform}
|
||||
transition={transition}
|
||||
listeners={listeners}
|
||||
{...attributes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Sortable } from './Sortable'
|
||||
@@ -0,0 +1 @@
|
||||
export type RenderItemType<T> = (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PointerSensor } from '@dnd-kit/core'
|
||||
|
||||
export const PORTAL_NO_DND_SELECTORS = [
|
||||
'.ant-dropdown',
|
||||
'.ant-select-dropdown',
|
||||
'.ant-popover',
|
||||
'.ant-tooltip',
|
||||
'.ant-modal'
|
||||
].join(',')
|
||||
|
||||
/**
|
||||
* Prevent drag on elements with specific classes or data-no-dnd attribute
|
||||
*/
|
||||
export class PortalSafePointerSensor extends PointerSensor {
|
||||
static activators = [
|
||||
{
|
||||
eventName: 'onPointerDown',
|
||||
handler: ({ nativeEvent: event }) => {
|
||||
let target = event.target as HTMLElement
|
||||
|
||||
while (target) {
|
||||
if (target.closest(PORTAL_NO_DND_SELECTORS) || target.dataset?.noDnd) {
|
||||
return false
|
||||
}
|
||||
target = target.parentElement as HTMLElement
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
] as (typeof PointerSensor)['activators']
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './useDndReorder'
|
||||
export * from './useDndState'
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Key } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
interface UseDndReorderParams<T> {
|
||||
/** 原始的、完整的数据列表 */
|
||||
originalList: T[]
|
||||
/** 当前在界面上渲染的、可能被过滤的列表 */
|
||||
filteredList: T[]
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
itemKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强拖拽排序能力,处理“过滤后列表”与“原始列表”的索引映射问题。
|
||||
*
|
||||
* @template T 列表项的类型
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调
|
||||
*/
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, itemKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
const map = new Map<Key, number>()
|
||||
originalList.forEach((item, index) => {
|
||||
map.set(getId(item), index)
|
||||
})
|
||||
return map
|
||||
}, [originalList, getId])
|
||||
|
||||
// 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引
|
||||
const getItemKey = useCallback(
|
||||
(index: number): Key => {
|
||||
const item = filteredList[index]
|
||||
// 如果找不到item,返回视图索引兜底
|
||||
if (!item) return index
|
||||
|
||||
const originalIndex = itemIndexMap.get(getId(item))
|
||||
return originalIndex ?? index
|
||||
},
|
||||
[filteredList, itemIndexMap, getId]
|
||||
)
|
||||
|
||||
// 创建 onSortEnd 回调,封装了所有重排逻辑
|
||||
const onSortEnd = useCallback(
|
||||
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
|
||||
// 使用 getItemKey 将视图索引转换为数据索引
|
||||
const sourceOriginalIndex = getItemKey(oldIndex) as number
|
||||
const destOriginalIndex = getItemKey(newIndex) as number
|
||||
|
||||
// 如果索引转换失败,不进行任何操作
|
||||
if (sourceOriginalIndex === undefined || destOriginalIndex === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceOriginalIndex === destOriginalIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// 操作原始列表的副本
|
||||
const newList = [...originalList]
|
||||
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
|
||||
newList.splice(destOriginalIndex, 0, movedItem)
|
||||
|
||||
// 调用外部更新函数
|
||||
onUpdate(newList)
|
||||
},
|
||||
[getItemKey, originalList, onUpdate]
|
||||
)
|
||||
|
||||
return { onSortEnd, itemKey: getItemKey }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useDndContext } from '@dnd-kit/core'
|
||||
|
||||
interface DndState {
|
||||
/** 是否有元素正在拖拽 */
|
||||
isDragging: boolean
|
||||
/** 当前拖拽元素的ID */
|
||||
draggedId: string | number | null
|
||||
/** 当前悬停位置的ID */
|
||||
overId: string | number | null
|
||||
/** 是否正在悬停在某个可放置区域 */
|
||||
isOver: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供 dnd-kit 的全局拖拽状态管理
|
||||
*
|
||||
* @returns 当前拖拽状态信息
|
||||
*/
|
||||
export function useDndState(): DndState {
|
||||
const { active, over } = useDndContext()
|
||||
|
||||
return {
|
||||
isDragging: active !== null,
|
||||
draggedId: active?.id ?? null,
|
||||
overId: over?.id ?? null,
|
||||
isOver: over !== null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// 主入口文件 - 导出所有公共API
|
||||
export * from './components'
|
||||
// export * from './hooks'
|
||||
export * from './hooks'
|
||||
// export * from './types'
|
||||
export * from './utils'
|
||||
|
||||
Reference in New Issue
Block a user