From aab941d89cae5b5f7de2f6ec0a2ae0dbecdb2fd5 Mon Sep 17 00:00:00 2001 From: one Date: Wed, 17 Sep 2025 17:26:40 +0800 Subject: [PATCH] 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 --- packages/ui/package.json | 18 +- packages/ui/src/components/index.ts | 1 + .../interactive/Sortable/ItemRenderer.tsx | 110 ++++++++ .../interactive/Sortable/Sortable.tsx | 246 ++++++++++++++++++ .../interactive/Sortable}/SortableItem.tsx | 0 .../components/interactive/Sortable/index.ts | 1 + .../components/interactive/Sortable}/types.ts | 0 .../components/interactive/Sortable}/utils.ts | 0 packages/ui/src/hooks/index.ts | 2 + .../ui/src/hooks}/useDndReorder.ts | 0 .../ui/src/hooks}/useDndState.ts | 0 packages/ui/src/index.ts | 2 +- .../interactive/Sortable.stories.tsx | 185 +++++++++++++ .../{dnd => Sortable}/ItemRenderer.tsx | 0 .../components/{dnd => Sortable}/Sortable.tsx | 0 .../src/components/Sortable/SortableItem.tsx | 42 +++ .../src/components/{dnd => Sortable}/index.ts | 0 src/renderer/src/components/Sortable/types.ts | 1 + .../src/components/Sortable/useDndReorder.ts | 78 ++++++ .../src/components/Sortable/useDndState.ts | 28 ++ src/renderer/src/components/Sortable/utils.ts | 31 +++ .../src/components/Tab/TabContainer.tsx | 2 +- .../settings/MCPSettings/McpServersList.tsx | 2 +- yarn.lock | 4 + 24 files changed, 738 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/components/interactive/Sortable/ItemRenderer.tsx create mode 100644 packages/ui/src/components/interactive/Sortable/Sortable.tsx rename {src/renderer/src/components/dnd => packages/ui/src/components/interactive/Sortable}/SortableItem.tsx (100%) create mode 100644 packages/ui/src/components/interactive/Sortable/index.ts rename {src/renderer/src/components/dnd => packages/ui/src/components/interactive/Sortable}/types.ts (100%) rename {src/renderer/src/components/dnd => packages/ui/src/components/interactive/Sortable}/utils.ts (100%) rename {src/renderer/src/components/dnd => packages/ui/src/hooks}/useDndReorder.ts (100%) rename {src/renderer/src/components/dnd => packages/ui/src/hooks}/useDndState.ts (100%) create mode 100644 packages/ui/stories/components/interactive/Sortable.stories.tsx rename src/renderer/src/components/{dnd => Sortable}/ItemRenderer.tsx (100%) rename src/renderer/src/components/{dnd => Sortable}/Sortable.tsx (100%) create mode 100644 src/renderer/src/components/Sortable/SortableItem.tsx rename src/renderer/src/components/{dnd => Sortable}/index.ts (100%) create mode 100644 src/renderer/src/components/Sortable/types.ts create mode 100644 src/renderer/src/components/Sortable/useDndReorder.ts create mode 100644 src/renderer/src/components/Sortable/useDndState.ts create mode 100644 src/renderer/src/components/Sortable/utils.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index cc0c083999..fa6e9af789 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 796d28e2b4..ab1d3d40bd 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.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 (复合组件) diff --git a/packages/ui/src/components/interactive/Sortable/ItemRenderer.tsx b/packages/ui/src/components/interactive/Sortable/ItemRenderer.tsx new file mode 100644 index 0000000000..dfec065eaa --- /dev/null +++ b/packages/ui/src/components/interactive/Sortable/ItemRenderer.tsx @@ -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 { + ref?: React.Ref + index?: number + item: T + renderItem: RenderItemType + dragging?: boolean + dragOverlay?: boolean + ghost?: boolean + transform?: Transform | null + transition?: string | null + listeners?: DraggableSyntheticListeners +} + +export function ItemRenderer({ + ref, + index, + item, + renderItem, + dragging, + dragOverlay, + ghost, + transform, + transition, + listeners, + ...props +}: ItemRendererProps) { + 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 ( + + + {renderItem(item, { dragging: !!dragging })} + + + ) +} + +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)); + } + } +` diff --git a/packages/ui/src/components/interactive/Sortable/Sortable.tsx b/packages/ui/src/components/interactive/Sortable/Sortable.tsx new file mode 100644 index 0000000000..b2783ff199 --- /dev/null +++ b/packages/ui/src/components/interactive/Sortable/Sortable.tsx @@ -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 { + /** 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 + /** 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({ + items, + itemKey, + onSortEnd, + onDragStart: customOnDragStart, + onDragEnd: customOnDragEnd, + renderItem, + layout = 'list', + horizontal = false, + useDragOverlay = true, + showGhost = false, + className, + listStyle, + gap, + restrictions, + modifiers: customModifiers +}: SortableProps) { + 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(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( + () => [ + ...(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 ( + + + + {items.map((item, index) => ( + + ))} + + + + {useDragOverlay + ? createPortal( + + {activeItem ? : null} + , + document.body + ) + : null} + + ) +} + +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 diff --git a/src/renderer/src/components/dnd/SortableItem.tsx b/packages/ui/src/components/interactive/Sortable/SortableItem.tsx similarity index 100% rename from src/renderer/src/components/dnd/SortableItem.tsx rename to packages/ui/src/components/interactive/Sortable/SortableItem.tsx diff --git a/packages/ui/src/components/interactive/Sortable/index.ts b/packages/ui/src/components/interactive/Sortable/index.ts new file mode 100644 index 0000000000..716f49710d --- /dev/null +++ b/packages/ui/src/components/interactive/Sortable/index.ts @@ -0,0 +1 @@ +export { default as Sortable } from './Sortable' diff --git a/src/renderer/src/components/dnd/types.ts b/packages/ui/src/components/interactive/Sortable/types.ts similarity index 100% rename from src/renderer/src/components/dnd/types.ts rename to packages/ui/src/components/interactive/Sortable/types.ts diff --git a/src/renderer/src/components/dnd/utils.ts b/packages/ui/src/components/interactive/Sortable/utils.ts similarity index 100% rename from src/renderer/src/components/dnd/utils.ts rename to packages/ui/src/components/interactive/Sortable/utils.ts diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index e69de29bb2..31e4e7ab2d 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useDndReorder' +export * from './useDndState' diff --git a/src/renderer/src/components/dnd/useDndReorder.ts b/packages/ui/src/hooks/useDndReorder.ts similarity index 100% rename from src/renderer/src/components/dnd/useDndReorder.ts rename to packages/ui/src/hooks/useDndReorder.ts diff --git a/src/renderer/src/components/dnd/useDndState.ts b/packages/ui/src/hooks/useDndState.ts similarity index 100% rename from src/renderer/src/components/dnd/useDndState.ts rename to packages/ui/src/hooks/useDndState.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bd424b2e8b..0f9f049d9c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,5 +1,5 @@ // 主入口文件 - 导出所有公共API export * from './components' -// export * from './hooks' +export * from './hooks' // export * from './types' export * from './utils' diff --git a/packages/ui/stories/components/interactive/Sortable.stories.tsx b/packages/ui/stories/components/interactive/Sortable.stories.tsx new file mode 100644 index 0000000000..33c456403e --- /dev/null +++ b/packages/ui/stories/components/interactive/Sortable.stories.tsx @@ -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 = { + 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 + +function useExampleData() { + const [originalList, setOriginalList] = useState(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({ + originalList, + filteredList, + onUpdate: setOriginalList, + itemKey: 'id' + }) + + return { originalList, setOriginalList, query, setQuery, filteredList, onSortEnd } +} + +function ItemCard({ item, dragging }: { item: ExampleItem; dragging: boolean }) { + return ( +
+
{item.label}
+
+ ) +} + +export const Vertical: Story = { + render: (args) => +} + +export const Horizontal: Story = { + render: (args) => +} + +export const Grid: Story = { + render: (args) => +} + +function VerticalDemo(args: any) { + const { query, setQuery, filteredList, onSortEnd } = useExampleData() + + return ( +
+ setQuery(e.target.value)} + placeholder="Search (fuzzy match label)" + className="w-full rounded-md border px-3 py-2 text-sm" + /> + +
+ + 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 }) => ( +
+ +
+ )} + /> +
+ +

+ Dragging within a filtered view correctly updates the original order (handled by useDndReorder). +

+
+ ) +} + +function HorizontalDemo(args: any) { + const { query, setQuery, filteredList, onSortEnd } = useExampleData() + + return ( +
+ setQuery(e.target.value)} + placeholder="Search (fuzzy match label)" + className="w-full rounded-md border px-3 py-2 text-sm" + /> + +
+ + 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 }) => ( +
+ +
+ )} + /> +
+ +

Horizontal dragging with overflow scrolling.

+
+ ) +} + +function GridDemo(args: any) { + const { query, setQuery, filteredList, onSortEnd } = useExampleData() + + return ( +
+ setQuery(e.target.value)} + placeholder="Search (fuzzy match label)" + className="w-full rounded-md border px-3 py-2 text-sm" + /> + + + 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 }) => } + /> + +

Responsive grid layout with drag-and-drop sorting.

+
+ ) +} diff --git a/src/renderer/src/components/dnd/ItemRenderer.tsx b/src/renderer/src/components/Sortable/ItemRenderer.tsx similarity index 100% rename from src/renderer/src/components/dnd/ItemRenderer.tsx rename to src/renderer/src/components/Sortable/ItemRenderer.tsx diff --git a/src/renderer/src/components/dnd/Sortable.tsx b/src/renderer/src/components/Sortable/Sortable.tsx similarity index 100% rename from src/renderer/src/components/dnd/Sortable.tsx rename to src/renderer/src/components/Sortable/Sortable.tsx diff --git a/src/renderer/src/components/Sortable/SortableItem.tsx b/src/renderer/src/components/Sortable/SortableItem.tsx new file mode 100644 index 0000000000..97b0e3a3d0 --- /dev/null +++ b/src/renderer/src/components/Sortable/SortableItem.tsx @@ -0,0 +1,42 @@ +import { useSortable } from '@dnd-kit/sortable' + +import { ItemRenderer } from './ItemRenderer' +import type { RenderItemType } from './types' + +interface SortableItemProps { + item: T + id: string | number + index: number + renderItem: RenderItemType + useDragOverlay?: boolean + showGhost?: boolean +} + +export function SortableItem({ + item, + id, + index, + renderItem, + useDragOverlay = true, + showGhost = true +}: SortableItemProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id + }) + + return ( + + ) +} diff --git a/src/renderer/src/components/dnd/index.ts b/src/renderer/src/components/Sortable/index.ts similarity index 100% rename from src/renderer/src/components/dnd/index.ts rename to src/renderer/src/components/Sortable/index.ts diff --git a/src/renderer/src/components/Sortable/types.ts b/src/renderer/src/components/Sortable/types.ts new file mode 100644 index 0000000000..7235646d38 --- /dev/null +++ b/src/renderer/src/components/Sortable/types.ts @@ -0,0 +1 @@ +export type RenderItemType = (item: T, props: { dragging: boolean }) => React.ReactNode diff --git a/src/renderer/src/components/Sortable/useDndReorder.ts b/src/renderer/src/components/Sortable/useDndReorder.ts new file mode 100644 index 0000000000..0651a1ae6a --- /dev/null +++ b/src/renderer/src/components/Sortable/useDndReorder.ts @@ -0,0 +1,78 @@ +import type { Key } from 'react' +import { useCallback, useMemo } from 'react' + +interface UseDndReorderParams { + /** 原始的、完整的数据列表 */ + 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({ originalList, filteredList, onUpdate, itemKey }: UseDndReorderParams) { + const getId = useCallback( + (item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)), + [itemKey] + ) + + // 创建从 item ID 到其在 *原始列表* 中索引的映射 + const itemIndexMap = useMemo(() => { + const map = new Map() + 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 } +} diff --git a/src/renderer/src/components/Sortable/useDndState.ts b/src/renderer/src/components/Sortable/useDndState.ts new file mode 100644 index 0000000000..82d432fa4c --- /dev/null +++ b/src/renderer/src/components/Sortable/useDndState.ts @@ -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 + } +} diff --git a/src/renderer/src/components/Sortable/utils.ts b/src/renderer/src/components/Sortable/utils.ts new file mode 100644 index 0000000000..d2e73ede6e --- /dev/null +++ b/src/renderer/src/components/Sortable/utils.ts @@ -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'] +} diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 02052c0a9d..83e7a02986 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -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' diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index c2b782d084..f803592664 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -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' diff --git a/yarn.lock b/yarn.lock index fe0fd4d97b..2eec098e91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"