diff --git a/packages/ui/MIGRATION_STATUS.md b/packages/ui/MIGRATION_STATUS.md index cf17765677..dac52ab9a3 100644 --- a/packages/ui/MIGRATION_STATUS.md +++ b/packages/ui/MIGRATION_STATUS.md @@ -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 | ❌ | ❌ | 语言选择 | diff --git a/packages/ui/MIGRATION_STATUS_EN.md b/packages/ui/MIGRATION_STATUS_EN.md index ad07d752a8..240d6d6127 100644 --- a/packages/ui/MIGRATION_STATUS_EN.md +++ b/packages/ui/MIGRATION_STATUS_EN.md @@ -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 | diff --git a/packages/ui/src/components/display/MaxContextCount/index.tsx b/packages/ui/src/components/display/MaxContextCount/index.tsx new file mode 100644 index 0000000000..0d874846f6 --- /dev/null +++ b/packages/ui/src/components/display/MaxContextCount/index.tsx @@ -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 ? ( + + ) : ( + {maxContext.toString()} + ) +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 75b1b63587..889b1e328b 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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' diff --git a/packages/ui/src/components/interactive/CollapsibleSearchBar/index.tsx b/packages/ui/src/components/interactive/CollapsibleSearchBar/index.tsx new file mode 100644 index 0000000000..18c61f82e0 --- /dev/null +++ b/packages/ui/src/components/interactive/CollapsibleSearchBar/index.tsx @@ -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 = , + maxWidth = '100%', + style +}: CollapsibleSearchBarProps) => { + const [searchVisible, setSearchVisible] = useState(false) + const [searchText, setSearchText] = useState('') + const inputRef = useRef(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 ( +
+ + 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 }} + /> + + setSearchVisible(true)}> + + {icon} + + +
+ ) +} + +export default memo(CollapsibleSearchBar) diff --git a/packages/ui/src/components/interactive/DraggableList/index.tsx b/packages/ui/src/components/interactive/DraggableList/index.tsx new file mode 100644 index 0000000000..4dd9622c9c --- /dev/null +++ b/packages/ui/src/components/interactive/DraggableList/index.tsx @@ -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' diff --git a/packages/ui/src/components/interactive/DraggableList/list.tsx b/packages/ui/src/components/interactive/DraggableList/list.tsx new file mode 100644 index 0000000000..0bd6b364c7 --- /dev/null +++ b/packages/ui/src/components/interactive/DraggableList/list.tsx @@ -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(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 { + list: T[] + style?: React.CSSProperties + listStyle?: React.CSSProperties + listProps?: HTMLAttributes + children: (item: T, index: number) => React.ReactNode + itemKey?: keyof T | ((item: T) => Key) + onUpdate: (list: T[]) => void + onDragStart?: OnDragStartResponder + onDragEnd?: OnDragEndResponder + droppableProps?: Partial +} + +function DraggableList({ + children, + list, + style, + listStyle, + listProps, + itemKey, + droppableProps, + onDragStart, + onUpdate, + onDragEnd +}: Props) { + 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 ( + + + {(provided) => ( +
+
+ {list.map((item, index) => { + const draggableId = String(getId(item) ?? index) + return ( + + {(provided) => ( +
+ {children(item, index)} +
+ )} +
+ ) + })} +
+ {provided.placeholder} +
+ )} +
+
+ ) +} + +export default DraggableList diff --git a/packages/ui/src/components/interactive/DraggableList/sort.ts b/packages/ui/src/components/interactive/DraggableList/sort.ts new file mode 100644 index 0000000000..1341c3e6b5 --- /dev/null +++ b/packages/ui/src/components/interactive/DraggableList/sort.ts @@ -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(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 +} diff --git a/packages/ui/src/components/interactive/DraggableList/useDraggableReorder.ts b/packages/ui/src/components/interactive/DraggableList/useDraggableReorder.ts new file mode 100644 index 0000000000..96ebf15872 --- /dev/null +++ b/packages/ui/src/components/interactive/DraggableList/useDraggableReorder.ts @@ -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 { + /** 原始的、完整的数据列表 */ + 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({ + originalList, + filteredList, + onUpdate, + itemKey +}: UseDraggableReorderParams) { + 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] + ) + + // 创建 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 } +} diff --git a/packages/ui/src/components/interactive/DraggableList/virtual-list.tsx b/packages/ui/src/components/interactive/DraggableList/virtual-list.tsx new file mode 100644 index 0000000000..0ce148eee5 --- /dev/null +++ b/packages/ui/src/components/interactive/DraggableList/virtual-list.tsx @@ -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] 透传给 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 { + ref?: React.Ref + className?: string + style?: React.CSSProperties + scrollerStyle?: React.CSSProperties + itemStyle?: React.CSSProperties + itemContainerStyle?: React.CSSProperties + droppableProps?: Partial + 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} props 组件参数 + * @returns {React.ReactElement} + */ +function DraggableVirtualList({ + ref, + className, + style, + scrollerStyle, + itemStyle, + itemContainerStyle, + droppableProps, + onDragStart, + onUpdate, + onDragEnd, + list, + itemKey, + estimateSize: _estimateSize, + overscan = 5, + header, + children, + disabled +}: DraggableVirtualListProps): 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(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 ( +
+ + {header} + { + const item = list[rubric.source.index] + return ( +
+ {item && children(item, rubric.source.index)} +
+ ) + }} + {...droppableProps}> + {(provided) => { + // 让 dnd 和虚拟列表共享同一个滚动容器 + const setRefs = (el: HTMLDivElement | null) => { + provided.innerRef(el) + parentRef.current = el + } + + return ( + +
+ {virtualizer.getVirtualItems().map((virtualItem) => ( + + ))} +
+
+ ) + }} +
+
+
+ ) +} + +/** + * 渲染单个可拖拽的虚拟列表项,高度为动态测量 + */ +const VirtualRow = memo( + ({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer, disabled }: any) => { + const item = list[virtualItem.index] + const draggableId = String(virtualItem.key) + return ( + + {(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 ( +
+
+ {item && children(item, virtualItem.index)} +
+
+ ) + }} +
+ ) + } +) + +export default DraggableVirtualList diff --git a/packages/ui/src/components/interactive/ImageToolButton/index.tsx b/packages/ui/src/components/interactive/ImageToolButton/index.tsx new file mode 100644 index 0000000000..b8defb32b0 --- /dev/null +++ b/packages/ui/src/components/interactive/ImageToolButton/index.tsx @@ -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 ( + +