mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 02:09:03 +08:00
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:
parent
1b04fd065d
commit
aab941d89c
@ -17,14 +17,7 @@
|
|||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": ["ui", "components", "react", "tailwindcss", "typescript", "cherry-studio"],
|
||||||
"ui",
|
|
||||||
"components",
|
|
||||||
"react",
|
|
||||||
"tailwindcss",
|
|
||||||
"typescript",
|
|
||||||
"cherry-studio"
|
|
||||||
],
|
|
||||||
"author": "Cherry Studio",
|
"author": "Cherry Studio",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
@ -43,6 +36,10 @@
|
|||||||
"tailwindcss": "^4.1.13"
|
"tailwindcss": "^4.1.13"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.525.0"
|
"lucide-react": "^0.525.0"
|
||||||
},
|
},
|
||||||
@ -79,10 +76,7 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": ["dist", "README.md"],
|
||||||
"dist",
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|||||||
@ -63,6 +63,7 @@ export { default as ImageToolButton } from './interactive/ImageToolButton'
|
|||||||
export { default as InfoPopover } from './interactive/InfoPopover'
|
export { default as InfoPopover } from './interactive/InfoPopover'
|
||||||
export { default as InfoTooltip } from './interactive/InfoTooltip'
|
export { default as InfoTooltip } from './interactive/InfoTooltip'
|
||||||
export { default as Selector } from './interactive/Selector'
|
export { default as Selector } from './interactive/Selector'
|
||||||
|
export { Sortable } from './interactive/Sortable'
|
||||||
export { default as WarnTooltip } from './interactive/WarnTooltip'
|
export { default as WarnTooltip } from './interactive/WarnTooltip'
|
||||||
|
|
||||||
// Composite Components (复合组件)
|
// Composite Components (复合组件)
|
||||||
|
|||||||
110
packages/ui/src/components/interactive/Sortable/ItemRenderer.tsx
Normal file
110
packages/ui/src/components/interactive/Sortable/ItemRenderer.tsx
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
246
packages/ui/src/components/interactive/Sortable/Sortable.tsx
Normal file
246
packages/ui/src/components/interactive/Sortable/Sortable.tsx
Normal 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
|
||||||
1
packages/ui/src/components/interactive/Sortable/index.ts
Normal file
1
packages/ui/src/components/interactive/Sortable/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Sortable } from './Sortable'
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from './useDndReorder'
|
||||||
|
export * from './useDndState'
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// 主入口文件 - 导出所有公共API
|
// 主入口文件 - 导出所有公共API
|
||||||
export * from './components'
|
export * from './components'
|
||||||
// export * from './hooks'
|
export * from './hooks'
|
||||||
// export * from './types'
|
// export * from './types'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
|||||||
185
packages/ui/stories/components/interactive/Sortable.stories.tsx
Normal file
185
packages/ui/stories/components/interactive/Sortable.stories.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/renderer/src/components/Sortable/SortableItem.tsx
Normal file
42
src/renderer/src/components/Sortable/SortableItem.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/renderer/src/components/Sortable/types.ts
Normal file
1
src/renderer/src/components/Sortable/types.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type RenderItemType<T> = (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||||
78
src/renderer/src/components/Sortable/useDndReorder.ts
Normal file
78
src/renderer/src/components/Sortable/useDndReorder.ts
Normal 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 }
|
||||||
|
}
|
||||||
28
src/renderer/src/components/Sortable/useDndState.ts
Normal file
28
src/renderer/src/components/Sortable/useDndState.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/renderer/src/components/Sortable/utils.ts
Normal file
31
src/renderer/src/components/Sortable/utils.ts
Normal 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']
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons'
|
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 HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
import { Sortable, useDndReorder } from '@cherrystudio/ui'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { nanoid } from '@reduxjs/toolkit'
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
|
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
|
||||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
|
||||||
import { EditIcon, RefreshIcon } from '@renderer/components/Icons'
|
import { EditIcon, RefreshIcon } from '@renderer/components/Icons'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||||
|
|||||||
@ -2651,6 +2651,10 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@cherrystudio/ui@workspace:packages/ui"
|
resolution: "@cherrystudio/ui@workspace:packages/ui"
|
||||||
dependencies:
|
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"
|
"@heroui/react": "npm:^2.8.4"
|
||||||
"@storybook/addon-docs": "npm:^9.1.6"
|
"@storybook/addon-docs": "npm:^9.1.6"
|
||||||
"@storybook/addon-themes": "npm:^9.1.6"
|
"@storybook/addon-themes": "npm:^9.1.6"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user