diff --git a/package.json b/package.json index 875515bbf9..28561231b0 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,6 @@ "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", "@types/react-infinite-scroll-component": "^5.0.0", - "@types/react-window": "^1", "@types/tinycolor2": "^1", "@types/word-extractor": "^1", "@uiw/codemirror-extensions-langs": "^4.23.14", @@ -236,7 +235,6 @@ "react-router": "6", "react-router-dom": "6", "react-spinners": "^0.14.1", - "react-window": "^1.8.11", "redux": "^5.0.1", "redux-persist": "^6.0.0", "reflect-metadata": "0.2.2", diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts deleted file mode 100644 index 4a8206df69..0000000000 --- a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMemo, useReducer } from 'react' - -import { initialScrollState, scrollReducer } from './reducer' -import { FlatListItem, ScrollTrigger } from './types' - -/** - * 管理滚动和焦点状态的 hook - */ -export function useScrollState() { - const [state, dispatch] = useReducer(scrollReducer, initialScrollState) - - const actions = useMemo( - () => ({ - setFocusedItemKey: (key: string) => dispatch({ type: 'SET_FOCUSED_ITEM_KEY', payload: key }), - setScrollTrigger: (trigger: ScrollTrigger) => dispatch({ type: 'SET_SCROLL_TRIGGER', payload: trigger }), - setLastScrollOffset: (offset: number) => dispatch({ type: 'SET_LAST_SCROLL_OFFSET', payload: offset }), - setStickyGroup: (group: FlatListItem | null) => dispatch({ type: 'SET_STICKY_GROUP', payload: group }), - setIsMouseOver: (isMouseOver: boolean) => dispatch({ type: 'SET_IS_MOUSE_OVER', payload: isMouseOver }), - focusNextItem: (modelItems: FlatListItem[], step: number) => - dispatch({ type: 'FOCUS_NEXT_ITEM', payload: { modelItems, step } }), - focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) => - dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }), - searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }), - focusOnListChange: (modelItems: FlatListItem[]) => - dispatch({ type: 'FOCUS_ON_LIST_CHANGE', payload: { modelItems } }) - }), - [] - ) - - return { - // 状态 - focusedItemKey: state.focusedItemKey, - scrollTrigger: state.scrollTrigger, - lastScrollOffset: state.lastScrollOffset, - stickyGroup: state.stickyGroup, - isMouseOver: state.isMouseOver, - // 操作 - ...actions - } -} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index c1b04de5df..e7557f5e5e 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,17 +1,16 @@ import { PushpinOutlined } from '@ant-design/icons' -import { HStack } from '@renderer/components/Layout' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' import { TopView } from '@renderer/components/TopView' +import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model } from '@renderer/types' +import { Model, Provider } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' -import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd' +import { Avatar, Divider, Empty, Modal } from 'antd' import { first, sortBy } from 'lodash' -import { Search } from 'lucide-react' -import { +import React, { startTransition, useCallback, useDeferredValue, @@ -21,15 +20,13 @@ import { useRef, useState } from 'react' -import React from 'react' import { useTranslation } from 'react-i18next' -import { FixedSizeList } from 'react-window' import styled from 'styled-components' -import { useScrollState } from './hook' +import SelectModelSearchBar from './searchbar' import { FlatListItem } from './types' -const PAGE_SIZE = 10 +const PAGE_SIZE = 11 const ITEM_HEIGHT = 36 interface PopupParams { @@ -47,8 +44,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() const [open, setOpen] = useState(true) - const inputRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) @@ -56,49 +52,19 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const currentModelId = model ? getModelUniqId(model) : '' // 管理滚动和焦点状态 - const { - focusedItemKey, - scrollTrigger, - lastScrollOffset, - stickyGroup, - isMouseOver, - setFocusedItemKey: _setFocusedItemKey, - setScrollTrigger, - setLastScrollOffset: _setLastScrollOffset, - setStickyGroup: _setStickyGroup, - setIsMouseOver, - focusNextItem, - focusPage, - searchChanged, - focusOnListChange - } = useScrollState() + const [focusedItemKey, _setFocusedItemKey] = useState('') + const [isMouseOver, setIsMouseOver] = useState(false) + const preventScrollToIndex = useRef(false) - const firstGroupRef = useRef(null) - - const setFocusedItemKey = useCallback( - (key: string) => { - startTransition(() => _setFocusedItemKey(key)) - }, - [_setFocusedItemKey] - ) - - const setLastScrollOffset = useCallback( - (offset: number) => { - startTransition(() => _setLastScrollOffset(offset)) - }, - [_setLastScrollOffset] - ) - - const setStickyGroup = useCallback( - (group: FlatListItem | null) => { - startTransition(() => _setStickyGroup(group)) - }, - [_setStickyGroup] - ) + const setFocusedItemKey = useCallback((key: string) => { + startTransition(() => { + _setFocusedItemKey(key) + }) + }, []) // 根据输入的文本筛选模型 const getFilteredModels = useCallback( - (provider) => { + (provider: Provider) => { let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) if (searchText.trim()) { @@ -112,7 +78,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 创建模型列表项 const createModelItem = useCallback( - (model: Model, provider: any, isPinned: boolean): FlatListItem => { + (model: Model, provider: Provider, isPinned: boolean): FlatListItem => { const modelId = getModelUniqId(model) const groupName = getFancyProviderName(provider) @@ -143,16 +109,18 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { [currentModelId] ) - // 构建扁平化列表数据 - const listItems = useMemo(() => { + // 构建扁平化列表数据,并派生出可选择的模型项 + const { listItems, modelItems } = useMemo(() => { const items: FlatListItem[] = [] + const pinnedModelIds = new Set(pinnedModels) + const finalModelFilter = modelFilter || (() => true) // 添加置顶模型分组(仅在无搜索文本时) - if (searchText.length === 0 && pinnedModels.length > 0) { + if (searchText.length === 0 && pinnedModelIds.size > 0) { const pinnedItems = providers.flatMap((p) => p.models - .filter((m) => pinnedModels.includes(getModelUniqId(m))) - .filter(modelFilter ? modelFilter : () => true) + .filter((m) => pinnedModelIds.has(getModelUniqId(m))) + .filter(finalModelFilter) .map((m) => createModelItem(m, p, true)) ) @@ -172,8 +140,8 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 添加常规模型分组 providers.forEach((p) => { const filteredModels = getFilteredModels(p) - .filter((m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m))) - .filter(modelFilter ? modelFilter : () => true) + .filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m))) + .filter(finalModelFilter) if (filteredModels.length === 0) return @@ -185,92 +153,52 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { isSelected: false }) - items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModels.includes(getModelUniqId(m))))) + items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModelIds.has(getModelUniqId(m))))) }) - // 移除第一个分组标题,使用 sticky group banner 替代,模拟 sticky 效果 - if (items.length > 0 && items[0].type === 'group') { - firstGroupRef.current = items[0] - items.shift() - } else { - firstGroupRef.current = null - } - return items + // 获取可选择的模型项(过滤掉分组标题) + const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[] + return { listItems: items, modelItems } }, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels]) - // 获取可选择的模型项(过滤掉分组标题) - const modelItems = useMemo(() => { - return listItems.filter((item) => item.type === 'model') - }, [listItems]) + const listHeight = useMemo(() => { + return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT + }, [listItems.length]) - // 当搜索文本变化时更新滚动触发器 - useEffect(() => { - searchChanged(searchText) - }, [searchText, searchChanged]) - - // 基于滚动位置更新sticky分组标题 - const updateStickyGroup = useCallback( - (scrollOffset?: number) => { - if (listItems.length === 0) { - stickyGroup && setStickyGroup(null) - return - } - - let newStickyGroup: FlatListItem | null = null - - // 基于滚动位置计算当前可见的第一个项的索引 - const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT) - - // 从该索引向前查找最近的分组标题 - for (let i = estimatedIndex - 1; i >= 0; i--) { - if (i < listItems.length && listItems[i]?.type === 'group') { - newStickyGroup = listItems[i] - break - } - } - - // 找不到则使用第一个分组标题 - if (!newStickyGroup) newStickyGroup = firstGroupRef.current - - if (stickyGroup?.key !== newStickyGroup?.key) { - setStickyGroup(newStickyGroup) - } - }, - [listItems, lastScrollOffset, setStickyGroup, stickyGroup] - ) - - // 处理列表滚动事件,更新lastScrollOffset并更新sticky分组 - const handleScroll = useCallback( - ({ scrollOffset }) => { - setLastScrollOffset(scrollOffset) - }, - [setLastScrollOffset] - ) - - // 列表项更新时,更新焦点 - useEffect(() => { - if (!loading) focusOnListChange(modelItems) - }, [modelItems, focusOnListChange, loading]) - - // 列表项更新时,更新sticky分组 - useEffect(() => { - if (!loading) updateStickyGroup() - }, [modelItems, updateStickyGroup, loading]) - - // 滚动到聚焦项 + // 处理程序化滚动(加载、搜索开始、搜索清空) useLayoutEffect(() => { - if (scrollTrigger === 'none' || !focusedItemKey) return + if (loading) return - const index = listItems.findIndex((item) => item.key === focusedItemKey) - if (index < 0) return + if (preventScrollToIndex.current) { + preventScrollToIndex.current = false + return + } - // 根据触发源决定滚动对齐方式 - const alignment = scrollTrigger === 'keyboard' ? 'auto' : 'center' - listRef.current?.scrollToItem(index, alignment) + let targetItemKey: string | undefined - // 滚动后重置触发器 - setScrollTrigger('none') - }, [focusedItemKey, scrollTrigger, listItems, setScrollTrigger]) + // 启动搜索时,滚动到第一个 item + if (searchText) { + targetItemKey = modelItems[0]?.key + } + // 初始加载或清空搜索时,滚动到 selected item + else { + targetItemKey = modelItems.find((item) => item.isSelected)?.key + } + + if (targetItemKey) { + setFocusedItemKey(targetItemKey) + const index = listItems.findIndex((item) => item.key === targetItemKey) + if (index >= 0) { + // FIXME: 手动计算偏移量,给 scroller 增加了 scrollPaddingStart 之后, + // scrollToIndex 不能准确滚动到 item 中心,但是又需要 padding 来改善体验。 + const targetScrollTop = index * ITEM_HEIGHT - listHeight / 2 + listRef.current?.scrollToOffset(targetScrollTop, { + align: 'start', + behavior: 'auto' + }) + } + } + }, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight]) const handleItemClick = useCallback( (item: FlatListItem) => { @@ -285,7 +213,9 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 处理键盘导航 const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!open || modelItems.length === 0 || e.isComposing) return + const modelCount = modelItems.length + + if (!open || modelCount === 0 || e.isComposing) return // 键盘操作时禁用鼠标 hover if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) { @@ -294,25 +224,31 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { setIsMouseOver(false) } + // 当前聚焦的模型 index const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey) - const normalizedIndex = currentIndex < 0 ? 0 : currentIndex + + let nextIndex = -1 switch (e.key) { - case 'ArrowUp': - focusNextItem(modelItems, -1) + case 'ArrowUp': { + nextIndex = (currentIndex < 0 ? 0 : currentIndex - 1 + modelCount) % modelCount break - case 'ArrowDown': - focusNextItem(modelItems, 1) + } + case 'ArrowDown': { + nextIndex = (currentIndex < 0 ? 0 : currentIndex + 1) % modelCount break - case 'PageUp': - focusPage(modelItems, normalizedIndex, -PAGE_SIZE) + } + case 'PageUp': { + nextIndex = Math.max(0, (currentIndex < 0 ? 0 : currentIndex) - PAGE_SIZE) break - case 'PageDown': - focusPage(modelItems, normalizedIndex, PAGE_SIZE) + } + case 'PageDown': { + nextIndex = Math.min(modelCount - 1, (currentIndex < 0 ? 0 : currentIndex) + PAGE_SIZE) break + } case 'Enter': - if (focusedItemKey) { - const selectedItem = modelItems.find((item) => item.key === focusedItemKey) + if (currentIndex >= 0) { + const selectedItem = modelItems[currentIndex] if (selectedItem) { handleItemClick(selectedItem) } @@ -324,8 +260,20 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { resolve(undefined) break } + + // 没有键盘导航,直接返回 + if (nextIndex < 0) return + + const nextKey = modelItems[nextIndex]?.key || '' + if (nextKey) { + setFocusedItemKey(nextKey) + const index = listItems.findIndex((item) => item.key === nextKey) + if (index >= 0) { + listRef.current?.scrollToIndex(index, { align: 'auto' }) + } + } }, - [focusedItemKey, modelItems, handleItemClick, open, resolve, setIsMouseOver, focusNextItem, focusPage] + [modelItems, open, focusedItemKey, resolve, handleItemClick, setFocusedItemKey, listItems] ) useEffect(() => { @@ -338,40 +286,57 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { }, []) const onAfterClose = useCallback(async () => { - setScrollTrigger('initial') resolve(undefined) SelectModelPopup.hide() - }, [resolve, setScrollTrigger]) - - // 初始化焦点和滚动位置 - useEffect(() => { - if (!open) return - const timer = setTimeout(() => inputRef.current?.focus(), 0) - return () => clearTimeout(timer) - }, [open]) + }, [resolve]) const togglePin = useCallback( async (modelId: string) => { await togglePinnedModel(modelId) + preventScrollToIndex.current = true }, [togglePinnedModel] ) - const RowData = useMemo( - (): VirtualizedRowData => ({ - listItems, - focusedItemKey, - setFocusedItemKey, - stickyGroup, - handleItemClick, - togglePin - }), - [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin, setFocusedItemKey] - ) + const getItemKey = useCallback((index: number) => listItems[index].key, [listItems]) + const estimateSize = useCallback(() => ITEM_HEIGHT, []) + const isSticky = useCallback((index: number) => listItems[index].type === 'group', [listItems]) - const listHeight = useMemo(() => { - return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT - }, [listItems.length]) + const rowRenderer = useCallback( + (item: FlatListItem) => { + const isFocused = item.key === focusedItemKey + if (item.type === 'group') { + return {item.name} + } + return ( + handleItemClick(item)} + onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> + + {item.icon} + {item.name} + {item.tags} + + { + e.stopPropagation() + if (item.model) { + togglePin(getModelUniqId(item.model)) + } + }} + data-pinned={item.isPinned} + $isPinned={item.isPinned}> + + + + ) + }, + [focusedItemKey, handleItemClick, setFocusedItemKey, togglePin] + ) return ( = ({ model, resolve, modelFilter }) => { closeIcon={null} footer={null}> {/* 搜索框 */} - - - - - } - ref={inputRef} - placeholder={t('models.search')} - value={_searchText} // 使用 _searchText,需要实时更新 - onChange={(e) => setSearchText(e.target.value)} - allowClear - autoFocus - spellCheck={false} - style={{ paddingLeft: 0 }} - variant="borderless" - size="middle" - onKeyDown={(e) => { - // 防止上下键移动光标 - if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') { - e.preventDefault() - } - }} - /> - + {listItems.length > 0 ? ( - !isMouseOver && startTransition(() => setIsMouseOver(true))}> - {/* Sticky Group Banner,它会替换第一个分组名称 */} - {stickyGroup?.name} - !isMouseOver && setIsMouseOver(true)}> + data.listItems[index].key} - overscanCount={4} - onScroll={handleScroll} - style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}> - {VirtualizedRow} - + list={listItems} + size={listHeight} + getItemKey={getItemKey} + estimateSize={estimateSize} + isSticky={isSticky} + scrollPaddingStart={ITEM_HEIGHT} // 留出 sticky header 高度 + overscan={5} + scrollerStyle={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}> + {rowRenderer} + ) : ( @@ -450,73 +388,12 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { ) } -interface VirtualizedRowData { - listItems: FlatListItem[] - focusedItemKey: string - setFocusedItemKey: (key: string) => void - stickyGroup: FlatListItem | null - handleItemClick: (item: FlatListItem) => void - togglePin: (modelId: string) => void -} - -/** - * 虚拟化列表行组件,用于避免重新渲染 - */ -const VirtualizedRow = React.memo( - ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { - const { listItems, focusedItemKey, setFocusedItemKey, handleItemClick, togglePin, stickyGroup } = data - - const item = listItems[index] - - if (!item) { - return
- } - - const isFocused = item.key === focusedItemKey - - return ( -
- {item.type === 'group' ? ( - {item.name} - ) : ( - handleItemClick(item)} - onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> - - {item.icon} - {item.name} - {item.tags} - - { - e.stopPropagation() - if (item.model) { - togglePin(getModelUniqId(item.model)) - } - }} - data-pinned={item.isPinned} - $isPinned={item.isPinned}> - - - - )} -
- ) - } -) - -VirtualizedRow.displayName = 'VirtualizedRow' - const ListContainer = styled.div` position: relative; overflow: hidden; ` -const GroupItem = styled.div<{ $isSticky?: boolean }>` +const GroupItem = styled.div` display: flex; align-items: center; position: relative; @@ -526,12 +403,6 @@ const GroupItem = styled.div<{ $isSticky?: boolean }>` padding: 5px 10px 5px 18px; color: var(--color-text-3); z-index: 1; - - visibility: ${(props) => (props.$isSticky ? 'hidden' : 'visible')}; -` - -const StickyGroupBanner = styled(GroupItem)` - position: sticky; background: var(--modal-background); ` @@ -613,18 +484,6 @@ const EmptyState = styled.div` height: 200px; ` -const SearchIcon = styled.div` - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - background-color: var(--color-background-soft); - margin-right: 2px; -` - const PinIconWrapper = styled.div.attrs({ className: 'pin-icon' })<{ $isPinned?: boolean }>` margin-left: auto; padding: 0 10px; diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts deleted file mode 100644 index 974fc5b509..0000000000 --- a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ScrollAction, ScrollState } from './types' - -/** - * 初始状态 - */ -export const initialScrollState: ScrollState = { - focusedItemKey: '', - scrollTrigger: 'initial', - lastScrollOffset: 0, - stickyGroup: null, - isMouseOver: false -} - -/** - * 滚动状态的 reducer,用于避免复杂依赖可能带来的状态更新问题 - * @param state 当前状态 - * @param action 动作 - * @returns 新的状态 - */ -export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollState => { - switch (action.type) { - case 'SET_FOCUSED_ITEM_KEY': - return { ...state, focusedItemKey: action.payload } - - case 'SET_SCROLL_TRIGGER': - return { ...state, scrollTrigger: action.payload } - - case 'SET_LAST_SCROLL_OFFSET': - return { ...state, lastScrollOffset: action.payload } - - case 'SET_STICKY_GROUP': - return { ...state, stickyGroup: action.payload } - - case 'SET_IS_MOUSE_OVER': - return { ...state, isMouseOver: action.payload } - - case 'FOCUS_NEXT_ITEM': { - const { modelItems, step } = action.payload - - if (modelItems.length === 0) { - return { - ...state, - focusedItemKey: '', - scrollTrigger: 'keyboard' - } - } - - const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey) - const nextIndex = (currentIndex < 0 ? 0 : currentIndex + step + modelItems.length) % modelItems.length - - return { - ...state, - focusedItemKey: modelItems[nextIndex].key, - scrollTrigger: 'keyboard' - } - } - - case 'FOCUS_PAGE': { - const { modelItems, currentIndex, step } = action.payload - const nextIndex = Math.max(0, Math.min(currentIndex + step, modelItems.length - 1)) - - return { - ...state, - focusedItemKey: modelItems.length > 0 ? modelItems[nextIndex].key : '', - scrollTrigger: 'keyboard' - } - } - - case 'SEARCH_CHANGED': - return { - ...state, - scrollTrigger: action.payload.searchText ? 'search' : 'initial' - } - - case 'FOCUS_ON_LIST_CHANGE': { - const { modelItems } = action.payload - - // 在列表变化时尝试聚焦一个模型: - // - 如果是 initial 状态,先尝试聚焦当前选中的模型 - // - 如果是 search 状态,尝试聚焦第一个模型 - let newFocusedKey = '' - if (state.scrollTrigger === 'initial' || state.scrollTrigger === 'search') { - const selectedItem = modelItems.find((item) => item.isSelected) - if (selectedItem && state.scrollTrigger === 'initial') { - newFocusedKey = selectedItem.key - } else if (modelItems.length > 0) { - newFocusedKey = modelItems[0].key - } - } else { - newFocusedKey = state.focusedItemKey - } - - return { - ...state, - focusedItemKey: newFocusedKey - } - } - - default: - return state - } -} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx new file mode 100644 index 0000000000..1b4d1dbb02 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx @@ -0,0 +1,77 @@ +import { HStack } from '@renderer/components/Layout' +import { Input, InputRef } from 'antd' +import { Search } from 'lucide-react' +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface SelectModelSearchBarProps { + onSearch: (text: string) => void +} + +const SelectModelSearchBar: React.FC = ({ onSearch }) => { + const { t } = useTranslation() + const [searchText, setSearchText] = useState('') + const inputRef = useRef(null) + + const handleTextChange = useCallback( + (text: string) => { + setSearchText(text) + onSearch(text) + }, + [onSearch] + ) + + const handleClear = useCallback(() => { + setSearchText('') + onSearch('') + }, [onSearch]) + + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 0) + return () => clearTimeout(timer) + }, []) + + return ( + + + + + } + ref={inputRef} + placeholder={t('models.search')} + value={searchText} + onChange={(e) => handleTextChange(e.target.value)} + onClear={handleClear} + allowClear + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" + onKeyDown={(e) => { + // 防止上下键移动光标 + if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') { + e.preventDefault() + } + }} + /> + + ) +} + +const SearchIcon = styled.div` + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + background-color: var(--color-background-soft); + margin-right: 2px; +` + +export default memo(SelectModelSearchBar) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts index 745e9688bb..954a8e0d37 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/types.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -18,24 +18,3 @@ export interface FlatListItem { isPinned?: boolean isSelected?: boolean } - -// 滚动和焦点相关的状态类型 -export interface ScrollState { - focusedItemKey: string - scrollTrigger: ScrollTrigger - lastScrollOffset: number - stickyGroup: FlatListItem | null - isMouseOver: boolean -} - -// 滚动和焦点相关的 action 类型 -export type ScrollAction = - | { type: 'SET_FOCUSED_ITEM_KEY'; payload: string } - | { type: 'SET_SCROLL_TRIGGER'; payload: ScrollTrigger } - | { type: 'SET_LAST_SCROLL_OFFSET'; payload: number } - | { type: 'SET_STICKY_GROUP'; payload: FlatListItem | null } - | { type: 'SET_IS_MOUSE_OVER'; payload: boolean } - | { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } } - | { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } } - | { type: 'SEARCH_CHANGED'; payload: { searchText: string } } - | { type: 'FOCUS_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 4f91a729ae..36957aaf63 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -1,4 +1,5 @@ import { RightOutlined } from '@ant-design/icons' +import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { isMac } from '@renderer/config/constant' import useUserTheme from '@renderer/hooks/useUserTheme' import { classNames } from '@renderer/utils' @@ -6,7 +7,6 @@ import { Flex } from 'antd' import { t } from 'i18next' import { Check } from 'lucide-react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { FixedSizeList } from 'react-window' import styled from 'styled-components' import * as tinyPinyin from 'tiny-pinyin' @@ -55,7 +55,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const [historyPanel, setHistoryPanel] = useState([]) const bodyRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const footerRef = useRef(null) const [_searchText, setSearchText] = useState('') @@ -306,8 +306,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { useLayoutEffect(() => { if (!listRef.current || index < 0 || scrollTriggerRef.current === 'none') return - const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'smart' - listRef.current?.scrollToItem(index, alignment) + const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center' + listRef.current?.scrollToIndex(index, { align: alignment }) scrollTriggerRef.current = 'none' }, [index]) @@ -470,13 +470,45 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT }, [ctx.pageSize, list.length]) - const RowData = useMemo( - (): VirtualizedRowData => ({ - list, - focusedIndex: index, - handleItemAction - }), - [list, index, handleItemAction] + const estimateSize = useCallback(() => ITEM_HEIGHT, []) + + const rowRenderer = useCallback( + (item: QuickPanelListItem, itemIndex: number) => { + if (!item) return null + + return ( + { + e.stopPropagation() + handleItemAction(item, 'click') + }}> + + {item.icon} + {item.label} + + + + {item.description && {item.description}} + + {item.suffix ? ( + item.suffix + ) : item.isSelected ? ( + + ) : ( + item.isMenu && !item.disabled && + )} + + + + ) + }, + [index, handleItemAction] ) return ( @@ -494,19 +526,17 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return prev ? prev : true }) }> - - {VirtualizedRow} - + {rowRenderer} + {ctx.title || ''} @@ -546,57 +576,6 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { ) } -interface VirtualizedRowData { - list: QuickPanelListItem[] - focusedIndex: number - handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void -} - -/** - * 虚拟化列表行组件,用于避免重新渲染 - */ -const VirtualizedRow = React.memo( - ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { - const { list, focusedIndex, handleItemAction } = data - const item = list[index] - if (!item) return null - - return ( -
- { - e.stopPropagation() - handleItemAction(item, 'click') - }}> - - {item.icon} - {item.label} - - - - {item.description && {item.description}} - - {item.suffix ? ( - item.suffix - ) : item.isSelected ? ( - - ) : ( - item.isMenu && !item.disabled && - )} - - - -
- ) - } -) - const QuickPanelContainer = styled.div<{ $pageSize: number $selectedColor: string diff --git a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx index 995c5c5d0b..4e904efeb8 100644 --- a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx +++ b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx @@ -1,12 +1,35 @@ import { configureStore } from '@reduxjs/toolkit' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useEffect } from 'react' +import React, { useEffect } from 'react' import { Provider } from 'react-redux' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel' +// Mock the DynamicVirtualList component +vi.mock('@renderer/components/VirtualList', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + DynamicVirtualList: ({ ref, list, children, scrollerStyle }: any & { ref?: React.RefObject }) => { + // Expose a mock function for scrollToIndex + React.useImperativeHandle(ref, () => ({ + scrollToIndex: vi.fn() + })) + + // Render all items, not virtualized + return ( +
+ {list.map((item: any, index: number) => ( +
{children(item, index)}
+ ))} +
+ ) + } + } +}) + // Mock Redux store const mockStore = configureStore({ reducer: { @@ -16,6 +39,7 @@ const mockStore = configureStore({ function createList(length: number, prefix = 'Item', extra: Partial = {}) { return Array.from({ length }, (_, i) => ({ + id: `${prefix}-${i + 1}`, label: `${prefix} ${i + 1}`, description: `${prefix} Description ${i + 1}`, icon: `${prefix} Icon ${i + 1}`, diff --git a/yarn.lock b/yarn.lock index a1643ab873..17f2d0dfac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1461,7 +1461,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2": version: 7.27.4 resolution: "@babel/runtime@npm:7.27.4" checksum: 10c0/ca99e964179c31615e1352e058cc9024df7111c829631c90eec84caba6703cc32acc81503771847c306b3c70b815609fe82dde8682936debe295b0b283b2dc6e @@ -6625,15 +6625,6 @@ __metadata: languageName: node linkType: hard -"@types/react-window@npm:^1": - version: 1.8.8 - resolution: "@types/react-window@npm:1.8.8" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f - languageName: node - linkType: hard - "@types/react@npm:*, @types/react@npm:^19.0.12": version: 19.1.2 resolution: "@types/react@npm:19.1.2" @@ -7698,7 +7689,6 @@ __metadata: "@types/react": "npm:^19.0.12" "@types/react-dom": "npm:^19.0.4" "@types/react-infinite-scroll-component": "npm:^5.0.0" - "@types/react-window": "npm:^1" "@types/tinycolor2": "npm:^1" "@types/word-extractor": "npm:^1" "@uiw/codemirror-extensions-langs": "npm:^4.23.14" @@ -7790,7 +7780,6 @@ __metadata: react-router: "npm:6" react-router-dom: "npm:6" react-spinners: "npm:^0.14.1" - react-window: "npm:^1.8.11" redux: "npm:^5.0.1" redux-persist: "npm:^6.0.0" reflect-metadata: "npm:0.2.2" @@ -15223,13 +15212,6 @@ __metadata: languageName: node linkType: hard -"memoize-one@npm:>=3.1.1 <6": - version: 5.2.1 - resolution: "memoize-one@npm:5.2.1" - checksum: 10c0/fd22dbe9a978a2b4f30d6a491fc02fb90792432ad0dab840dc96c1734d2bd7c9cdeb6a26130ec60507eb43230559523615873168bcbe8fafab221c30b11d54c1 - languageName: node - linkType: hard - "memoize-one@npm:^6.0.0": version: 6.0.0 resolution: "memoize-one@npm:6.0.0" @@ -18410,19 +18392,6 @@ __metadata: languageName: node linkType: hard -"react-window@npm:^1.8.11": - version: 1.8.11 - resolution: "react-window@npm:1.8.11" - dependencies: - "@babel/runtime": "npm:^7.0.0" - memoize-one: "npm:>=3.1.1 <6" - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/5ae8da1bc5c47d8f0a428b28a600256e2db511975573e52cb65a9b27ed1a0e5b9f7b3bee5a54fb0da93956d782c24010be434be451072f46ba5a89159d2b3944 - languageName: node - linkType: hard - "react@npm:^19.0.0": version: 19.1.0 resolution: "react@npm:19.1.0"