mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +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
|
||||
- **已迁移**: 38
|
||||
- **已迁移**: 43
|
||||
- **已重构**: 0
|
||||
- **待迁移**: 198
|
||||
- **待迁移**: 193
|
||||
|
||||
## 组件状态表
|
||||
|
||||
@ -75,6 +75,7 @@ function MyComponent() {
|
||||
| | ThinkingEffect | ✅ | ❌ | 思考效果动画 |
|
||||
| | EmojiAvatar | ✅ | ❌ | 表情头像 |
|
||||
| | ListItem | ✅ | ❌ | 列表项 |
|
||||
| | MaxContextCount | ✅ | ❌ | 最大上下文数显示 |
|
||||
| | CodeViewer | ❌ | ❌ | 代码查看器 (外部依赖) |
|
||||
| | OGCard | ❌ | ❌ | OG 卡片 |
|
||||
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown 渲染器 |
|
||||
@ -107,8 +108,11 @@ function MyComponent() {
|
||||
| | HelpTooltip | ✅ | ❌ | 帮助提示 |
|
||||
| | WarnTooltip | ✅ | ❌ | 警告提示 |
|
||||
| | EditableNumber | ✅ | ❌ | 可编辑数字 |
|
||||
| | DraggableList | ❌ | ❌ | 可拖拽列表 |
|
||||
| | EmojiPicker | ❌ | ❌ | 表情选择器 |
|
||||
| | InfoPopover | ✅ | ❌ | 信息弹出框 |
|
||||
| | CollapsibleSearchBar | ✅ | ❌ | 可折叠搜索栏 |
|
||||
| | ImageToolButton | ✅ | ❌ | 图片工具按钮 |
|
||||
| | DraggableList | ✅ | ❌ | 可拖拽列表 |
|
||||
| | EmojiPicker | ❌ | ❌ | 表情选择器 (useTheme 依赖) |
|
||||
| | Selector | ✅ | ❌ | 选择器 (i18n 依赖) |
|
||||
| | ModelSelector | ❌ | ❌ | 模型选择器 (Redux 依赖) |
|
||||
| | LanguageSelect | ❌ | ❌ | 语言选择 |
|
||||
|
||||
@ -48,9 +48,9 @@ When submitting PRs, please place components in the correct directory based on t
|
||||
## Migration Overview
|
||||
|
||||
- **Total Components**: 236
|
||||
- **Migrated**: 38
|
||||
- **Migrated**: 43
|
||||
- **Refactored**: 0
|
||||
- **Pending Migration**: 198
|
||||
- **Pending Migration**: 193
|
||||
|
||||
## Component Status Table
|
||||
|
||||
@ -74,6 +74,7 @@ When submitting PRs, please place components in the correct directory based on t
|
||||
| | ThinkingEffect | ✅ | ❌ | Thinking effect animation |
|
||||
| | EmojiAvatar | ✅ | ❌ | Emoji avatar |
|
||||
| | ListItem | ✅ | ❌ | List item |
|
||||
| | MaxContextCount | ✅ | ❌ | Max context count display |
|
||||
| | CodeViewer | ❌ | ❌ | Code viewer (external deps) |
|
||||
| | OGCard | ❌ | ❌ | OG card |
|
||||
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown renderer |
|
||||
@ -106,8 +107,11 @@ When submitting PRs, please place components in the correct directory based on t
|
||||
| | HelpTooltip | ✅ | ❌ | Help tooltip |
|
||||
| | WarnTooltip | ✅ | ❌ | Warning tooltip |
|
||||
| | EditableNumber | ✅ | ❌ | Editable number |
|
||||
| | DraggableList | ❌ | ❌ | Draggable list |
|
||||
| | EmojiPicker | ❌ | ❌ | Emoji picker |
|
||||
| | InfoPopover | ✅ | ❌ | Info popover |
|
||||
| | CollapsibleSearchBar | ✅ | ❌ | Collapsible search bar |
|
||||
| | ImageToolButton | ✅ | ❌ | Image tool button |
|
||||
| | DraggableList | ✅ | ❌ | Draggable list |
|
||||
| | EmojiPicker | ❌ | ❌ | Emoji picker (useTheme dependency) |
|
||||
| | Selector | ✅ | ❌ | Selector (i18n dependency) |
|
||||
| | ModelSelector | ❌ | ❌ | Model selector (Redux dependency) |
|
||||
| | 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 ExpandableText } from './display/ExpandableText'
|
||||
export { default as ListItem } from './display/ListItem'
|
||||
export { default as MaxContextCount } from './display/MaxContextCount'
|
||||
export { default as ThinkingEffect } from './display/ThinkingEffect'
|
||||
|
||||
// Layout Components
|
||||
@ -40,9 +41,13 @@ export { default as WebSearchIcon } from './icons/WebSearchIcon'
|
||||
export { default as WrapIcon } from './icons/WrapIcon'
|
||||
|
||||
// Interactive Components
|
||||
export { default as CollapsibleSearchBar } from './interactive/CollapsibleSearchBar'
|
||||
export { DraggableList, useDraggableReorder } from './interactive/DraggableList'
|
||||
export type { EditableNumberProps } from './interactive/EditableNumber'
|
||||
export { default as EditableNumber } from './interactive/EditableNumber'
|
||||
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 Selector } from './interactive/Selector'
|
||||
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