feat: Enhanced the experience of the model selection popup (#3407)

* feat: enhance SelectModelPopup with menu item refs and layout effect

- Added useLayoutEffect to manage scrolling behavior for keyboard navigation.
- Introduced a mechanism to assign refs to menu items for improved accessibility.
- Refactored menu item processing to support recursive rendering with refs.

* feat: update SelectModelPopup to filter out pinned models when not in search mode

- Added logic to filter out pinned models when the popup is not in search state.
- Updated dependencies in useMemo to include pinnedModels for accurate filtering.

* refactor: update SelectModelPopup to clarify model selection logic

* refactor: enhance scrolling behavior in SelectModelPopup for keyboard navigation

- Added logic to scroll to the top of the container if the first model is selected.
- Updated dependencies in useLayoutEffect to include getVisibleModelItems for accurate scrolling behavior.

* refactor: improve scrolling logic in SelectModelPopup for better keyboard navigation

- Enhanced the scrolling behavior to account for group titles when navigating with the keyboard.
- Removed dependency on getVisibleModelItems in useLayoutEffect for a more streamlined effect.
This commit is contained in:
Asurada 2025-03-16 20:19:03 +08:00 committed by GitHub
parent e5f2fab43c
commit 8c5273d47d
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 { Model } from '@renderer/types'
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd' import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
import { first, sortBy } from 'lodash' import { first, sortBy } from 'lodash'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -34,6 +34,16 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const [pinnedModels, setPinnedModels] = useState<string[]>([]) const [pinnedModels, setPinnedModels] = useState<string[]>([])
const scrollContainerRef = useRef<HTMLDivElement>(null) const scrollContainerRef = useRef<HTMLDivElement>(null)
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('') const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
const menuItemRefs = useRef<Record<string, HTMLElement | null>>({})
const setMenuItemRef = useCallback(
(key: string) => (el: HTMLElement | null) => {
if (el) {
menuItemRefs.current[key] = el
}
},
[]
)
useEffect(() => { useEffect(() => {
const loadPinnedModels = async () => { const loadPinnedModels = async () => {
@ -78,11 +88,38 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const lowerFullName = fullName.toLowerCase() const lowerFullName = fullName.toLowerCase()
return keywords.every((keyword) => lowerFullName.includes(keyword)) return keywords.every((keyword) => lowerFullName.includes(keyword))
}) })
} else {
// 如果不是搜索状态,过滤掉已固定的模型
models = models.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
} }
return sortBy(models, ['group', 'name']) return sortBy(models, ['group', 'name'])
}, },
[searchText, t] [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>
}
}
return items.map((item) => {
if (item && 'children' in item && item.children) {
return {
...item,
children: (item.children as MenuItem[]).map(renderMenuItem)
}
}
return item
})
},
[setMenuItemRef]
) )
const filteredItems: MenuItem[] = providers const filteredItems: MenuItem[] = providers
@ -180,6 +217,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
} }
} }
// 处理菜单项添加ref
const processedItems = processMenuItems(filteredItems)
const onCancel = () => { const onCancel = () => {
setKeyboardSelectedId('') setKeyboardSelectedId('')
setOpen(false) setOpen(false)
@ -198,9 +238,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
useEffect(() => { useEffect(() => {
if (open && model) { if (open && model) {
setTimeout(() => { setTimeout(() => {
const selectedElement = document.querySelector('.ant-menu-item-selected') const modelId = getModelUniqId(model)
if (selectedElement && scrollContainerRef.current) { if (menuItemRefs.current[modelId]) {
selectedElement.scrollIntoView({ block: 'center', behavior: 'auto' }) menuItemRefs.current[modelId]?.scrollIntoView({ block: 'center', behavior: 'auto' })
} }
}, 100) // Small delay to ensure menu is rendered }, 100) // Small delay to ensure menu is rendered
} }
@ -224,10 +264,12 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
getFilteredModels(p).forEach((m) => { getFilteredModels(p).forEach((m) => {
const modelId = getModelUniqId(m) const modelId = getModelUniqId(m)
const isPinned = pinnedModels.includes(modelId) const isPinned = pinnedModels.includes(modelId)
// 如果是搜索状态,或者不是固定模型,才添加到列表中
// 搜索状态下,所有匹配的模型都应该可以被选中,包括固定的模型
// 非搜索状态下,只添加非固定模型(固定模型已在上面添加)
if (searchText.length > 0 || !isPinned) { if (searchText.length > 0 || !isPinned) {
items.push({ items.push({
key: isPinned ? modelId + '_pinned' : modelId, key: modelId,
model: m model: m
}) })
} }
@ -238,6 +280,40 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
return items return items
}, [pinnedModels, searchText, providers, getFilteredModels]) }, [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'
})
}
}
}, [open, keyboardSelectedId])
// 处理键盘导航 // 处理键盘导航
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@ -258,9 +334,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const nextItem = items[nextIndex] const nextItem = items[nextIndex]
setKeyboardSelectedId(nextItem.key) setKeyboardSelectedId(nextItem.key)
const element = document.querySelector(`[data-menu-id="${nextItem.key}"]`)
element?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() // 阻止回车的默认行为 e.preventDefault() // 阻止回车的默认行为
if (keyboardSelectedId) { if (keyboardSelectedId) {
@ -332,8 +405,16 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} /> <Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}> <Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
<Container> <Container>
{filteredItems.length > 0 ? ( {processedItems.length > 0 ? (
<StyledMenu items={filteredItems} selectedKeys={selectedKeys} mode="inline" inlineIndent={6} /> <StyledMenu
items={processedItems}
selectedKeys={selectedKeys}
mode="inline"
inlineIndent={6}
onSelect={({ key }) => {
setKeyboardSelectedId(key as string)
}}
/>
) : ( ) : (
<EmptyState> <EmptyState>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />