diff --git a/package.json b/package.json index a7436a1f18..d6b70fb827 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 9721f36126..0662045cb9 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -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); diff --git a/src/renderer/src/components/CustomTag.tsx b/src/renderer/src/components/CustomTag.tsx index 72f637ad05..d16b29c732 100644 --- a/src/renderer/src/components/CustomTag.tsx +++ b/src/renderer/src/components/CustomTag.tsx @@ -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 = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => { - return ( - + const [showTooltip, setShowTooltip] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => setShowTooltip(true), 300) + return () => clearTimeout(timer) + }, []) + + const tagContent = useMemo( + () => ( {icon && icon} {children} {closable && } + ), + [children, color, closable, icon, onClose, size] + ) + + return tooltip && showTooltip ? ( + + {tagContent} + ) : ( + tagContent ) } -export default CustomTag +export default memo(CustomTag) const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>` display: inline-flex; diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index b6cfa1a7cf..9e5feb45b0 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -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 = ({ style }) => { const { t } = useTranslation() - const [_showLabel, _setShowLabel] = useState(showLabel) + const [shouldShowLabel, setShouldShowLabel] = useState(false) const containerRef = useRef(null) - const resizeObserver = useRef(null) + const resizeObserver = useRef(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 ( @@ -73,7 +74,7 @@ const ModelTagsWithLabel: FC = ({ color="#00b96b" icon={} tooltip={t('models.type.vision')}> - {_showLabel ? t('models.type.vision') : ''} + {shouldShowLabel ? t('models.type.vision') : ''} )} {isWebSearchModel(model) && ( @@ -82,7 +83,7 @@ const ModelTagsWithLabel: FC = ({ color="#1677ff" icon={} tooltip={t('models.type.websearch')}> - {_showLabel ? t('models.type.websearch') : ''} + {shouldShowLabel ? t('models.type.websearch') : ''} )} {showReasoning && isReasoningModel(model) && ( @@ -91,7 +92,7 @@ const ModelTagsWithLabel: FC = ({ color="#6372bd" icon={} tooltip={t('models.type.reasoning')}> - {_showLabel ? t('models.type.reasoning') : ''} + {shouldShowLabel ? t('models.type.reasoning') : ''} )} {showToolsCalling && isFunctionCallingModel(model) && ( @@ -100,7 +101,7 @@ const ModelTagsWithLabel: FC = ({ color="#f18737" icon={} tooltip={t('models.type.function_calling')}> - {_showLabel ? t('models.type.function_calling') : ''} + {shouldShowLabel ? t('models.type.function_calling') : ''} )} {isEmbeddingModel(model) && ( @@ -128,4 +129,4 @@ const Container = styled.div` } ` -export default ModelTagsWithLabel +export default memo(ModelTagsWithLabel) diff --git a/src/renderer/src/components/Popups/SelectModelPopup.tsx b/src/renderer/src/components/Popups/SelectModelPopup.tsx index 424e18a267..c883e02ffa 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup.tsx @@ -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['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 = ({ model, resolve }) => { - const [open, setOpen] = useState(true) const { t } = useTranslation() - const [searchText, setSearchText] = useState('') - const inputRef = useRef(null) const { providers } = useProviders() + const [open, setOpen] = useState(true) + const inputRef = useRef(null) + const listRef = useRef(null) + const [_searchText, setSearchText] = useState('') + const searchText = useDeferredValue(_searchText) + const [isMouseOver, setIsMouseOver] = useState(false) const [pinnedModels, setPinnedModels] = useState([]) - const scrollContainerRef = useRef(null) - const [keyboardSelectedId, setKeyboardSelectedId] = useState('') - const menuItemRefs = useRef>({}) + const [_focusedItemKey, setFocusedItemKey] = useState('') + const focusedItemKey = useDeferredValue(_focusedItemKey) + const [currentStickyGroup, setCurrentStickyGroup] = useState(null) + const firstGroupRef = useRef(null) + const scrollTriggerRef = useRef('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 = ({ 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 = ({ model, resolve }) => { [searchText, t, pinnedModels] ) - // 递归处理菜单项,为每个项添加ref - const processMenuItems = useCallback( - (items: MenuItem[]) => { - // 内部定义 renderMenuItem 函数 - const renderMenuItem = (item: any) => { - return { - ...item, - label:
{item.label}
- } - } + // 创建模型列表项 + 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: ( + + {model.name} + {isPinned && | {groupName}} + + ), + tags: ( + + + + ), + icon: ( + + {first(model.name) || 'M'} + + ), + 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: ( - - - {m?.name} - - { - e.stopPropagation() - togglePin(getModelUniqId(m)) - }} - isPinned={pinnedModels.includes(getModelUniqId(m))}> - - - - ), - icon: ( - - {first(m?.name)} - - ), - 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: ( - - - - {m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name} - {' '} - - - { - e.stopPropagation() - togglePin(getModelUniqId(m.model)) - }} - isPinned={true}> - - - - ), - icon: ( - - {first(m.model?.name)} - - ), - 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 ( = ({ model, resolve }) => { }} closeIcon={null} footer={null}> + {/* 搜索框 */} = ({ 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() } }} /> - - - {processedItems.length > 0 ? ( - { - setKeyboardSelectedId(key as string) - }} - /> - ) : ( - - - - )} - - + + {listItems.length > 0 ? ( + setIsMouseOver(true)}> + {/* Sticky Group Banner,它会替换第一个分组名称 */} + {currentStickyGroup?.name} + data.listItems[index].key} + overscanCount={4} + onItemsRendered={handleItemsRendered} + style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}> + {VirtualizedRow} + + + ) : ( + + + + )} ) } -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
+ } + + return ( +
+ {item.type === 'group' ? ( + {item.name} + ) : ( + handleItemClick(item)} + onMouseEnter={() => setFocusedItemKey(item.key)}> + + {item.icon} + {item.name} + {item.tags} + + { + e.stopPropagation() + if (item.model) { + togglePin(getModelUniqId(item.model)) + } + }} + data-pinned={item.isPinned} + $isPinned={item.isPinned}> + + + + )} +
+ ) + } +) + +VirtualizedRow.displayName = 'VirtualizedRow' + +const ListContainer = styled.div` + position: relative; + overflow: hidden; ` -const 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((resolve) => { TopView.show(, 'SelectModelPopup') diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx index 4dd588e09d..fc73bc3418 100644 --- a/src/renderer/src/components/QuickPanel/provider.tsx +++ b/src/renderer/src/components/QuickPanel/provider.tsx @@ -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 = ({ children const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() + const clearTimer = useRef(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 = ({ children setIsVisible(false) onClose?.({ symbol, action }) - setTimeout(() => { + clearTimer.current = setTimeout(() => { setList([]) setOnClose(undefined) setBeforeAction(undefined) @@ -57,6 +64,15 @@ export const QuickPanelProvider: React.FC = ({ children [onClose, symbol] ) + useEffect(() => { + return () => { + if (clearTimer.current) { + clearTimeout(clearTimer.current) + clearTimer.current = null + } + } + }, []) + const value = useMemo( () => ({ open, diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index eed4a881bc..2bd1b14349 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -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> } @@ -47,19 +50,13 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const [historyPanel, setHistoryPanel] = useState([]) const bodyRef = useRef(null) - const contentRef = useRef(null) + const listRef = useRef(null) const footerRef = useRef(null) - const scrollBlock = useRef('nearest') - const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) const searchTextRef = useRef('') - // 解决长按上下键时滚动太慢问题 - const keyPressCount = useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ( = ({ setInputText }) => { $selectedColorHover={selectedColorHover} className={ctx.isVisible ? 'visible' : ''}> setIsMouseOver(true)}> - - {list.map((item, i) => ( - { - e.stopPropagation() - handleItemAction(item, 'click') - }} - onMouseEnter={() => setIndex(i)}> - - {item.icon} - {item.label} - - - - {item.description && {item.description}} - - {item.suffix ? ( - item.suffix - ) : item.isSelected ? ( - - ) : ( - item.isMenu && !item.disabled && - )} - - - - ))} - + + {VirtualizedRow} + {ctx.title || ''} @@ -510,6 +475,59 @@ export const QuickPanelView: React.FC = ({ 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 ( +
+ { + e.stopPropagation() + handleItemAction(item, 'click') + }} + onMouseEnter={() => setIndex(index)}> + + {item.icon} + {item.label} + + + + {item.description && {item.description}} + + {item.suffix ? ( + item.suffix + ) : item.isSelected ? ( + + ) : ( + item.isMenu && !item.disabled && + )} + + + +
+ ) + } +) + const QuickPanelContainer = styled.div<{ $pageSize: number $selectedColor: string @@ -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); diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 24b6025a12..31075ebbb7 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -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 = ({ 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 = ({ assistant, updateAssistant, updateA defaultModel: selectedModel }) } - } + }, [assistant, defaultModel, updateAssistant]) useEffect(() => { return () => updateAssistantSettings({ customParameters: customParametersRef.current }) diff --git a/yarn.lock b/yarn.lock index 0cf53dba53..400ed47dcc 100644 --- a/yarn.lock +++ b/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"