mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 11:20:07 +08:00
perf: model select popup (#8766)
- use DynamicVirtualList in SelectModelPopup - use DynamicVirtualList in QuickPanelView - remove react-window - simplify SelectModelPopup states, improve maintainability
This commit is contained in:
parent
82923a7c64
commit
9e405f0604
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<Props> = ({ model, resolve, modelFilter }) => {
|
||||
const { providers } = useProviders()
|
||||
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
|
||||
const [open, setOpen] = useState(true)
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const listRef = useRef<FixedSizeList>(null)
|
||||
const listRef = useRef<DynamicVirtualListRef>(null)
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
|
||||
@ -56,49 +52,19 @@ const PopupContainer: React.FC<Props> = ({ 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<FlatListItem | null>(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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 <GroupItem>{item.name}</GroupItem>
|
||||
}
|
||||
return (
|
||||
<ModelItem
|
||||
className={classNames({
|
||||
focused: isFocused,
|
||||
selected: item.isSelected
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}>
|
||||
<ModelItemLeft>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
{item.tags}
|
||||
</ModelItemLeft>
|
||||
<PinIconWrapper
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.model) {
|
||||
togglePin(getModelUniqId(item.model))
|
||||
}
|
||||
}}
|
||||
data-pinned={item.isPinned}
|
||||
$isPinned={item.isPinned}>
|
||||
<PushpinOutlined />
|
||||
</PinIconWrapper>
|
||||
</ModelItem>
|
||||
)
|
||||
},
|
||||
[focusedItemKey, handleItemClick, setFocusedItemKey, togglePin]
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -396,50 +361,23 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
|
||||
closeIcon={null}
|
||||
footer={null}>
|
||||
{/* 搜索框 */}
|
||||
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchIcon>
|
||||
<Search size={15} />
|
||||
</SearchIcon>
|
||||
}
|
||||
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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<SelectModelSearchBar onSearch={setSearchText} />
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
|
||||
{listItems.length > 0 ? (
|
||||
<ListContainer onMouseMove={() => !isMouseOver && startTransition(() => setIsMouseOver(true))}>
|
||||
{/* Sticky Group Banner,它会替换第一个分组名称 */}
|
||||
<StickyGroupBanner>{stickyGroup?.name}</StickyGroupBanner>
|
||||
<FixedSizeList
|
||||
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
height={listHeight}
|
||||
width="100%"
|
||||
itemCount={listItems.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemData={RowData}
|
||||
itemKey={(index, data) => data.listItems[index].key}
|
||||
overscanCount={4}
|
||||
onScroll={handleScroll}
|
||||
style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}>
|
||||
{VirtualizedRow}
|
||||
</FixedSizeList>
|
||||
list={listItems}
|
||||
size={listHeight}
|
||||
getItemKey={getItemKey}
|
||||
estimateSize={estimateSize}
|
||||
isSticky={isSticky}
|
||||
scrollPaddingStart={ITEM_HEIGHT} // 留出 sticky header 高度
|
||||
overscan={5}
|
||||
scrollerStyle={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}>
|
||||
{rowRenderer}
|
||||
</DynamicVirtualList>
|
||||
</ListContainer>
|
||||
) : (
|
||||
<EmptyState>
|
||||
@ -450,73 +388,12 @@ const PopupContainer: React.FC<Props> = ({ 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 <div style={style} />
|
||||
}
|
||||
|
||||
const isFocused = item.key === focusedItemKey
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
{item.type === 'group' ? (
|
||||
<GroupItem $isSticky={item.key === stickyGroup?.key}>{item.name}</GroupItem>
|
||||
) : (
|
||||
<ModelItem
|
||||
className={classNames({
|
||||
focused: isFocused,
|
||||
selected: item.isSelected
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}>
|
||||
<ModelItemLeft>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
{item.tags}
|
||||
</ModelItemLeft>
|
||||
<PinIconWrapper
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.model) {
|
||||
togglePin(getModelUniqId(item.model))
|
||||
}
|
||||
}}
|
||||
data-pinned={item.isPinned}
|
||||
$isPinned={item.isPinned}>
|
||||
<PushpinOutlined />
|
||||
</PinIconWrapper>
|
||||
</ModelItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<SelectModelSearchBarProps> = ({ onSearch }) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(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 (
|
||||
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchIcon>
|
||||
<Search size={15} />
|
||||
</SearchIcon>
|
||||
}
|
||||
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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
@ -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[] } }
|
||||
|
||||
@ -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<Props> = ({ setInputText }) => {
|
||||
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<FixedSizeList>(null)
|
||||
const listRef = useRef<DynamicVirtualListRef>(null)
|
||||
const footerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
@ -306,8 +306,8 @@ export const QuickPanelView: React.FC<Props> = ({ 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<Props> = ({ 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 (
|
||||
<QuickPanelItem
|
||||
className={classNames({
|
||||
focused: itemIndex === index,
|
||||
selected: item.isSelected,
|
||||
disabled: item.disabled
|
||||
})}
|
||||
data-id={itemIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleItemAction(item, 'click')
|
||||
}}>
|
||||
<QuickPanelItemLeft>
|
||||
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
|
||||
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
|
||||
</QuickPanelItemLeft>
|
||||
|
||||
<QuickPanelItemRight>
|
||||
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
|
||||
<QuickPanelItemSuffixIcon>
|
||||
{item.suffix ? (
|
||||
item.suffix
|
||||
) : item.isSelected ? (
|
||||
<Check />
|
||||
) : (
|
||||
item.isMenu && !item.disabled && <RightOutlined />
|
||||
)}
|
||||
</QuickPanelItemSuffixIcon>
|
||||
</QuickPanelItemRight>
|
||||
</QuickPanelItem>
|
||||
)
|
||||
},
|
||||
[index, handleItemAction]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -494,19 +526,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
<FixedSizeList
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
itemCount={list.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemData={RowData}
|
||||
height={listHeight}
|
||||
width="100%"
|
||||
overscanCount={4}
|
||||
style={{
|
||||
list={list}
|
||||
size={listHeight}
|
||||
estimateSize={estimateSize}
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
pointerEvents: isMouseOver ? 'auto' : 'none'
|
||||
}}>
|
||||
{VirtualizedRow}
|
||||
</FixedSizeList>
|
||||
{rowRenderer}
|
||||
</DynamicVirtualList>
|
||||
<QuickPanelFooter ref={footerRef}>
|
||||
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
|
||||
<QuickPanelFooterTips $footerWidth={footerWidth}>
|
||||
@ -546,57 +576,6 @@ export const QuickPanelView: React.FC<Props> = ({ 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 (
|
||||
<div style={style}>
|
||||
<QuickPanelItem
|
||||
className={classNames({
|
||||
focused: index === focusedIndex,
|
||||
selected: item.isSelected,
|
||||
disabled: item.disabled
|
||||
})}
|
||||
data-id={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleItemAction(item, 'click')
|
||||
}}>
|
||||
<QuickPanelItemLeft>
|
||||
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
|
||||
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
|
||||
</QuickPanelItemLeft>
|
||||
|
||||
<QuickPanelItemRight>
|
||||
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
|
||||
<QuickPanelItemSuffixIcon>
|
||||
{item.suffix ? (
|
||||
item.suffix
|
||||
) : item.isSelected ? (
|
||||
<Check />
|
||||
) : (
|
||||
item.isMenu && !item.disabled && <RightOutlined />
|
||||
)}
|
||||
</QuickPanelItemSuffixIcon>
|
||||
</QuickPanelItemRight>
|
||||
</QuickPanelItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const QuickPanelContainer = styled.div<{
|
||||
$pageSize: number
|
||||
$selectedColor: string
|
||||
|
||||
@ -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<typeof import('@renderer/components/VirtualList')>()
|
||||
return {
|
||||
...mod,
|
||||
DynamicVirtualList: ({ ref, list, children, scrollerStyle }: any & { ref?: React.RefObject<any | null> }) => {
|
||||
// Expose a mock function for scrollToIndex
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: vi.fn()
|
||||
}))
|
||||
|
||||
// Render all items, not virtualized
|
||||
return (
|
||||
<div style={scrollerStyle}>
|
||||
{list.map((item: any, index: number) => (
|
||||
<div key={item.id || index}>{children(item, index)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Mock Redux store
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
@ -16,6 +39,7 @@ const mockStore = configureStore({
|
||||
|
||||
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
|
||||
return Array.from({ length }, (_, i) => ({
|
||||
id: `${prefix}-${i + 1}`,
|
||||
label: `${prefix} ${i + 1}`,
|
||||
description: `${prefix} Description ${i + 1}`,
|
||||
icon: `${prefix} Icon ${i + 1}`,
|
||||
|
||||
33
yarn.lock
33
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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user