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:
one 2025-09-17 17:26:40 +08:00 committed by GitHub
parent 1b04fd065d
commit aab941d89c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 738 additions and 15 deletions

View File

@ -17,14 +17,7 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"keywords": [
"ui",
"components",
"react",
"tailwindcss",
"typescript",
"cherry-studio"
],
"keywords": ["ui", "components", "react", "tailwindcss", "typescript", "cherry-studio"],
"author": "Cherry Studio",
"license": "MIT",
"repository": {
@ -43,6 +36,10 @@
"tailwindcss": "^4.1.13"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0"
},
@ -79,10 +76,7 @@
"engines": {
"node": ">=18.0.0"
},
"files": [
"dist",
"README.md"
],
"files": ["dist", "README.md"],
"exports": {
".": {
"types": "./dist/index.d.ts",

View File

@ -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 (复合组件)

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default as Sortable } from './Sortable'

View File

@ -0,0 +1,2 @@
export * from './useDndReorder'
export * from './useDndState'

View File

@ -1,5 +1,5 @@
// 主入口文件 - 导出所有公共API
export * from './components'
// export * from './hooks'
export * from './hooks'
// export * from './types'
export * from './utils'

View File

@ -0,0 +1,185 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import clsx from 'clsx'
import { useMemo, useState } from 'react'
import { Sortable } from '../../../src/components/interactive/Sortable'
import { useDndReorder } from '../../../src/hooks'
type ExampleItem = { id: number; label: string }
const initialItems: ExampleItem[] = Array.from({ length: 18 }).map((_, i) => ({
id: i + 1,
label: `Item ${i + 1}`
}))
const meta: Meta<typeof Sortable> = {
title: 'Interactive/Sortable',
component: Sortable,
parameters: {
layout: 'padded',
docs: {
description: {
component:
'A basic drag-and-drop sorting component that supports vertical/horizontal lists and grid layout. Each demo includes a search box to filter items, and useDndReorder ensures drags in the filtered view correctly update the original list order.'
}
}
},
tags: ['autodocs'],
argTypes: {
gap: { control: 'text', description: 'CSS gap value, e.g., 8px, 0.5rem, 12px' },
useDragOverlay: { control: 'boolean' },
showGhost: { control: 'boolean' }
},
args: {
gap: '8px',
useDragOverlay: true,
showGhost: false
}
}
export default meta
type Story = StoryObj<typeof meta>
function useExampleData() {
const [originalList, setOriginalList] = useState<ExampleItem[]>(initialItems)
const [query, setQuery] = useState('')
const filteredList = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return originalList
return originalList.filter((x) => x.label.toLowerCase().includes(q))
}, [query, originalList])
const { onSortEnd } = useDndReorder<ExampleItem>({
originalList,
filteredList,
onUpdate: setOriginalList,
itemKey: 'id'
})
return { originalList, setOriginalList, query, setQuery, filteredList, onSortEnd }
}
function ItemCard({ item, dragging }: { item: ExampleItem; dragging: boolean }) {
return (
<div
className={clsx(
'select-none rounded-md border p-3 shadow-sm transition',
dragging ? 'opacity-50 ring-2 ring-blue-400' : 'bg-white'
)}>
<div className="text-sm font-medium">{item.label}</div>
</div>
)
}
export const Vertical: Story = {
render: (args) => <VerticalDemo {...(args as any)} />
}
export const Horizontal: Story = {
render: (args) => <HorizontalDemo {...(args as any)} />
}
export const Grid: Story = {
render: (args) => <GridDemo {...(args as any)} />
}
function VerticalDemo(args: any) {
const { query, setQuery, filteredList, onSortEnd } = useExampleData()
return (
<div className="w-full space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search (fuzzy match label)"
className="w-full rounded-md border px-3 py-2 text-sm"
/>
<div className="overflow-x-auto h-[500px]">
<Sortable<ExampleItem>
items={filteredList}
itemKey="id"
onSortEnd={onSortEnd}
layout="list"
horizontal={false}
gap={args.gap as string}
useDragOverlay={args.useDragOverlay as boolean}
showGhost={args.showGhost as boolean}
renderItem={(item, { dragging }) => (
<div className="min-w-[200px]">
<ItemCard item={item} dragging={dragging} />
</div>
)}
/>
</div>
<p className="text-xs text-gray-500">
Dragging within a filtered view correctly updates the original order (handled by useDndReorder).
</p>
</div>
)
}
function HorizontalDemo(args: any) {
const { query, setQuery, filteredList, onSortEnd } = useExampleData()
return (
<div className="w-full space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search (fuzzy match label)"
className="w-full rounded-md border px-3 py-2 text-sm"
/>
<div className="overflow-x-auto">
<Sortable<ExampleItem>
items={filteredList}
itemKey="id"
onSortEnd={onSortEnd}
layout="list"
horizontal
gap={args.gap as string}
useDragOverlay={args.useDragOverlay as boolean}
showGhost={args.showGhost as boolean}
renderItem={(item, { dragging }) => (
<div className="min-w-[100px]">
<ItemCard item={item} dragging={dragging} />
</div>
)}
/>
</div>
<p className="text-xs text-gray-500">Horizontal dragging with overflow scrolling.</p>
</div>
)
}
function GridDemo(args: any) {
const { query, setQuery, filteredList, onSortEnd } = useExampleData()
return (
<div className="w-full space-y-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search (fuzzy match label)"
className="w-full rounded-md border px-3 py-2 text-sm"
/>
<Sortable<ExampleItem>
items={filteredList}
itemKey="id"
onSortEnd={onSortEnd}
layout="grid"
gap={(args.gap as string) ?? '12px'}
useDragOverlay={args.useDragOverlay as boolean}
showGhost={args.showGhost as boolean}
renderItem={(item, { dragging }) => <ItemCard item={item} dragging={dragging} />}
/>
<p className="text-xs text-gray-500">Responsive grid layout with drag-and-drop sorting.</p>
</div>
)
}

View File

@ -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}
/>
)
}

View File

@ -0,0 +1 @@
export type RenderItemType<T> = (item: T, props: { dragging: boolean }) => React.ReactNode

View File

@ -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 }
}

View File

@ -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
}
}

View File

@ -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']
}

View File

@ -1,5 +1,5 @@
import { PlusOutlined } from '@ant-design/icons'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import { Sortable, useDndReorder } from '@cherrystudio/ui'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { isMac } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'

View File

@ -1,7 +1,7 @@
import { Sortable, useDndReorder } from '@cherrystudio/ui'
import { loggerService } from '@logger'
import { nanoid } from '@reduxjs/toolkit'
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import { EditIcon, RefreshIcon } from '@renderer/components/Icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMCPServers } from '@renderer/hooks/useMCPServers'

View File

@ -2651,6 +2651,10 @@ __metadata:
version: 0.0.0-use.local
resolution: "@cherrystudio/ui@workspace:packages/ui"
dependencies:
"@dnd-kit/core": "npm:^6.3.1"
"@dnd-kit/modifiers": "npm:^9.0.0"
"@dnd-kit/sortable": "npm:^10.0.0"
"@dnd-kit/utilities": "npm:^3.2.2"
"@heroui/react": "npm:^2.8.4"
"@storybook/addon-docs": "npm:^9.1.6"
"@storybook/addon-themes": "npm:^9.1.6"