From 070614cd3c7dcb17ccb4c7784da2416c5026cb5a Mon Sep 17 00:00:00 2001 From: one Date: Mon, 25 Aug 2025 14:19:56 +0800 Subject: [PATCH] feat: new dnd list (#9311) * feat: add Sortable * refactor: update SortableItem style, fix grid layout * refactor: dragOverlay * refactor: use Sortable grid in mcp server list * refactor: improve style * refactor: support custom dropAnimation for drag overlay * fix: cursor grabbing * fix: unexpected drag * fix: z-index * revert: assistants tab * refactor: improve button layout * docs: update comments * fix: interaction between Sortable and portal elements * refactor: improve McpServerCard dnd experience * refactor: prevent pointer events on drag overlay * refactor: rename and extraction * refactor: simplify usage * refactor: add showGhost --- package.json | 4 + .../src/components/dnd/ItemRenderer.tsx | 110 ++++++++++ src/renderer/src/components/dnd/Sortable.tsx | 192 ++++++++++++++++++ .../src/components/dnd/SortableItem.tsx | 41 ++++ src/renderer/src/components/dnd/index.ts | 3 + .../src/components/dnd/useDndReorder.ts | 74 +++++++ .../src/components/dnd/useDndState.ts | 28 +++ src/renderer/src/components/dnd/utils.ts | 45 ++++ .../home/Tabs/components/AssistantItem.tsx | 6 +- .../settings/MCPSettings/McpServerCard.tsx | 67 +++--- .../settings/MCPSettings/McpServersList.tsx | 70 ++++--- yarn.lock | 68 ++++++- 12 files changed, 641 insertions(+), 67 deletions(-) create mode 100644 src/renderer/src/components/dnd/ItemRenderer.tsx create mode 100644 src/renderer/src/components/dnd/Sortable.tsx create mode 100644 src/renderer/src/components/dnd/SortableItem.tsx create mode 100644 src/renderer/src/components/dnd/index.ts create mode 100644 src/renderer/src/components/dnd/useDndReorder.ts create mode 100644 src/renderer/src/components/dnd/useDndState.ts create mode 100644 src/renderer/src/components/dnd/utils.ts diff --git a/package.json b/package.json index 13765cf642..472326ee65 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,10 @@ "@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", diff --git a/src/renderer/src/components/dnd/ItemRenderer.tsx b/src/renderer/src/components/dnd/ItemRenderer.tsx new file mode 100644 index 0000000000..825772a8dd --- /dev/null +++ b/src/renderer/src/components/dnd/ItemRenderer.tsx @@ -0,0 +1,110 @@ +import { DraggableSyntheticListeners } from '@dnd-kit/core' +import { Transform } from '@dnd-kit/utilities' +import { classNames } from '@renderer/utils' +import React, { useEffect } from 'react' +import styled from 'styled-components' + +interface ItemRendererProps { + ref?: React.Ref + item: T + renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode + dragging?: boolean + dragOverlay?: boolean + ghost?: boolean + transform?: Transform | null + transition?: string | null + listeners?: DraggableSyntheticListeners +} + +export function ItemRenderer({ + ref, + 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, + '--translate-x': transform ? `${Math.round(transform.x)}px` : undefined, + '--translate-y': transform ? `${Math.round(transform.y)}px` : undefined, + '--scale-x': transform?.scaleX ? `${transform.scaleX}` : undefined, + '--scale-y': transform?.scaleY ? `${transform.scaleY}` : undefined + } as React.CSSProperties + + return ( + + + {renderItem(item, { dragging: !!dragging })} + + + ) +} + +const ItemWrapper = styled.div` + box-sizing: border-box; + transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1)) + scaleY(var(--scale-y, 1)); + 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/src/renderer/src/components/dnd/Sortable.tsx b/src/renderer/src/components/dnd/Sortable.tsx new file mode 100644 index 0000000000..a3102748cc --- /dev/null +++ b/src/renderer/src/components/dnd/Sortable.tsx @@ -0,0 +1,192 @@ +import { + Active, + defaultDropAnimationSideEffects, + DndContext, + DragOverlay, + DropAnimation, + KeyboardSensor, + Over, + TouchSensor, + UniqueIdentifier, + useSensor, + useSensors +} from '@dnd-kit/core' +import { restrictToHorizontalAxis, restrictToVerticalAxis } 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 { 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 }) => void + /** Function to render individual item, receives item data and drag state */ + renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode + /** 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 + /** Ghost item style */ + ghostItemStyle?: React.CSSProperties +} + +function Sortable({ + items, + itemKey, + onSortEnd, + onDragStart: customOnDragStart, + onDragEnd: customOnDragEnd, + renderItem, + layout = 'list', + horizontal = false, + useDragOverlay = true, + showGhost = false, + className, + listStyle +}: 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 }) => { + customOnDragStart?.({ active }) + if (active) { + setActiveId(active.id) + } + } + + const handleDragEnd = ({ over }) => { + 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 modifiers = layout === 'list' ? (horizontal ? [restrictToHorizontalAxis] : [restrictToVerticalAxis]) : [] + + 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` + &[data-layout='grid'] { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + width: 100%; + gap: 12px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } +` + +export default Sortable diff --git a/src/renderer/src/components/dnd/SortableItem.tsx b/src/renderer/src/components/dnd/SortableItem.tsx new file mode 100644 index 0000000000..3d83f58a1d --- /dev/null +++ b/src/renderer/src/components/dnd/SortableItem.tsx @@ -0,0 +1,41 @@ +import { useSortable } from '@dnd-kit/sortable' +import React from 'react' + +import { ItemRenderer } from './ItemRenderer' + +interface SortableItemProps { + item: T + getId: (item: T) => string | number + renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode + useDragOverlay?: boolean + showGhost?: boolean +} + +export function SortableItem({ + item, + getId, + renderItem, + useDragOverlay = true, + showGhost = true +}: SortableItemProps) { + const id = getId(item) + + 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/dnd/index.ts new file mode 100644 index 0000000000..f14e26e0a5 --- /dev/null +++ b/src/renderer/src/components/dnd/index.ts @@ -0,0 +1,3 @@ +export { default as Sortable } from './Sortable' +export * from './useDndReorder' +export * from './useDndState' diff --git a/src/renderer/src/components/dnd/useDndReorder.ts b/src/renderer/src/components/dnd/useDndReorder.ts new file mode 100644 index 0000000000..1b91507cb5 --- /dev/null +++ b/src/renderer/src/components/dnd/useDndReorder.ts @@ -0,0 +1,74 @@ +import { Key, useCallback, useMemo } from 'react' + +interface UseDndReorderParams { + /** 原始的、完整的数据列表 */ + originalList: T[] + /** 当前在界面上渲染的、可能被过滤的列表 */ + filteredList: T[] + /** 用于更新原始列表状态的函数 */ + onUpdate: (newList: T[]) => void + /** 用于从列表项中获取唯一ID的属性名或函数 */ + idKey: keyof T | ((item: T) => Key) +} + +/** + * 增强拖拽排序能力,处理“过滤后列表”与“原始列表”的索引映射问题。 + * + * @template T 列表项的类型 + * @param params - { originalList, filteredList, onUpdate, idKey } + * @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调 + */ +export function useDndReorder({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams) { + const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey]) + + // 创建从 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/dnd/useDndState.ts b/src/renderer/src/components/dnd/useDndState.ts new file mode 100644 index 0000000000..82d432fa4c --- /dev/null +++ b/src/renderer/src/components/dnd/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/dnd/utils.ts b/src/renderer/src/components/dnd/utils.ts new file mode 100644 index 0000000000..1435621efd --- /dev/null +++ b/src/renderer/src/components/dnd/utils.ts @@ -0,0 +1,45 @@ +import { defaultDropAnimationSideEffects, type DropAnimation, PointerSensor } from '@dnd-kit/core' + +export const PORTAL_NO_DND_SELECTORS = [ + '.ant-dropdown', + '.ant-select-dropdown', + '.ant-popover', + '.ant-tooltip', + '.ant-modal' +].join(',') + +/** + * Default drop animation config. + * The opacity is set so to match the drag overlay case. + */ +export const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: '0.25' + } + } + }) +} + +/** + * 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/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index 238fe5c58c..1d8e12626a 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -137,7 +137,10 @@ const AssistantItem: FC = ({ ) return ( - +
e.stopPropagation()}>{menu}
}> {assistantIconType === 'model' ? ( @@ -386,7 +389,6 @@ const Container = styled.div` border-radius: var(--list-item-border-radius); border: 0.5px solid transparent; width: calc(var(--assistants-width) - 20px); - cursor: pointer; &:hover { background-color: var(--color-list-item-hover); } diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx index 1f8d5822ba..1fa043e895 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx @@ -1,7 +1,7 @@ import { DeleteIcon } from '@renderer/components/Icons' import { getMcpTypeLabel } from '@renderer/i18n/label' import { MCPServer } from '@renderer/types' -import { Badge, Button, Switch, Tag } from 'antd' +import { Button, Switch, Tag, Typography } from 'antd' import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { FC } from 'react' import styled from 'styled-components' @@ -33,38 +33,49 @@ const McpServerCard: FC = ({ } return ( - + - + {server.logoUrl && } - {server.name} - {version && } + {server.name} {server.providerUrl && ( - - - ) - } - }}> - {(server: MCPServer) => ( -
navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}> - handleToggleActive(server, active)} - onDelete={() => onDeleteMcpServer(server)} - onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)} - onOpenUrl={(url) => window.open(url, '_blank')} - /> -
+ { + const newList = [...mcpServers] + const [removed] = newList.splice(oldIndex, 1) + newList.splice(newIndex, 0, removed) + updateMcpServers(newList) + }} + layout="grid" + useDragOverlay + showGhost + renderItem={(server) => ( + handleToggleActive(server, active)} + onDelete={() => onDeleteMcpServer(server)} + onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)} + onOpenUrl={(url) => window.open(url, '_blank')} + /> )} -
+ /> + {mcpServers.length === 0 && ( + + )} diff --git a/yarn.lock b/yarn.lock index 3ef1c7aa89..6c1b8a4bb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2691,6 +2691,68 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.1" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52 + languageName: node + linkType: hard + +"@dnd-kit/modifiers@npm:^9.0.0": + version: 9.0.0 + resolution: "@dnd-kit/modifiers@npm:9.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/ca8cc9da8296df10774d779c1611074dc327ccc3c49041c102111c98c7f2b2b73b6af5209c0eef6b2fe978ac63dc2a985efa87c85a8d786577304bd2e64cee1d + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0 + languageName: node + linkType: hard + "@electron-toolkit/eslint-config-prettier@npm:^3.0.0": version: 3.0.0 resolution: "@electron-toolkit/eslint-config-prettier@npm:3.0.0" @@ -8406,6 +8468,10 @@ __metadata: "@cherrystudio/embedjs-loader-xml": "npm:^0.1.31" "@cherrystudio/embedjs-ollama": "npm:^0.1.31" "@cherrystudio/embedjs-openai": "npm:^0.1.31" + "@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" "@electron-toolkit/eslint-config-prettier": "npm:^3.0.0" "@electron-toolkit/eslint-config-ts": "npm:^3.0.0" "@electron-toolkit/preload": "npm:^3.0.0" @@ -21309,7 +21375,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62