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:
parent
e5f2fab43c
commit
8c5273d47d
@ -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} />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user