mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +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",
|
||||
"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",
|
||||
|
||||
@ -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 (复合组件)
|
||||
|
||||
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
|
||||
export * from './components'
|
||||
// export * from './hooks'
|
||||
export * from './hooks'
|
||||
// export * from './types'
|
||||
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 { 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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user