mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
perf: virtual list for quick panel and SelectModelPopup (#5594)
* perf: quick panel with virtual list * fix: adaptive virtual list height * fix: keep panel content if it is re-opened in a very short interval * refactor: use modified rc-virtual-list to support overscan * fix: virtual list item key * refactor: remove useless styles * refactor: support smooth scrolling for virtual list * fix: lint error * perf: use virtual list for SelectModelPopup * refactor: change model name style * fix: better auto scroll behaviour * perf: improve memorization for SelectModelPopup * fix: group name background * refactor: change model item margin * chore: update rc-virtual-list to 3.19.2 * fix: adaptive list height * refactor: improve styles for focused or selected items * refactor: do not show model if the assistant has not default model * chore: migrate to custom rc-virtual-list * refactor: improve selected item style * refactor: improve selected item style * fix: left margin * refactor: simplify the indicator for selected item * fix: prevent mouse hover for keyboard events * chore: bump rc-virtual-list * refactor: simulate sticky group header in SelectModelPopup * fix: cleanup timer, add comments * perf: improve smooth scrolling * chore: bump to rc-virtual-list:3.19.6 * refactor: update memorization * refactor: extract item rendering logic * refactor: delay CustomTag tooltip for performance * fix: disable spellcheck in model search bar * refactor: expand/collapse model label on resizing window * refactor: simplify filtering * chore: update rc-virtual-list * refactor: always render virtual list to avoid inconsistent state * chore: update dependencies * chore: update dependencies * chore: update dependencies * refactor: remove useless states * refactor: simplify selected state * refactor: improve keyboard events for SelectModelPopup * revert: do not expand mode tags * refactor: reduce animation time * chore: update dependencies * refactor: better names and comments * refactor: better error handling * refactor: simplify auto-scrolling logic * refactor: use react-window rather than rc-virtual-list * fix: disable auto-scroll * fix: scroll bar style and item margin * fix: initialize sticky banner * refactor: distinguish auto-scrolling behaviour for different causes * fix: keyboard navigation error
This commit is contained in:
parent
685ea0e297
commit
fcc52867c4
@ -97,6 +97,8 @@
|
||||
"opendal": "^0.47.11",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react-window": "^1.8.11",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@ -141,6 +143,7 @@
|
||||
"@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/ws": "^8",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
@ -179,7 +182,6 @@
|
||||
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rc-virtual-list": "^3.18.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
|
||||
@ -51,6 +51,8 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
@ -123,6 +125,8 @@ body[theme-mode='light'] {
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { CloseOutlined } from '@ant-design/icons'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, memo, useEffect, useMemo, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CustomTagProps {
|
||||
@ -14,17 +14,33 @@ interface CustomTagProps {
|
||||
}
|
||||
|
||||
const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => {
|
||||
return (
|
||||
<Tooltip title={tooltip} placement="top">
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShowTooltip(true), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const tagContent = useMemo(
|
||||
() => (
|
||||
<Tag $color={color} $size={size} $closable={closable}>
|
||||
{icon && icon} {children}
|
||||
{closable && <CloseIcon $size={size} $color={color} onClick={onClose} />}
|
||||
</Tag>
|
||||
),
|
||||
[children, color, closable, icon, onClose, size]
|
||||
)
|
||||
|
||||
return tooltip && showTooltip ? (
|
||||
<Tooltip title={tooltip} placement="top">
|
||||
{tagContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
tagContent
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomTag
|
||||
export default memo(CustomTag)
|
||||
|
||||
const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>`
|
||||
display: inline-flex;
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import i18n from '@renderer/i18n'
|
||||
import { Model } from '@renderer/types'
|
||||
import { isFreeModel } from '@renderer/utils'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -36,34 +36,35 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
style
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [_showLabel, _setShowLabel] = useState(showLabel)
|
||||
const [shouldShowLabel, setShouldShowLabel] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const resizeObserver = useRef<ResizeObserver>(null)
|
||||
const resizeObserver = useRef<ResizeObserver | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLabel) return
|
||||
const maxWidth = useMemo(() => (i18n.language.startsWith('zh') ? 300 : 350), [])
|
||||
|
||||
if (containerRef.current) {
|
||||
const currentElement = containerRef.current
|
||||
useLayoutEffect(() => {
|
||||
const currentElement = containerRef.current
|
||||
if (!showLabel || !currentElement) return
|
||||
|
||||
setShouldShowLabel(currentElement.offsetWidth >= maxWidth)
|
||||
|
||||
if (currentElement) {
|
||||
resizeObserver.current = new ResizeObserver((entries) => {
|
||||
const maxWidth = i18n.language.startsWith('zh') ? 300 : 350
|
||||
|
||||
for (const entry of entries) {
|
||||
const { width } = entry.contentRect
|
||||
_setShowLabel(width >= maxWidth)
|
||||
setShouldShowLabel(width >= maxWidth)
|
||||
}
|
||||
})
|
||||
resizeObserver.current.observe(currentElement)
|
||||
|
||||
return () => {
|
||||
if (resizeObserver.current) {
|
||||
resizeObserver.current.unobserve(currentElement)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (resizeObserver.current && currentElement) {
|
||||
resizeObserver.current.unobserve(currentElement)
|
||||
resizeObserver.current.disconnect()
|
||||
resizeObserver.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [showLabel])
|
||||
}, [maxWidth, showLabel])
|
||||
|
||||
return (
|
||||
<Container ref={containerRef} style={style}>
|
||||
@ -73,7 +74,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
color="#00b96b"
|
||||
icon={<EyeOutlined style={{ fontSize: size }} />}
|
||||
tooltip={t('models.type.vision')}>
|
||||
{_showLabel ? t('models.type.vision') : ''}
|
||||
{shouldShowLabel ? t('models.type.vision') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{isWebSearchModel(model) && (
|
||||
@ -82,7 +83,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
color="#1677ff"
|
||||
icon={<GlobalOutlined style={{ fontSize: size }} />}
|
||||
tooltip={t('models.type.websearch')}>
|
||||
{_showLabel ? t('models.type.websearch') : ''}
|
||||
{shouldShowLabel ? t('models.type.websearch') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{showReasoning && isReasoningModel(model) && (
|
||||
@ -91,7 +92,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
color="#6372bd"
|
||||
icon={<i className="iconfont icon-thinking" />}
|
||||
tooltip={t('models.type.reasoning')}>
|
||||
{_showLabel ? t('models.type.reasoning') : ''}
|
||||
{shouldShowLabel ? t('models.type.reasoning') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{showToolsCalling && isFunctionCallingModel(model) && (
|
||||
@ -100,7 +101,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
color="#f18737"
|
||||
icon={<ToolOutlined style={{ fontSize: size }} />}
|
||||
tooltip={t('models.type.function_calling')}>
|
||||
{_showLabel ? t('models.type.function_calling') : ''}
|
||||
{shouldShowLabel ? t('models.type.function_calling') : ''}
|
||||
</CustomTag>
|
||||
)}
|
||||
{isEmbeddingModel(model) && (
|
||||
@ -128,4 +129,4 @@ const Container = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default ModelTagsWithLabel
|
||||
export default memo(ModelTagsWithLabel)
|
||||
|
||||
@ -5,18 +5,39 @@ import db from '@renderer/databases'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
|
||||
import { classNames } from '@renderer/utils/style'
|
||||
import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from '../Layout'
|
||||
import ModelTagsWithLabel from '../ModelTagsWithLabel'
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number]
|
||||
const PAGE_SIZE = 9
|
||||
const ITEM_HEIGHT = 36
|
||||
|
||||
// 列表项类型,组名也作为列表项
|
||||
type ListItemType = 'group' | 'model'
|
||||
|
||||
// 滚动触发来源类型
|
||||
type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
|
||||
|
||||
// 扁平化列表项接口
|
||||
interface FlatListItem {
|
||||
key: string
|
||||
type: ListItemType
|
||||
icon?: React.ReactNode
|
||||
name: React.ReactNode
|
||||
tags?: React.ReactNode
|
||||
model?: Model
|
||||
isPinned?: boolean
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
model?: Model
|
||||
@ -27,25 +48,25 @@ interface PopupContainerProps extends Props {
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const { providers } = useProviders()
|
||||
const [open, setOpen] = useState(true)
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const listRef = useRef<FixedSizeList>(null)
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||
const [pinnedModels, setPinnedModels] = useState<string[]>([])
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
|
||||
const menuItemRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||
const [_focusedItemKey, setFocusedItemKey] = useState<string>('')
|
||||
const focusedItemKey = useDeferredValue(_focusedItemKey)
|
||||
const [currentStickyGroup, setCurrentStickyGroup] = useState<FlatListItem | null>(null)
|
||||
const firstGroupRef = useRef<FlatListItem | null>(null)
|
||||
const scrollTriggerRef = useRef<ScrollTrigger>('initial')
|
||||
|
||||
const setMenuItemRef = useCallback(
|
||||
(key: string) => (el: HTMLElement | null) => {
|
||||
if (el) {
|
||||
menuItemRefs.current[key] = el
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
// 当前选中的模型ID
|
||||
const currentModelId = model ? getModelUniqId(model) : ''
|
||||
|
||||
// 加载置顶模型列表
|
||||
useEffect(() => {
|
||||
const loadPinnedModels = async () => {
|
||||
const setting = await db.settings.get('pinned:models')
|
||||
@ -60,19 +81,34 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
|
||||
}
|
||||
|
||||
setPinnedModels(sortBy(validPinnedModels, ['group', 'name']))
|
||||
setPinnedModels(sortBy(validPinnedModels))
|
||||
}
|
||||
|
||||
try {
|
||||
loadPinnedModels()
|
||||
} catch (error) {
|
||||
console.error('Failed to load pinned models', error)
|
||||
setPinnedModels([])
|
||||
}
|
||||
loadPinnedModels()
|
||||
}, [providers])
|
||||
|
||||
const togglePin = async (modelId: string) => {
|
||||
const newPinnedModels = pinnedModels.includes(modelId)
|
||||
? pinnedModels.filter((id) => id !== modelId)
|
||||
: [...pinnedModels, modelId]
|
||||
const togglePin = useCallback(
|
||||
async (modelId: string) => {
|
||||
const newPinnedModels = pinnedModels.includes(modelId)
|
||||
? pinnedModels.filter((id) => id !== modelId)
|
||||
: [...pinnedModels, modelId]
|
||||
|
||||
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
|
||||
setPinnedModels(sortBy(newPinnedModels, ['group', 'name']))
|
||||
}
|
||||
try {
|
||||
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
|
||||
setPinnedModels(sortBy(newPinnedModels))
|
||||
// Pin操作不触发滚动
|
||||
scrollTriggerRef.current = 'none'
|
||||
} catch (error) {
|
||||
console.error('Failed to update pinned models', error)
|
||||
}
|
||||
},
|
||||
[pinnedModels]
|
||||
)
|
||||
|
||||
// 根据输入的文本筛选模型
|
||||
const getFilteredModels = useCallback(
|
||||
@ -99,267 +135,276 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
[searchText, t, pinnedModels]
|
||||
)
|
||||
|
||||
// 递归处理菜单项,为每个项添加ref
|
||||
const processMenuItems = useCallback(
|
||||
(items: MenuItem[]) => {
|
||||
// 内部定义 renderMenuItem 函数
|
||||
const renderMenuItem = (item: any) => {
|
||||
return {
|
||||
...item,
|
||||
label: <div ref={setMenuItemRef(item.key)}>{item.label}</div>
|
||||
}
|
||||
}
|
||||
// 创建模型列表项
|
||||
const createModelItem = useCallback(
|
||||
(model: Model, provider: any, isPinned: boolean): FlatListItem => {
|
||||
const modelId = getModelUniqId(model)
|
||||
const groupName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
|
||||
|
||||
return items.map((item) => {
|
||||
if (item && 'children' in item && item.children) {
|
||||
return {
|
||||
...item,
|
||||
children: (item.children as MenuItem[]).map(renderMenuItem)
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
return {
|
||||
key: isPinned ? `${modelId}_pinned` : modelId,
|
||||
type: 'model',
|
||||
name: (
|
||||
<ModelName>
|
||||
{model.name}
|
||||
{isPinned && <span style={{ color: 'var(--color-text-3)' }}> | {groupName}</span>}
|
||||
</ModelName>
|
||||
),
|
||||
tags: (
|
||||
<TagsContainer>
|
||||
<ModelTagsWithLabel model={model} size={11} showLabel={false} />
|
||||
</TagsContainer>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(model.id || '')} size={24}>
|
||||
{first(model.name) || 'M'}
|
||||
</Avatar>
|
||||
),
|
||||
model,
|
||||
isPinned,
|
||||
isSelected: modelId === currentModelId
|
||||
}
|
||||
},
|
||||
[setMenuItemRef]
|
||||
[t, currentModelId]
|
||||
)
|
||||
|
||||
const filteredItems: MenuItem[] = providers
|
||||
.filter((p) => p.models && p.models.length > 0)
|
||||
.map((p) => {
|
||||
const filteredModels = getFilteredModels(p).map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m))
|
||||
}}
|
||||
isPinned={pinnedModels.includes(getModelUniqId(m))}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||
{first(m?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => {
|
||||
resolve(m)
|
||||
setOpen(false)
|
||||
}
|
||||
}))
|
||||
// 构建扁平化列表数据
|
||||
const listItems = useMemo(() => {
|
||||
const items: FlatListItem[] = []
|
||||
|
||||
// Only return the group if it has filtered models
|
||||
return filteredModels.length > 0
|
||||
? {
|
||||
key: p.id,
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
type: 'group',
|
||||
children: filteredModels
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter(Boolean) as MenuItem[] // Filter out null items
|
||||
|
||||
if (pinnedModels.length > 0 && searchText.length === 0) {
|
||||
const pinnedItems = providers
|
||||
.flatMap((p) =>
|
||||
p.models
|
||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m),
|
||||
model: m,
|
||||
provider: p
|
||||
}))
|
||||
)
|
||||
.map((m) => ({
|
||||
key: getModelUniqId(m.model) + '_pinned',
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>
|
||||
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
|
||||
</span>{' '}
|
||||
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(getModelUniqId(m.model))
|
||||
}}
|
||||
isPinned={true}>
|
||||
<PushpinOutlined />
|
||||
</PinIcon>
|
||||
</ModelItem>
|
||||
),
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.model?.id || '')} size={24}>
|
||||
{first(m.model?.name)}
|
||||
</Avatar>
|
||||
),
|
||||
onClick: () => {
|
||||
resolve(m.model)
|
||||
setOpen(false)
|
||||
}
|
||||
}))
|
||||
|
||||
if (pinnedItems.length > 0) {
|
||||
filteredItems.unshift({
|
||||
key: 'pinned',
|
||||
label: t('models.pinned'),
|
||||
type: 'group',
|
||||
children: pinnedItems
|
||||
} as MenuItem)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理菜单项,添加ref
|
||||
const processedItems = processMenuItems(filteredItems)
|
||||
|
||||
const onCancel = () => {
|
||||
setKeyboardSelectedId('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = async () => {
|
||||
setKeyboardSelectedId('')
|
||||
resolve(undefined)
|
||||
SelectModelPopup.hide()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
open && setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && model) {
|
||||
setTimeout(() => {
|
||||
const modelId = getModelUniqId(model)
|
||||
if (menuItemRefs.current[modelId]) {
|
||||
menuItemRefs.current[modelId]?.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
}
|
||||
}, 100) // Small delay to ensure menu is rendered
|
||||
}
|
||||
}, [open, model])
|
||||
|
||||
// 获取所有可见的模型项
|
||||
const getVisibleModelItems = useCallback(() => {
|
||||
const items: { key: string; model: Model }[] = []
|
||||
|
||||
// 如果有置顶模型且没有搜索文本,添加置顶模型
|
||||
// 添加置顶模型分组(仅在无搜索文本时)
|
||||
if (pinnedModels.length > 0 && searchText.length === 0) {
|
||||
providers
|
||||
.flatMap((p) => p.models || [])
|
||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
.forEach((m) => items.push({ key: getModelUniqId(m) + '_pinned', model: m }))
|
||||
const pinnedItems = providers.flatMap((p) =>
|
||||
p.models.filter((m) => pinnedModels.includes(getModelUniqId(m))).map((m) => createModelItem(m, p, true))
|
||||
)
|
||||
|
||||
if (pinnedItems.length > 0) {
|
||||
// 添加置顶分组标题
|
||||
items.push({
|
||||
key: 'pinned-group',
|
||||
type: 'group',
|
||||
name: t('models.pinned'),
|
||||
isSelected: false
|
||||
})
|
||||
|
||||
items.push(...pinnedItems)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加其他过滤后的模型
|
||||
// 添加常规模型分组
|
||||
providers.forEach((p) => {
|
||||
if (p.models) {
|
||||
getFilteredModels(p).forEach((m) => {
|
||||
const modelId = getModelUniqId(m)
|
||||
const isPinned = pinnedModels.includes(modelId)
|
||||
const filteredModels = getFilteredModels(p).filter(
|
||||
(m) => !pinnedModels.includes(getModelUniqId(m)) || searchText.length > 0
|
||||
)
|
||||
|
||||
// 搜索状态下,所有匹配的模型都应该可以被选中,包括固定的模型
|
||||
// 非搜索状态下,只添加非固定模型(固定模型已在上面添加)
|
||||
if (searchText.length > 0 || !isPinned) {
|
||||
items.push({
|
||||
key: modelId,
|
||||
model: m
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
if (filteredModels.length === 0) return
|
||||
|
||||
// 添加 provider 分组标题
|
||||
items.push({
|
||||
key: `provider-${p.id}`,
|
||||
type: 'group',
|
||||
name: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
isSelected: false
|
||||
})
|
||||
|
||||
items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModels.includes(getModelUniqId(m)))))
|
||||
})
|
||||
|
||||
return items
|
||||
}, [pinnedModels, searchText, providers, getFilteredModels])
|
||||
|
||||
// 添加一个useLayoutEffect来处理滚动
|
||||
useLayoutEffect(() => {
|
||||
if (open && keyboardSelectedId && menuItemRefs.current[keyboardSelectedId]) {
|
||||
// 获取当前选中元素和容器
|
||||
const selectedElement = menuItemRefs.current[keyboardSelectedId]
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
|
||||
if (!scrollContainer) return
|
||||
|
||||
const selectedRect = selectedElement.getBoundingClientRect()
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
|
||||
// 计算元素相对于容器的位置
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
const elementTop = selectedRect.top - containerRect.top + currentScrollTop
|
||||
const groupTitleHeight = 30
|
||||
|
||||
// 确定滚动位置
|
||||
if (selectedRect.top < containerRect.top + groupTitleHeight) {
|
||||
// 元素被组标题遮挡,向上滚动
|
||||
scrollContainer.scrollTo({
|
||||
top: elementTop - groupTitleHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
} else if (selectedRect.bottom > containerRect.bottom) {
|
||||
// 元素在视口下方,向下滚动
|
||||
scrollContainer.scrollTo({
|
||||
top: elementTop - containerRect.height + selectedRect.height,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
// 移除第一个分组标题,使用 sticky group banner 替代,模拟 sticky 效果
|
||||
if (items.length > 0 && items[0].type === 'group') {
|
||||
firstGroupRef.current = items[0]
|
||||
items.shift()
|
||||
} else {
|
||||
firstGroupRef.current = null
|
||||
}
|
||||
}, [open, keyboardSelectedId])
|
||||
return items
|
||||
}, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem])
|
||||
|
||||
// 处理键盘导航
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const items = getVisibleModelItems()
|
||||
if (items.length === 0) return
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const currentIndex = items.findIndex((item) => item.key === keyboardSelectedId)
|
||||
let nextIndex
|
||||
|
||||
if (currentIndex === -1) {
|
||||
nextIndex = e.key === 'ArrowDown' ? 0 : items.length - 1
|
||||
} else {
|
||||
nextIndex =
|
||||
e.key === 'ArrowDown' ? (currentIndex + 1) % items.length : (currentIndex - 1 + items.length) % items.length
|
||||
}
|
||||
|
||||
const nextItem = items[nextIndex]
|
||||
setKeyboardSelectedId(nextItem.key)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault() // 阻止回车的默认行为
|
||||
if (keyboardSelectedId) {
|
||||
const selectedItem = items.find((item) => item.key === keyboardSelectedId)
|
||||
if (selectedItem) {
|
||||
resolve(selectedItem.model)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[keyboardSelectedId, getVisibleModelItems, resolve, setOpen]
|
||||
)
|
||||
// 获取可选择的模型项(过滤掉分组标题)
|
||||
const modelItems = useMemo(() => {
|
||||
return listItems.filter((item) => item.type === 'model')
|
||||
}, [listItems])
|
||||
|
||||
// 搜索文本变化时设置滚动来源
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
// 搜索文本改变时重置键盘选中状态
|
||||
useEffect(() => {
|
||||
setKeyboardSelectedId('')
|
||||
if (searchText.trim() !== '') {
|
||||
scrollTriggerRef.current = 'search'
|
||||
setFocusedItemKey('')
|
||||
}
|
||||
}, [searchText])
|
||||
|
||||
const selectedKeys = keyboardSelectedId ? [keyboardSelectedId] : model ? [getModelUniqId(model)] : []
|
||||
// 设置初始聚焦项以触发滚动
|
||||
useEffect(() => {
|
||||
if (scrollTriggerRef.current === 'initial' || scrollTriggerRef.current === 'search') {
|
||||
const selectedItem = modelItems.find((item) => item.isSelected)
|
||||
if (selectedItem) {
|
||||
setFocusedItemKey(selectedItem.key)
|
||||
} else if (scrollTriggerRef.current === 'initial' && modelItems.length > 0) {
|
||||
setFocusedItemKey(modelItems[0].key)
|
||||
}
|
||||
// 其余情况不设置focusedItemKey
|
||||
}
|
||||
}, [modelItems])
|
||||
|
||||
// 滚动到聚焦项
|
||||
useEffect(() => {
|
||||
if (scrollTriggerRef.current === 'none' || !focusedItemKey) return
|
||||
|
||||
const index = listItems.findIndex((item) => item.key === focusedItemKey)
|
||||
if (index < 0) return
|
||||
|
||||
// 根据触发源决定滚动对齐方式
|
||||
const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center'
|
||||
listRef.current?.scrollToItem(index, alignment)
|
||||
|
||||
console.log('focusedItemKey', focusedItemKey)
|
||||
console.log('scrollToFocusedItem', index, alignment)
|
||||
|
||||
// 滚动后重置触发器
|
||||
scrollTriggerRef.current = 'none'
|
||||
}, [focusedItemKey, listItems])
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: FlatListItem) => {
|
||||
if (item.type === 'model') {
|
||||
scrollTriggerRef.current = 'none'
|
||||
resolve(item.model)
|
||||
setOpen(false)
|
||||
}
|
||||
},
|
||||
[resolve]
|
||||
)
|
||||
|
||||
// 处理键盘导航
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!open) return
|
||||
|
||||
if (modelItems.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 键盘操作时禁用鼠标 hover
|
||||
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
}
|
||||
|
||||
const getCurrentIndex = (currentKey: string) => {
|
||||
const currentIndex = modelItems.findIndex((item) => item.key === currentKey)
|
||||
return currentIndex < 0 ? 0 : currentIndex
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setFocusedItemKey((prev) => {
|
||||
const currentIndex = getCurrentIndex(prev)
|
||||
const nextIndex = (currentIndex - 1 + modelItems.length) % modelItems.length
|
||||
return modelItems[nextIndex].key
|
||||
})
|
||||
break
|
||||
case 'ArrowDown':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setFocusedItemKey((prev) => {
|
||||
const currentIndex = getCurrentIndex(prev)
|
||||
const nextIndex = (currentIndex + 1) % modelItems.length
|
||||
return modelItems[nextIndex].key
|
||||
})
|
||||
break
|
||||
case 'PageUp':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setFocusedItemKey((prev) => {
|
||||
const currentIndex = getCurrentIndex(prev)
|
||||
const nextIndex = Math.max(currentIndex - PAGE_SIZE, 0)
|
||||
return modelItems[nextIndex].key
|
||||
})
|
||||
break
|
||||
case 'PageDown':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setFocusedItemKey((prev) => {
|
||||
const currentIndex = getCurrentIndex(prev)
|
||||
const nextIndex = Math.min(currentIndex + PAGE_SIZE, modelItems.length - 1)
|
||||
return modelItems[nextIndex].key
|
||||
})
|
||||
break
|
||||
case 'Enter':
|
||||
if (focusedItemKey) {
|
||||
const selectedItem = modelItems.find((item) => item.key === focusedItemKey)
|
||||
if (selectedItem) {
|
||||
handleItemClick(selectedItem)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
scrollTriggerRef.current = 'none'
|
||||
setOpen(false)
|
||||
resolve(undefined)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [focusedItemKey, modelItems, handleItemClick, open, resolve])
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
scrollTriggerRef.current = 'none'
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const onClose = useCallback(async () => {
|
||||
scrollTriggerRef.current = 'none'
|
||||
resolve(undefined)
|
||||
SelectModelPopup.hide()
|
||||
}, [resolve])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
scrollTriggerRef.current = 'initial'
|
||||
}, [open])
|
||||
|
||||
// 初始化sticky分组标题
|
||||
useEffect(() => {
|
||||
if (firstGroupRef.current) {
|
||||
setCurrentStickyGroup(firstGroupRef.current)
|
||||
}
|
||||
}, [listItems])
|
||||
|
||||
const handleItemsRendered = useCallback(
|
||||
({ visibleStartIndex }: { visibleStartIndex: number; visibleStopIndex: number }) => {
|
||||
// 从可见区域的起始位置向前查找最近的分组标题
|
||||
for (let i = visibleStartIndex - 1; i >= 0; i--) {
|
||||
if (listItems[i]?.type === 'group') {
|
||||
setCurrentStickyGroup(listItems[i])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 找不到则使用第一个分组标题
|
||||
setCurrentStickyGroup(firstGroupRef.current ?? null)
|
||||
},
|
||||
[listItems]
|
||||
)
|
||||
|
||||
const RowData = useMemo(
|
||||
(): VirtualizedRowData => ({
|
||||
listItems,
|
||||
focusedItemKey,
|
||||
setFocusedItemKey,
|
||||
currentStickyGroup,
|
||||
handleItemClick,
|
||||
togglePin
|
||||
}),
|
||||
[currentStickyGroup, focusedItemKey, handleItemClick, listItems, togglePin]
|
||||
)
|
||||
|
||||
const listHeight = useMemo(() => {
|
||||
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
|
||||
}, [listItems.length])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -380,6 +425,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
}}
|
||||
closeIcon={null}
|
||||
footer={null}>
|
||||
{/* 搜索框 */}
|
||||
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
||||
<Input
|
||||
prefix={
|
||||
@ -389,118 +435,203 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
}
|
||||
ref={inputRef}
|
||||
placeholder={t('models.search')}
|
||||
value={searchText}
|
||||
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') {
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
|
||||
<Container>
|
||||
{processedItems.length > 0 ? (
|
||||
<StyledMenu
|
||||
items={processedItems}
|
||||
selectedKeys={selectedKeys}
|
||||
mode="inline"
|
||||
inlineIndent={6}
|
||||
onSelect={({ key }) => {
|
||||
setKeyboardSelectedId(key as string)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</EmptyState>
|
||||
)}
|
||||
</Container>
|
||||
</Scrollbar>
|
||||
|
||||
{listItems.length > 0 ? (
|
||||
<ListContainer onMouseMove={() => setIsMouseOver(true)}>
|
||||
{/* Sticky Group Banner,它会替换第一个分组名称 */}
|
||||
<StickyGroupBanner>{currentStickyGroup?.name}</StickyGroupBanner>
|
||||
<FixedSizeList
|
||||
ref={listRef}
|
||||
height={listHeight}
|
||||
width="100%"
|
||||
itemCount={listItems.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemData={RowData}
|
||||
itemKey={(index, data) => data.listItems[index].key}
|
||||
overscanCount={4}
|
||||
onItemsRendered={handleItemsRendered}
|
||||
style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}>
|
||||
{VirtualizedRow}
|
||||
</FixedSizeList>
|
||||
</ListContainer>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</EmptyState>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin-top: 10px;
|
||||
interface VirtualizedRowData {
|
||||
listItems: FlatListItem[]
|
||||
focusedItemKey: string
|
||||
setFocusedItemKey: (key: string) => void
|
||||
currentStickyGroup: 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, currentStickyGroup } = data
|
||||
|
||||
const item = listItems[index]
|
||||
|
||||
if (!item) {
|
||||
return <div style={style} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
{item.type === 'group' ? (
|
||||
<GroupItem $isSticky={item.key === currentStickyGroup?.key}>{item.name}</GroupItem>
|
||||
) : (
|
||||
<ModelItem
|
||||
className={classNames({
|
||||
focused: item.key === focusedItemKey,
|
||||
selected: item.isSelected
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onMouseEnter={() => 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 StyledMenu = styled(Menu)`
|
||||
background-color: transparent;
|
||||
padding: 5px;
|
||||
margin-top: -10px;
|
||||
max-height: calc(60vh - 50px);
|
||||
const GroupItem = styled.div<{ $isSticky?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: ${ITEM_HEIGHT}px;
|
||||
padding: 5px 10px 5px 18px;
|
||||
color: var(--color-text-3);
|
||||
z-index: 1;
|
||||
|
||||
.ant-menu-item-group-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin: 0 -5px;
|
||||
padding: 5px 10px;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
visibility: ${(props) => (props.$isSticky ? 'hidden' : 'visible')};
|
||||
`
|
||||
|
||||
/* Scroll-driven animation for sticky header */
|
||||
animation: background-change linear both;
|
||||
animation-timeline: scroll();
|
||||
animation-range: entry 0% entry 1%;
|
||||
}
|
||||
|
||||
/* Simple animation that changes background color when sticky */
|
||||
@keyframes background-change {
|
||||
to {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background-color: var(--color-background-mute) !important;
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
&:not([data-menu-id^='pinned-']) {
|
||||
.pin-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anticon {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
const StickyGroupBanner = styled(GroupItem)`
|
||||
position: sticky;
|
||||
background: var(--modal-background);
|
||||
`
|
||||
|
||||
const ModelItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
padding: 0 8px;
|
||||
margin: 1px 8px;
|
||||
height: ${ITEM_HEIGHT - 2}px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
|
||||
&.focused {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 13%;
|
||||
width: 3px;
|
||||
height: 74%;
|
||||
background: var(--color-primary-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .pin-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
`
|
||||
|
||||
const ModelNameRow = styled.div`
|
||||
const ModelItemLeft = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding-right: 26px;
|
||||
|
||||
.anticon {
|
||||
min-width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`
|
||||
|
||||
const ModelName = styled.span`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
margin: 0 8px;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
const TagsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 80px;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const EmptyState = styled.div`
|
||||
@ -522,19 +653,19 @@ const SearchIcon = styled.div`
|
||||
margin-right: 2px;
|
||||
`
|
||||
|
||||
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ isPinned: boolean }>`
|
||||
const PinIconWrapper = styled.div.attrs({ className: 'pin-icon' })<{ $isPinned?: boolean }>`
|
||||
margin-left: auto;
|
||||
padding: 0 8px;
|
||||
opacity: ${(props) => (props.isPinned ? 1 : 'inherit')};
|
||||
padding: 0 10px;
|
||||
opacity: ${(props) => (props.$isPinned ? 1 : 'inherit')};
|
||||
transition: opacity 0.2s;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
transform: ${(props) => (props.isPinned ? 'rotate(-45deg)' : 'none')};
|
||||
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')};
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
|
||||
}
|
||||
`
|
||||
|
||||
@ -542,6 +673,7 @@ export default class SelectModelPopup {
|
||||
static hide() {
|
||||
TopView.hide('SelectModelPopup')
|
||||
}
|
||||
|
||||
static show(params: Props) {
|
||||
return new Promise<Model | undefined>((resolve) => {
|
||||
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { createContext, useCallback, useMemo, useState } from 'react'
|
||||
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
QuickPanelCallBackOptions,
|
||||
@ -25,7 +25,14 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
|
||||
const clearTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const open = useCallback((options: QuickPanelOpenOptions) => {
|
||||
if (clearTimer.current) {
|
||||
clearTimeout(clearTimer.current)
|
||||
clearTimer.current = null
|
||||
}
|
||||
|
||||
setTitle(options.title)
|
||||
setList(options.list)
|
||||
setDefaultIndex(options.defaultIndex ?? 0)
|
||||
@ -45,7 +52,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setIsVisible(false)
|
||||
onClose?.({ symbol, action })
|
||||
|
||||
setTimeout(() => {
|
||||
clearTimer.current = setTimeout(() => {
|
||||
setList([])
|
||||
setOnClose(undefined)
|
||||
setBeforeAction(undefined)
|
||||
@ -57,6 +64,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
[onClose, symbol]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearTimer.current) {
|
||||
clearTimeout(clearTimer.current)
|
||||
clearTimer.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
|
||||
@ -7,12 +7,15 @@ import Color from 'color'
|
||||
import { t } from 'i18next'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import { QuickPanelContext } from './provider'
|
||||
import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types'
|
||||
|
||||
const ITEM_HEIGHT = 31
|
||||
|
||||
interface Props {
|
||||
setInputText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
@ -47,19 +50,13 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<FixedSizeList>(null)
|
||||
const footerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollBlock = useRef<ScrollLogicalPosition>('nearest')
|
||||
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 解决长按上下键时滚动太慢问题
|
||||
const keyPressCount = useRef<number>(0)
|
||||
const scrollBehavior = useRef<'auto' | 'smooth'>('smooth')
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
@ -252,17 +249,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible])
|
||||
|
||||
// 处理上下翻时滚动到选中的元素
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const selectedElement = contentRef.current.children[index] as HTMLElement
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: scrollBlock.current,
|
||||
behavior: scrollBehavior.current
|
||||
})
|
||||
scrollBlock.current = 'nearest'
|
||||
if (index >= 0) {
|
||||
listRef.current?.scrollToItem(index, 'auto')
|
||||
}
|
||||
}, [index])
|
||||
|
||||
@ -275,14 +264,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
setIsAssistiveKeyPressed(true)
|
||||
}
|
||||
|
||||
// 处理上下翻页时,滚动太慢问题
|
||||
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
keyPressCount.current++
|
||||
if (keyPressCount.current > 5) {
|
||||
scrollBehavior.current = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Escape'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@ -297,34 +278,29 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
if (isAssistiveKeyPressed) {
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev - ctx.pageSize
|
||||
if (prev === 0) return list.length - 1
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
} else {
|
||||
scrollBlock.current = 'nearest'
|
||||
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
if (isAssistiveKeyPressed) {
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev + ctx.pageSize
|
||||
if (prev + 1 === list.length) return 0
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
} else {
|
||||
scrollBlock.current = 'nearest'
|
||||
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
|
||||
}
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev - ctx.pageSize
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
@ -332,7 +308,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
break
|
||||
|
||||
case 'PageDown':
|
||||
scrollBlock.current = 'start'
|
||||
setIndex((prev) => {
|
||||
const newIndex = prev + ctx.pageSize
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
@ -382,9 +357,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (isMac ? !e.metaKey : !e.ctrlKey) {
|
||||
setIsAssistiveKeyPressed(false)
|
||||
}
|
||||
|
||||
keyPressCount.current = 0
|
||||
scrollBehavior.current = 'smooth'
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@ -421,6 +393,20 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [ctx.isVisible])
|
||||
|
||||
const listHeight = useMemo(() => {
|
||||
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
|
||||
}, [ctx.pageSize, list.length])
|
||||
|
||||
const RowData = useMemo(
|
||||
(): VirtualizedRowData => ({
|
||||
list,
|
||||
focusedIndex: index,
|
||||
handleItemAction,
|
||||
setIndex
|
||||
}),
|
||||
[list, index, handleItemAction, setIndex]
|
||||
)
|
||||
|
||||
return (
|
||||
<QuickPanelContainer
|
||||
$pageSize={ctx.pageSize}
|
||||
@ -428,40 +414,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
$selectedColorHover={selectedColorHover}
|
||||
className={ctx.isVisible ? 'visible' : ''}>
|
||||
<QuickPanelBody ref={bodyRef} onMouseMove={() => setIsMouseOver(true)}>
|
||||
<QuickPanelContent ref={contentRef} $pageSize={ctx.pageSize} $isMouseOver={isMouseOver}>
|
||||
{list.map((item, i) => (
|
||||
<QuickPanelItem
|
||||
className={classNames({
|
||||
focused: i === index,
|
||||
selected: item.isSelected,
|
||||
disabled: item.disabled
|
||||
})}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleItemAction(item, 'click')
|
||||
}}
|
||||
onMouseEnter={() => setIndex(i)}>
|
||||
<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>
|
||||
))}
|
||||
</QuickPanelContent>
|
||||
<FixedSizeList
|
||||
ref={listRef}
|
||||
itemCount={list.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemData={RowData}
|
||||
height={listHeight}
|
||||
width="100%"
|
||||
overscanCount={4}
|
||||
style={{
|
||||
pointerEvents: isMouseOver ? 'auto' : 'none'
|
||||
}}>
|
||||
{VirtualizedRow}
|
||||
</FixedSizeList>
|
||||
<QuickPanelFooter ref={footerRef}>
|
||||
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
|
||||
<QuickPanelFooterTips $footerWidth={footerWidth}>
|
||||
@ -510,6 +475,59 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
)
|
||||
}
|
||||
|
||||
interface VirtualizedRowData {
|
||||
list: QuickPanelListItem[]
|
||||
focusedIndex: number
|
||||
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
|
||||
setIndex: (index: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟化列表行组件,用于避免重新渲染
|
||||
*/
|
||||
const VirtualizedRow = React.memo(
|
||||
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
|
||||
const { list, focusedIndex, handleItemAction, setIndex } = 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')
|
||||
}}
|
||||
onMouseEnter={() => setIndex(index)}>
|
||||
<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
|
||||
@ -533,7 +551,7 @@ const QuickPanelContainer = styled.div<{
|
||||
|
||||
&.visible {
|
||||
pointer-events: auto;
|
||||
max-height: ${(props) => props.$pageSize * 31 + 100}px;
|
||||
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px;
|
||||
}
|
||||
body[theme-mode='dark'] & {
|
||||
--focused-color: rgba(255, 255, 255, 0.1);
|
||||
@ -561,6 +579,10 @@ const QuickPanelBody = styled.div`
|
||||
background-color: rgba(40, 40, 40, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelFooter = styled.div`
|
||||
@ -590,30 +612,17 @@ const QuickPanelFooterTitle = styled.div`
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const QuickPanelContent = styled.div<{ $pageSize: number; $isMouseOver: boolean }>`
|
||||
width: 100%;
|
||||
max-height: ${(props) => props.$pageSize * 31}px;
|
||||
padding: 0 5px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
pointer-events: ${(props) => (props.$isMouseOver ? 'auto' : 'none')};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelItem = styled.div`
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
margin: 0 5px 1px 5px;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
margin-bottom: 1px;
|
||||
font-family: Ubuntu;
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
|
||||
@ -8,7 +8,7 @@ import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
|
||||
import { isNull } from 'lodash'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -160,8 +160,9 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
})
|
||||
}
|
||||
|
||||
const onSelectModel = async () => {
|
||||
const selectedModel = await SelectModelPopup.show({ model: assistant?.model })
|
||||
const onSelectModel = useCallback(async () => {
|
||||
const currentModel = defaultModel ? assistant?.model : undefined
|
||||
const selectedModel = await SelectModelPopup.show({ model: currentModel })
|
||||
if (selectedModel) {
|
||||
setDefaultModel(selectedModel)
|
||||
updateAssistant({
|
||||
@ -170,7 +171,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
defaultModel: selectedModel
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [assistant, defaultModel, updateAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
return () => updateAssistantSettings({ customParameters: customParametersRef.current })
|
||||
|
||||
57
yarn.lock
57
yarn.lock
@ -374,6 +374,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.0.0":
|
||||
version: 7.27.1
|
||||
resolution: "@babel/runtime@npm:7.27.1"
|
||||
checksum: 10c0/530a7332f86ac5a7442250456823a930906911d895c0b743bf1852efc88a20a016ed4cd26d442d0ca40ae6d5448111e02a08dd638a4f1064b47d080e2875dc05
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@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.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @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.7.2, @babel/runtime@npm:^7.9.2":
|
||||
version: 7.27.0
|
||||
resolution: "@babel/runtime@npm:7.27.0"
|
||||
@ -3951,6 +3958,15 @@ __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"
|
||||
@ -4418,6 +4434,7 @@ __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/ws": "npm:^8"
|
||||
"@vitejs/plugin-react-swc": "npm:^3.9.0"
|
||||
@ -4480,7 +4497,7 @@ __metadata:
|
||||
p-queue: "npm:^8.1.0"
|
||||
prettier: "npm:^3.5.3"
|
||||
proxy-agent: "npm:^6.5.0"
|
||||
rc-virtual-list: "npm:^3.18.5"
|
||||
rc-virtual-list: "npm:^3.18.6"
|
||||
react: "npm:^19.0.0"
|
||||
react-dom: "npm:^19.0.0"
|
||||
react-hotkeys-hook: "npm:^4.6.1"
|
||||
@ -4491,6 +4508,7 @@ __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"
|
||||
rehype-katex: "npm:^7.0.1"
|
||||
@ -11530,6 +11548,13 @@ __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"
|
||||
@ -14487,7 +14512,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rc-virtual-list@npm:^3.14.2, rc-virtual-list@npm:^3.18.5, rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2":
|
||||
"rc-virtual-list@npm:^3.14.2, rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2":
|
||||
version: 3.18.5
|
||||
resolution: "rc-virtual-list@npm:3.18.5"
|
||||
dependencies:
|
||||
@ -14502,6 +14527,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rc-virtual-list@npm:^3.18.6":
|
||||
version: 3.18.6
|
||||
resolution: "rc-virtual-list@npm:3.18.6"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.20.0"
|
||||
classnames: "npm:^2.2.6"
|
||||
rc-resize-observer: "npm:^1.0.0"
|
||||
rc-util: "npm:^5.36.0"
|
||||
peerDependencies:
|
||||
react: ">=16.9.0"
|
||||
react-dom: ">=16.9.0"
|
||||
checksum: 10c0/689f22ea64827c65d9fa32e1b6d52affcebdfb556dc27913b9d839600683aca327d2f5a1f18f11d16a2dee9029d991e69d2d3e87652c2c6d3ca804a64a8e61f9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rc@npm:^1.2.7":
|
||||
version: 1.2.8
|
||||
resolution: "rc@npm:1.2.8"
|
||||
@ -14687,6 +14727,19 @@ __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