mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-04 20:00:00 +08:00
feat: update migration status and add new UI components
- Updated migration status documentation to reflect the migration of 43 components, with 193 pending. - Enhanced the component status table with new entries for MaxContextCount, CollapsibleSearchBar, ImageToolButton, and InfoPopover. - Added implementations for the new components, improving the UI library's functionality. - Updated index.ts to export the newly added components for better accessibility.
This commit is contained in:
parent
046ed3edef
commit
04ef5edea2
@ -49,9 +49,9 @@ function MyComponent() {
|
|||||||
## 迁移概览
|
## 迁移概览
|
||||||
|
|
||||||
- **总组件数**: 236
|
- **总组件数**: 236
|
||||||
- **已迁移**: 38
|
- **已迁移**: 43
|
||||||
- **已重构**: 0
|
- **已重构**: 0
|
||||||
- **待迁移**: 198
|
- **待迁移**: 193
|
||||||
|
|
||||||
## 组件状态表
|
## 组件状态表
|
||||||
|
|
||||||
@ -75,6 +75,7 @@ function MyComponent() {
|
|||||||
| | ThinkingEffect | ✅ | ❌ | 思考效果动画 |
|
| | ThinkingEffect | ✅ | ❌ | 思考效果动画 |
|
||||||
| | EmojiAvatar | ✅ | ❌ | 表情头像 |
|
| | EmojiAvatar | ✅ | ❌ | 表情头像 |
|
||||||
| | ListItem | ✅ | ❌ | 列表项 |
|
| | ListItem | ✅ | ❌ | 列表项 |
|
||||||
|
| | MaxContextCount | ✅ | ❌ | 最大上下文数显示 |
|
||||||
| | CodeViewer | ❌ | ❌ | 代码查看器 (外部依赖) |
|
| | CodeViewer | ❌ | ❌ | 代码查看器 (外部依赖) |
|
||||||
| | OGCard | ❌ | ❌ | OG 卡片 |
|
| | OGCard | ❌ | ❌ | OG 卡片 |
|
||||||
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown 渲染器 |
|
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown 渲染器 |
|
||||||
@ -107,8 +108,11 @@ function MyComponent() {
|
|||||||
| | HelpTooltip | ✅ | ❌ | 帮助提示 |
|
| | HelpTooltip | ✅ | ❌ | 帮助提示 |
|
||||||
| | WarnTooltip | ✅ | ❌ | 警告提示 |
|
| | WarnTooltip | ✅ | ❌ | 警告提示 |
|
||||||
| | EditableNumber | ✅ | ❌ | 可编辑数字 |
|
| | EditableNumber | ✅ | ❌ | 可编辑数字 |
|
||||||
| | DraggableList | ❌ | ❌ | 可拖拽列表 |
|
| | InfoPopover | ✅ | ❌ | 信息弹出框 |
|
||||||
| | EmojiPicker | ❌ | ❌ | 表情选择器 |
|
| | CollapsibleSearchBar | ✅ | ❌ | 可折叠搜索栏 |
|
||||||
|
| | ImageToolButton | ✅ | ❌ | 图片工具按钮 |
|
||||||
|
| | DraggableList | ✅ | ❌ | 可拖拽列表 |
|
||||||
|
| | EmojiPicker | ❌ | ❌ | 表情选择器 (useTheme 依赖) |
|
||||||
| | Selector | ✅ | ❌ | 选择器 (i18n 依赖) |
|
| | Selector | ✅ | ❌ | 选择器 (i18n 依赖) |
|
||||||
| | ModelSelector | ❌ | ❌ | 模型选择器 (Redux 依赖) |
|
| | ModelSelector | ❌ | ❌ | 模型选择器 (Redux 依赖) |
|
||||||
| | LanguageSelect | ❌ | ❌ | 语言选择 |
|
| | LanguageSelect | ❌ | ❌ | 语言选择 |
|
||||||
|
|||||||
@ -48,9 +48,9 @@ When submitting PRs, please place components in the correct directory based on t
|
|||||||
## Migration Overview
|
## Migration Overview
|
||||||
|
|
||||||
- **Total Components**: 236
|
- **Total Components**: 236
|
||||||
- **Migrated**: 38
|
- **Migrated**: 43
|
||||||
- **Refactored**: 0
|
- **Refactored**: 0
|
||||||
- **Pending Migration**: 198
|
- **Pending Migration**: 193
|
||||||
|
|
||||||
## Component Status Table
|
## Component Status Table
|
||||||
|
|
||||||
@ -74,6 +74,7 @@ When submitting PRs, please place components in the correct directory based on t
|
|||||||
| | ThinkingEffect | ✅ | ❌ | Thinking effect animation |
|
| | ThinkingEffect | ✅ | ❌ | Thinking effect animation |
|
||||||
| | EmojiAvatar | ✅ | ❌ | Emoji avatar |
|
| | EmojiAvatar | ✅ | ❌ | Emoji avatar |
|
||||||
| | ListItem | ✅ | ❌ | List item |
|
| | ListItem | ✅ | ❌ | List item |
|
||||||
|
| | MaxContextCount | ✅ | ❌ | Max context count display |
|
||||||
| | CodeViewer | ❌ | ❌ | Code viewer (external deps) |
|
| | CodeViewer | ❌ | ❌ | Code viewer (external deps) |
|
||||||
| | OGCard | ❌ | ❌ | OG card |
|
| | OGCard | ❌ | ❌ | OG card |
|
||||||
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown renderer |
|
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown renderer |
|
||||||
@ -106,8 +107,11 @@ When submitting PRs, please place components in the correct directory based on t
|
|||||||
| | HelpTooltip | ✅ | ❌ | Help tooltip |
|
| | HelpTooltip | ✅ | ❌ | Help tooltip |
|
||||||
| | WarnTooltip | ✅ | ❌ | Warning tooltip |
|
| | WarnTooltip | ✅ | ❌ | Warning tooltip |
|
||||||
| | EditableNumber | ✅ | ❌ | Editable number |
|
| | EditableNumber | ✅ | ❌ | Editable number |
|
||||||
| | DraggableList | ❌ | ❌ | Draggable list |
|
| | InfoPopover | ✅ | ❌ | Info popover |
|
||||||
| | EmojiPicker | ❌ | ❌ | Emoji picker |
|
| | CollapsibleSearchBar | ✅ | ❌ | Collapsible search bar |
|
||||||
|
| | ImageToolButton | ✅ | ❌ | Image tool button |
|
||||||
|
| | DraggableList | ✅ | ❌ | Draggable list |
|
||||||
|
| | EmojiPicker | ❌ | ❌ | Emoji picker (useTheme dependency) |
|
||||||
| | Selector | ✅ | ❌ | Selector (i18n dependency) |
|
| | Selector | ✅ | ❌ | Selector (i18n dependency) |
|
||||||
| | ModelSelector | ❌ | ❌ | Model selector (Redux dependency) |
|
| | ModelSelector | ❌ | ❌ | Model selector (Redux dependency) |
|
||||||
| | LanguageSelect | ❌ | ❌ | Language select |
|
| | LanguageSelect | ❌ | ❌ | Language select |
|
||||||
|
|||||||
19
packages/ui/src/components/display/MaxContextCount/index.tsx
Normal file
19
packages/ui/src/components/display/MaxContextCount/index.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// Original path: src/renderer/src/components/MaxContextCount.tsx
|
||||||
|
import { Infinity as InfinityIcon } from 'lucide-react'
|
||||||
|
import { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
const MAX_CONTEXT_COUNT = 100
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
maxContext: number
|
||||||
|
style?: CSSProperties
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MaxContextCount({ maxContext, style, size = 14 }: Props) {
|
||||||
|
return maxContext === MAX_CONTEXT_COUNT ? (
|
||||||
|
<InfinityIcon size={size} style={style} aria-label="infinity" />
|
||||||
|
) : (
|
||||||
|
<span style={style}>{maxContext.toString()}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ export { default as Ellipsis } from './display/Ellipsis'
|
|||||||
export { default as EmojiAvatar } from './display/EmojiAvatar'
|
export { default as EmojiAvatar } from './display/EmojiAvatar'
|
||||||
export { default as ExpandableText } from './display/ExpandableText'
|
export { default as ExpandableText } from './display/ExpandableText'
|
||||||
export { default as ListItem } from './display/ListItem'
|
export { default as ListItem } from './display/ListItem'
|
||||||
|
export { default as MaxContextCount } from './display/MaxContextCount'
|
||||||
export { default as ThinkingEffect } from './display/ThinkingEffect'
|
export { default as ThinkingEffect } from './display/ThinkingEffect'
|
||||||
|
|
||||||
// Layout Components
|
// Layout Components
|
||||||
@ -40,9 +41,13 @@ export { default as WebSearchIcon } from './icons/WebSearchIcon'
|
|||||||
export { default as WrapIcon } from './icons/WrapIcon'
|
export { default as WrapIcon } from './icons/WrapIcon'
|
||||||
|
|
||||||
// Interactive Components
|
// Interactive Components
|
||||||
|
export { default as CollapsibleSearchBar } from './interactive/CollapsibleSearchBar'
|
||||||
|
export { DraggableList, useDraggableReorder } from './interactive/DraggableList'
|
||||||
export type { EditableNumberProps } from './interactive/EditableNumber'
|
export type { EditableNumberProps } from './interactive/EditableNumber'
|
||||||
export { default as EditableNumber } from './interactive/EditableNumber'
|
export { default as EditableNumber } from './interactive/EditableNumber'
|
||||||
export { default as HelpTooltip } from './interactive/HelpTooltip'
|
export { default as HelpTooltip } from './interactive/HelpTooltip'
|
||||||
|
export { default as ImageToolButton } from './interactive/ImageToolButton'
|
||||||
|
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 { default as WarnTooltip } from './interactive/WarnTooltip'
|
export { default as WarnTooltip } from './interactive/WarnTooltip'
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
// Original path: src/renderer/src/components/CollapsibleSearchBar.tsx
|
||||||
|
import { Input, InputRef, Tooltip } from 'antd'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface CollapsibleSearchBarProps {
|
||||||
|
onSearch: (text: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
tooltip?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
maxWidth?: string | number
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collapsible search bar for list headers
|
||||||
|
* Renders as an icon initially, expands to full search input when clicked
|
||||||
|
*/
|
||||||
|
const CollapsibleSearchBar = ({
|
||||||
|
onSearch,
|
||||||
|
placeholder = 'Search',
|
||||||
|
tooltip = 'Search',
|
||||||
|
icon = <Search size={14} color="var(--color-icon)" />,
|
||||||
|
maxWidth = '100%',
|
||||||
|
style
|
||||||
|
}: CollapsibleSearchBarProps) => {
|
||||||
|
const [searchVisible, setSearchVisible] = useState(false)
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const inputRef = useRef<InputRef>(null)
|
||||||
|
|
||||||
|
const handleTextChange = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setSearchText(text)
|
||||||
|
onSearch(text)
|
||||||
|
},
|
||||||
|
[onSearch]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setSearchText('')
|
||||||
|
setSearchVisible(false)
|
||||||
|
onSearch('')
|
||||||
|
}, [onSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchVisible && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [searchVisible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<motion.div
|
||||||
|
initial="collapsed"
|
||||||
|
animate={searchVisible ? 'expanded' : 'collapsed'}
|
||||||
|
variants={{
|
||||||
|
expanded: { maxWidth: maxWidth, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||||
|
collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
|
||||||
|
}}
|
||||||
|
style={{ overflow: 'hidden', flex: 1 }}>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
size="small"
|
||||||
|
suffix={icon}
|
||||||
|
value={searchText}
|
||||||
|
autoFocus
|
||||||
|
allowClear
|
||||||
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleTextChange('')
|
||||||
|
if (!searchText) setSearchVisible(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!searchText) setSearchVisible(false)
|
||||||
|
}}
|
||||||
|
onClear={handleClear}
|
||||||
|
style={{ width: '100%', ...style }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial="visible"
|
||||||
|
animate={searchVisible ? 'hidden' : 'visible'}
|
||||||
|
variants={{
|
||||||
|
visible: { opacity: 1, transition: { duration: 0.1, delay: 0.3, ease: 'easeInOut' } },
|
||||||
|
hidden: { opacity: 0, transition: { duration: 0.1, ease: 'easeInOut' } }
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer', display: 'flex' }}
|
||||||
|
onClick={() => setSearchVisible(true)}>
|
||||||
|
<Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||||
|
{icon}
|
||||||
|
</Tooltip>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(CollapsibleSearchBar)
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
// Original path: src/renderer/src/components/DraggableList/index.tsx
|
||||||
|
export { default as DraggableList } from './list'
|
||||||
|
export { useDraggableReorder } from './useDraggableReorder'
|
||||||
|
export {
|
||||||
|
default as DraggableVirtualList,
|
||||||
|
type DraggableVirtualListProps,
|
||||||
|
type DraggableVirtualListRef
|
||||||
|
} from './virtual-list'
|
||||||
110
packages/ui/src/components/interactive/DraggableList/list.tsx
Normal file
110
packages/ui/src/components/interactive/DraggableList/list.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// Original path: src/renderer/src/components/DraggableList/list.tsx
|
||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Draggable,
|
||||||
|
Droppable,
|
||||||
|
DroppableProps,
|
||||||
|
DropResult,
|
||||||
|
OnDragEndResponder,
|
||||||
|
OnDragStartResponder,
|
||||||
|
ResponderProvided
|
||||||
|
} from '@hello-pangea/dnd'
|
||||||
|
import { HTMLAttributes, Key, useCallback } from 'react'
|
||||||
|
|
||||||
|
// Inline utility function from @renderer/utils
|
||||||
|
function droppableReorder<T>(list: T[], sourceIndex: number, destIndex: number, len: number = 1): T[] {
|
||||||
|
const result = Array.from(list)
|
||||||
|
const removed = result.splice(sourceIndex, len)
|
||||||
|
|
||||||
|
if (sourceIndex < destIndex) {
|
||||||
|
result.splice(destIndex - len + 1, 0, ...removed)
|
||||||
|
} else {
|
||||||
|
result.splice(destIndex, 0, ...removed)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
list: T[]
|
||||||
|
style?: React.CSSProperties
|
||||||
|
listStyle?: React.CSSProperties
|
||||||
|
listProps?: HTMLAttributes<HTMLDivElement>
|
||||||
|
children: (item: T, index: number) => React.ReactNode
|
||||||
|
itemKey?: keyof T | ((item: T) => Key)
|
||||||
|
onUpdate: (list: T[]) => void
|
||||||
|
onDragStart?: OnDragStartResponder
|
||||||
|
onDragEnd?: OnDragEndResponder
|
||||||
|
droppableProps?: Partial<DroppableProps>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableList<T>({
|
||||||
|
children,
|
||||||
|
list,
|
||||||
|
style,
|
||||||
|
listStyle,
|
||||||
|
listProps,
|
||||||
|
itemKey,
|
||||||
|
droppableProps,
|
||||||
|
onDragStart,
|
||||||
|
onUpdate,
|
||||||
|
onDragEnd
|
||||||
|
}: Props<T>) {
|
||||||
|
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||||
|
onDragEnd?.(result, provided)
|
||||||
|
if (result.destination) {
|
||||||
|
const sourceIndex = result.source.index
|
||||||
|
const destIndex = result.destination.index
|
||||||
|
if (sourceIndex !== destIndex) {
|
||||||
|
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||||
|
onUpdate(reorderAgents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getId = useCallback(
|
||||||
|
(item: T) => {
|
||||||
|
if (typeof itemKey === 'function') return itemKey(item)
|
||||||
|
if (itemKey) return item[itemKey] as Key
|
||||||
|
if (typeof item === 'string') return item as Key
|
||||||
|
if (item && typeof item === 'object' && 'id' in item) return item.id as Key
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
[itemKey]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||||
|
<Droppable droppableId="droppable" {...droppableProps}>
|
||||||
|
{(provided) => (
|
||||||
|
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
|
||||||
|
<div {...listProps} className="draggable-list-container">
|
||||||
|
{list.map((item, index) => {
|
||||||
|
const draggableId = String(getId(item) ?? index)
|
||||||
|
return (
|
||||||
|
<Draggable key={`draggable_${draggableId}`} draggableId={draggableId} index={index}>
|
||||||
|
{(provided) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
style={{
|
||||||
|
...listStyle,
|
||||||
|
...provided.draggableProps.style,
|
||||||
|
marginBottom: 8
|
||||||
|
}}>
|
||||||
|
{children(item, index)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DraggableList
|
||||||
20
packages/ui/src/components/interactive/DraggableList/sort.ts
Normal file
20
packages/ui/src/components/interactive/DraggableList/sort.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 用于 dnd 列表的元素重新排序方法。支持多元素"拖动"排序。
|
||||||
|
* @template {T} 列表元素的类型
|
||||||
|
* @param {T[]} list 要重新排序的列表
|
||||||
|
* @param {number} sourceIndex 起始元素索引
|
||||||
|
* @param {number} destIndex 目标元素索引
|
||||||
|
* @param {number} [len=1] 要移动的元素数量,默认为 1
|
||||||
|
* @returns {T[]} 重新排序后的列表
|
||||||
|
*/
|
||||||
|
export function droppableReorder<T>(list: T[], sourceIndex: number, destIndex: number, len: number = 1): T[] {
|
||||||
|
const result = Array.from(list)
|
||||||
|
const removed = result.splice(sourceIndex, len)
|
||||||
|
|
||||||
|
if (sourceIndex < destIndex) {
|
||||||
|
result.splice(destIndex - len + 1, 0, ...removed)
|
||||||
|
} else {
|
||||||
|
result.splice(destIndex, 0, ...removed)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
// Original path: src/renderer/src/components/DraggableList/useDraggableReorder.ts
|
||||||
|
import { DropResult } from '@hello-pangea/dnd'
|
||||||
|
import { Key, useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
|
interface UseDraggableReorderParams<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 返回可以直接传递给 DraggableVirtualList 的 props: { onDragEnd, itemKey }
|
||||||
|
*/
|
||||||
|
export function useDraggableReorder<T>({
|
||||||
|
originalList,
|
||||||
|
filteredList,
|
||||||
|
onUpdate,
|
||||||
|
itemKey
|
||||||
|
}: UseDraggableReorderParams<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]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建 onDragEnd 回调,封装了所有重排逻辑
|
||||||
|
const onDragEnd = useCallback(
|
||||||
|
(result: DropResult) => {
|
||||||
|
if (!result.destination) return
|
||||||
|
|
||||||
|
// 使用 getItemKey 将视图索引转换为数据索引
|
||||||
|
const sourceOriginalIndex = getItemKey(result.source.index) as number
|
||||||
|
const destOriginalIndex = getItemKey(result.destination.index) as number
|
||||||
|
|
||||||
|
if (sourceOriginalIndex === destOriginalIndex) return
|
||||||
|
|
||||||
|
// 操作原始列表的副本
|
||||||
|
const newList = [...originalList]
|
||||||
|
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
|
||||||
|
newList.splice(destOriginalIndex, 0, movedItem)
|
||||||
|
|
||||||
|
// 调用外部更新函数
|
||||||
|
onUpdate(newList)
|
||||||
|
},
|
||||||
|
[originalList, onUpdate, getItemKey]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { onDragEnd, itemKey: getItemKey }
|
||||||
|
}
|
||||||
@ -0,0 +1,258 @@
|
|||||||
|
import { Scrollbar } from '@cherrystudio/ui'
|
||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Draggable,
|
||||||
|
Droppable,
|
||||||
|
DroppableProps,
|
||||||
|
DropResult,
|
||||||
|
OnDragEndResponder,
|
||||||
|
OnDragStartResponder,
|
||||||
|
ResponderProvided
|
||||||
|
} from '@hello-pangea/dnd'
|
||||||
|
import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
|
||||||
|
import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react'
|
||||||
|
|
||||||
|
import { droppableReorder } from './sort'
|
||||||
|
|
||||||
|
export interface DraggableVirtualListRef {
|
||||||
|
measure: () => void
|
||||||
|
scrollElement: () => HTMLDivElement | null
|
||||||
|
scrollToOffset: (offset: number, options?: ScrollToOptions) => void
|
||||||
|
scrollToIndex: (index: number, options?: ScrollToOptions) => void
|
||||||
|
resizeItem: (index: number, size: number) => void
|
||||||
|
getTotalSize: () => number
|
||||||
|
getVirtualItems: () => VirtualItem[]
|
||||||
|
getVirtualIndexes: () => number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 泛型 Props,用于配置 DraggableVirtualList。
|
||||||
|
*
|
||||||
|
* @template T 列表元素的类型
|
||||||
|
* @property {string} [className] 根节点附加 class
|
||||||
|
* @property {React.CSSProperties} [style] 根节点附加样式
|
||||||
|
* @property {React.CSSProperties} [itemStyle] 元素内容区域的附加样式
|
||||||
|
* @property {React.CSSProperties} [itemContainerStyle] 元素拖拽容器的附加样式
|
||||||
|
* @property {Partial<DroppableProps>} [droppableProps] 透传给 Droppable 的额外配置
|
||||||
|
* @property {(list: T[]) => void} [onUpdate] 拖拽排序完成后的回调,返回新的列表顺序(可被 useDraggableReorder 替代)
|
||||||
|
* @property {OnDragStartResponder} [onDragStart] 开始拖拽时的回调
|
||||||
|
* @property {OnDragEndResponder} [onDragEnd] 结束拖拽时的回调
|
||||||
|
* @property {T[]} list 渲染的数据源
|
||||||
|
* @property {(index: number) => Key} [itemKey] 提供给虚拟列表的行 key,若不提供默认使用 index
|
||||||
|
* @property {number} [overscan=5] 前后额外渲染的行数,提升快速滚动时的体验
|
||||||
|
* @property {React.ReactNode} [header] 列表头部内容
|
||||||
|
* @property {(item: T, index: number) => React.ReactNode} children 列表项渲染函数
|
||||||
|
*/
|
||||||
|
export interface DraggableVirtualListProps<T> {
|
||||||
|
ref?: React.Ref<DraggableVirtualListRef>
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
scrollerStyle?: React.CSSProperties
|
||||||
|
itemStyle?: React.CSSProperties
|
||||||
|
itemContainerStyle?: React.CSSProperties
|
||||||
|
droppableProps?: Partial<DroppableProps>
|
||||||
|
onUpdate?: (list: T[]) => void
|
||||||
|
onDragStart?: OnDragStartResponder
|
||||||
|
onDragEnd?: OnDragEndResponder
|
||||||
|
list: T[]
|
||||||
|
itemKey?: (index: number) => Key
|
||||||
|
estimateSize?: (index: number) => number
|
||||||
|
overscan?: number
|
||||||
|
header?: React.ReactNode
|
||||||
|
children: (item: T, index: number) => React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带虚拟滚动与拖拽排序能力的(垂直)列表组件。
|
||||||
|
* - 滚动容器由该组件内部管理。
|
||||||
|
* @template T 列表元素的类型
|
||||||
|
* @param {DraggableVirtualListProps<T>} props 组件参数
|
||||||
|
* @returns {React.ReactElement}
|
||||||
|
*/
|
||||||
|
function DraggableVirtualList<T>({
|
||||||
|
ref,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
scrollerStyle,
|
||||||
|
itemStyle,
|
||||||
|
itemContainerStyle,
|
||||||
|
droppableProps,
|
||||||
|
onDragStart,
|
||||||
|
onUpdate,
|
||||||
|
onDragEnd,
|
||||||
|
list,
|
||||||
|
itemKey,
|
||||||
|
estimateSize: _estimateSize,
|
||||||
|
overscan = 5,
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
disabled
|
||||||
|
}: DraggableVirtualListProps<T>): React.ReactElement {
|
||||||
|
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||||
|
onDragEnd?.(result, provided)
|
||||||
|
if (onUpdate && result.destination) {
|
||||||
|
const sourceIndex = result.source.index
|
||||||
|
const destIndex = result.destination.index
|
||||||
|
if (sourceIndex !== destIndex) {
|
||||||
|
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||||
|
onUpdate(reorderAgents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 虚拟列表滚动容器的 ref
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: list?.length ?? 0,
|
||||||
|
getScrollElement: useCallback(() => parentRef.current, []),
|
||||||
|
getItemKey: itemKey,
|
||||||
|
estimateSize: useCallback((index) => _estimateSize?.(index) ?? 50, [_estimateSize]),
|
||||||
|
overscan
|
||||||
|
})
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
measure: () => virtualizer.measure(),
|
||||||
|
scrollElement: () => virtualizer.scrollElement,
|
||||||
|
scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options),
|
||||||
|
scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options),
|
||||||
|
resizeItem: (index, size) => virtualizer.resizeItem(index, size),
|
||||||
|
getTotalSize: () => virtualizer.getTotalSize(),
|
||||||
|
getVirtualItems: () => virtualizer.getVirtualItems(),
|
||||||
|
getVirtualIndexes: () => virtualizer.getVirtualIndexes()
|
||||||
|
}),
|
||||||
|
[virtualizer]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} draggable-virtual-list`}
|
||||||
|
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
|
||||||
|
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||||
|
{header}
|
||||||
|
<Droppable
|
||||||
|
droppableId="droppable"
|
||||||
|
mode="virtual"
|
||||||
|
renderClone={(provided, _snapshot, rubric) => {
|
||||||
|
const item = list[rubric.source.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
style={{
|
||||||
|
...itemStyle,
|
||||||
|
...provided.draggableProps.style
|
||||||
|
}}>
|
||||||
|
{item && children(item, rubric.source.index)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{...droppableProps}>
|
||||||
|
{(provided) => {
|
||||||
|
// 让 dnd 和虚拟列表共享同一个滚动容器
|
||||||
|
const setRefs = (el: HTMLDivElement | null) => {
|
||||||
|
provided.innerRef(el)
|
||||||
|
parentRef.current = el
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scrollbar
|
||||||
|
ref={setRefs}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className="virtual-scroller"
|
||||||
|
style={{
|
||||||
|
...scrollerStyle,
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
className="virtual-list"
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||||
|
<VirtualRow
|
||||||
|
key={virtualItem.key}
|
||||||
|
virtualItem={virtualItem}
|
||||||
|
list={list}
|
||||||
|
itemStyle={itemStyle}
|
||||||
|
itemContainerStyle={itemContainerStyle}
|
||||||
|
virtualizer={virtualizer}
|
||||||
|
children={children}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染单个可拖拽的虚拟列表项,高度为动态测量
|
||||||
|
*/
|
||||||
|
const VirtualRow = memo(
|
||||||
|
({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer, disabled }: any) => {
|
||||||
|
const item = list[virtualItem.index]
|
||||||
|
const draggableId = String(virtualItem.key)
|
||||||
|
return (
|
||||||
|
<Draggable
|
||||||
|
key={`draggable_${draggableId}`}
|
||||||
|
draggableId={draggableId}
|
||||||
|
isDragDisabled={disabled}
|
||||||
|
index={virtualItem.index}>
|
||||||
|
{(provided) => {
|
||||||
|
const setDragRefs = (el: HTMLElement | null) => {
|
||||||
|
provided.innerRef(el)
|
||||||
|
virtualizer.measureElement(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dndStyle = provided.draggableProps.style
|
||||||
|
const virtualizerTransform = `translateY(${virtualItem.start}px)`
|
||||||
|
|
||||||
|
// dnd 的 transform 负责拖拽时的位移和让位动画,
|
||||||
|
// virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置,
|
||||||
|
// 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。
|
||||||
|
const combinedTransform = dndStyle?.transform
|
||||||
|
? `${dndStyle.transform} ${virtualizerTransform}`
|
||||||
|
: virtualizerTransform
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...provided.draggableProps}
|
||||||
|
ref={setDragRefs}
|
||||||
|
className="draggable-item"
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
style={{
|
||||||
|
...itemContainerStyle,
|
||||||
|
...dndStyle,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: combinedTransform
|
||||||
|
}}>
|
||||||
|
<div {...provided.dragHandleProps} className="draggable-content" style={itemStyle}>
|
||||||
|
{item && children(item, virtualItem.index)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default DraggableVirtualList
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
// Original path: src/renderer/src/components/Preview/ImageToolButton.tsx
|
||||||
|
import { Button, Tooltip } from 'antd'
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
interface ImageToolButtonProps {
|
||||||
|
tooltip: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageToolButton = ({ tooltip, icon, onClick }: ImageToolButtonProps) => {
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||||
|
<Button shape="circle" icon={icon} onClick={onClick} role="button" aria-label={tooltip} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ImageToolButton)
|
||||||
21
packages/ui/src/components/interactive/InfoPopover/index.tsx
Normal file
21
packages/ui/src/components/interactive/InfoPopover/index.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// Original path: src/renderer/src/components/InfoPopover.tsx
|
||||||
|
import { Popover, PopoverProps } from 'antd'
|
||||||
|
import { Info } from 'lucide-react'
|
||||||
|
|
||||||
|
type InheritedPopoverProps = Omit<PopoverProps, 'children'>
|
||||||
|
|
||||||
|
interface InfoPopoverProps extends InheritedPopoverProps {
|
||||||
|
iconColor?: string
|
||||||
|
iconSize?: string | number
|
||||||
|
iconStyle?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoPopover = ({ iconColor = 'var(--color-text-3)', iconSize = 14, iconStyle, ...rest }: InfoPopoverProps) => {
|
||||||
|
return (
|
||||||
|
<Popover {...rest}>
|
||||||
|
<Info size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Information" />
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoPopover
|
||||||
Loading…
Reference in New Issue
Block a user