feat: 为模型选择弹窗添加键盘导航功能 (#2057)

* feat: 为模型选择弹窗添加键盘导航功能

* fix(SelectModelPopup): 改进键盘导航和项目筛选
This commit is contained in:
CherryLover 2025-02-27 16:58:11 +08:00 committed by GitHub
parent d51da99b8f
commit c0117c25ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -7,7 +7,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
import { first, sortBy } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -33,6 +33,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const { providers } = useProviders()
const [pinnedModels, setPinnedModels] = useState<string[]>([])
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
useEffect(() => {
const loadPinnedModels = async () => {
@ -153,10 +154,12 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
}
const onCancel = () => {
setKeyboardSelectedId('')
setOpen(false)
}
const onClose = async () => {
setKeyboardSelectedId('')
resolve(undefined)
SelectModelPopup.hide()
}
@ -176,6 +179,90 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
}
}, [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 }))
}
// 添加其他过滤后的模型
providers.forEach((p) => {
if (p.models) {
sortBy(p.models, ['group', 'name'])
.filter((m) => !isEmbeddingModel(m))
.filter((m) =>
[m.name + m.provider + t('provider.' + p.id)].join('').toLowerCase().includes(searchText.toLowerCase())
)
.forEach((m) => {
const modelId = getModelUniqId(m)
const isPinned = pinnedModels.includes(modelId)
// 如果是搜索状态,或者不是固定模型,才添加到列表中
if (searchText.length > 0 || !isPinned) {
items.push({
key: isPinned ? modelId + '_pinned' : modelId,
model: m
})
}
})
}
})
return items
}, [pinnedModels, searchText, providers, t])
// 处理键盘导航
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)
const element = document.querySelector(`[data-menu-id="${nextItem.key}"]`)
element?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
} 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]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
// 搜索文本改变时重置键盘选中状态
useEffect(() => {
setKeyboardSelectedId('')
}, [searchText])
return (
<Modal
centered
@ -210,18 +297,19 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
style={{ paddingLeft: 0 }}
bordered={false}
size="middle"
onKeyDown={(e) => {
// 防止上下键移动光标
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
}
}}
/>
</HStack>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
<Container>
{filteredItems.length > 0 ? (
<StyledMenu
items={filteredItems}
selectedKeys={model ? [getModelUniqId(model)] : []}
mode="inline"
inlineIndent={6}
/>
<StyledMenu items={filteredItems} selectedKeys={[keyboardSelectedId]} mode="inline" inlineIndent={6} />
) : (
<EmptyState>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />