From 65ed8d3cf2d754fafd994655f4d88a380f025bb9 Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 18 Sep 2026 00:26:04 +0800 Subject: [PATCH] fix(home): stabilize proxy group and node dropdowns --- Changelog.md | 1 + src/components/home/current-proxy-card.tsx | 407 ++++++++++++--------- 2 files changed, 235 insertions(+), 173 deletions(-) diff --git a/Changelog.md b/Changelog.md index d438c5bb6..bc9942156 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,7 @@
🐞 修复问题 +- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开 - 修复系统代理切换失败后,开关状态显示错误的问题 - 修复代理守卫失败后反复重试且没有提示的问题 - 修复未开启系统代理时,启动仍提示代理错误的问题 diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 7bf922174..9aa459600 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -20,13 +20,12 @@ import { FormControl, IconButton, InputLabel, - type MenuProps, MenuItem, MenuList, Paper, Popper, + type PopperProps, Select, - type SelectChangeEvent, Tooltip, Typography, alpha, @@ -68,6 +67,40 @@ const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type' const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5 const AUTO_CHECK_INITIAL_DELAY_MS = 100 const PROXY_MENU_MAX_HEIGHT = 500 +const PROXY_MENU_VIEWPORT_MARGIN = 8 + +const proxyMenuModifiers: PopperProps['modifiers'] = [ + { + name: 'menuSize', + enabled: true, + phase: 'beforeRead', + fn: ({ state }) => { + const popper = state.elements.popper + const anchor = state.elements.reference.getBoundingClientRect() + const viewportHeight = popper.ownerDocument.documentElement.clientHeight + // Use the larger side before flip runs so sizing cannot alternate with placement. + const availableHeight = + Math.max(anchor.top, viewportHeight - anchor.bottom) - + PROXY_MENU_VIEWPORT_MARGIN + popper.style.width = `${anchor.width}px` + popper.style.setProperty( + '--proxy-menu-max-height', + `${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`, + ) + state.rects.popper.width = popper.offsetWidth + state.rects.popper.height = popper.offsetHeight + }, + }, + { + name: 'showMenu', + enabled: true, + phase: 'beforeWrite', + requires: ['computeStyles'], + fn: ({ state }) => { + state.styles.popper.visibility = 'visible' + }, + }, +] interface ProxyOption { memberIndex: number @@ -163,34 +196,6 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) => member.kind === 'node' ? member.node.recordId : member.ref.name }` -const hasPointerCoordinates = ( - value: unknown, -): value is { clientX: number; clientY: number } => - typeof value === 'object' && - value !== null && - 'clientX' in value && - typeof value.clientX === 'number' && - 'clientY' in value && - typeof value.clientY === 'number' - -const eventHitsElement = (event: unknown, element: HTMLElement | null) => { - if (!element) return false - - const nativeEvent = - typeof event === 'object' && event !== null && 'nativeEvent' in event - ? event.nativeEvent - : event - if (!hasPointerCoordinates(nativeEvent)) return false - - const rect = element.getBoundingClientRect() - return ( - nativeEvent.clientX >= rect.left && - nativeEvent.clientX <= rect.right && - nativeEvent.clientY >= rect.top && - nativeEvent.clientY <= rect.bottom - ) -} - const sortProxyOptions = ( options: ProxyOption[], sortType: ProxySortType, @@ -216,41 +221,62 @@ const sortProxyOptions = ( .map(({ option }) => option) } -interface PersistentProxySelectProps { +interface PersistentSelectProps { label: string - groupName: string - fixed?: string + labelId: string + listboxId: string value: string selectedName: string - selectedDelay: number - options: ProxyOption[] open: boolean disabled: boolean keepOpenRef: React.RefObject onOpen: () => void onClose: () => void - onChange: (value: string) => void + renderValue?: () => React.ReactNode + children: React.ReactNode } -const PersistentProxySelect = ({ +const PersistentSelect = ({ label, - groupName, - fixed, + labelId, + listboxId, value, selectedName, - selectedDelay, - options, open, disabled, keepOpenRef, onOpen, onClose, - onChange, -}: PersistentProxySelectProps) => { + renderValue, + children, +}: PersistentSelectProps) => { const anchorRef = useRef(null) - const listboxId = 'current-proxy-node-listbox' - const labelId = 'proxy-select-label' - const fixedProxyInUsed = selectedName === fixed + const listRef = useRef(null) + + const popperOptions = useMemo( + () => ({ + onFirstUpdate: () => { + const list = listRef.current + const paper = list?.parentElement + const item = + list?.querySelector( + '[aria-selected="true"]:not([aria-disabled="true"])', + ) ?? + list?.querySelector( + '[role="option"]:not([aria-disabled="true"])', + ) + if (!item || !paper) return + + // Focusing before positioning can scroll the page instead of just the menu. + item.focus({ preventScroll: true }) + paper.scrollTop += + item.getBoundingClientRect().top - + paper.getBoundingClientRect().top - + (paper.clientHeight - item.offsetHeight) / 2 + }, + }), + [], + ) useEffect(() => { if (!open) return @@ -287,37 +313,13 @@ const PersistentProxySelect = ({ labelId={labelId} value={value} open={false} - onOpen={onOpen} + onOpen={(event) => { + if (open && event.type === 'mousedown') onClose() + else onOpen() + }} label={label} disabled={disabled} - renderValue={() => ( - - {selectedName} - - {fixedProxyInUsed && ( - - 📌 - - )} - - )} + renderValue={renderValue} SelectDisplayProps={{ 'aria-controls': open ? listboxId : undefined, 'aria-expanded': open ? 'true' : 'false', @@ -331,80 +333,28 @@ const PersistentProxySelect = ({ open={open} anchorEl={anchorRef.current} placement="bottom-start" + modifiers={proxyMenuModifiers} + popperOptions={popperOptions} sx={{ - width: anchorRef.current?.clientWidth, + visibility: 'hidden', zIndex: (theme) => theme.zIndex.modal, }} > - {options.map((option) => { - const selected = option.value === value - const isFixed = option.name === fixed - const delay = option.disabled - ? -1 - : delayManager.getDelayFix(option.member, groupName) - - return ( - { - if (!option.disabled) onChange(option.value) - }} - sx={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - pr: 1, - }} - > - {isFixed && ( - - 📌 - - )} - - {option.name} - - {!option.disabled && ( - - )} - - ) - })} + {children} @@ -413,6 +363,128 @@ const PersistentProxySelect = ({ ) } +interface PersistentProxySelectProps + extends Omit< + PersistentSelectProps, + 'labelId' | 'listboxId' | 'renderValue' | 'children' + > { + groupName: string + fixed?: string + selectedDelay: number + options: ProxyOption[] + onChange: (value: string) => void +} + +const PersistentProxySelect = ({ + groupName, + fixed, + selectedName, + selectedDelay, + options, + value, + onChange, + ...props +}: PersistentProxySelectProps) => { + const fixedProxyInUsed = selectedName === fixed + + return ( + ( + + {selectedName} + + {fixedProxyInUsed && ( + + 📌 + + )} + + )} + > + {options.map((option) => { + const selected = option.value === value + const isFixed = option.name === fixed + const delay = option.disabled + ? -1 + : delayManager.getDelayFix(option.member, groupName) + + return ( + { + if (!option.disabled) onChange(option.value) + }} + sx={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + pr: 1, + }} + > + {isFixed && ( + + 📌 + + )} + + {option.name} + + {!option.disabled && ( + + )} + + ) + })} + + ) +} + export const CurrentProxyCard = () => { const { t } = useTranslation() const navigate = useNavigate() @@ -497,6 +569,11 @@ export const CurrentProxyCard = () => { const [selectedGroupName, setSelectedGroupName] = useState('') const [openSelect, setOpenSelect] = useState(null) + + useEffect(() => { + setOpenSelect(null) + }, [mode]) + const delayButtonRef = useRef(null) const delays = useGroupDelays(selectedGroupName || null) @@ -621,9 +698,8 @@ export const CurrentProxyCard = () => { latestProxyMemberRef.current = currentOption?.member ?? null const handleGroupChange = useCallback( - (event: SelectChangeEvent) => { + (newGroupName: string) => { if (isGlobalMode || isDirectMode) return - const newGroupName = event.target.value setSelectedGroupName(newGroupName) writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName) }, @@ -785,21 +861,6 @@ export const CurrentProxyCard = () => { refreshProxy() }) - const handleGroupMenuClose = useCallback>( - (event, reason) => { - if ( - reason === 'backdropClick' && - eventHitsElement(event, delayButtonRef.current) - ) { - void handleCheckDelay() - return - } - - setOpenSelect(null) - }, - [handleCheckDelay], - ) - const proxyOptions = useMemo( () => isDirectMode @@ -975,32 +1036,32 @@ export const CurrentProxyCard = () => { /> )} - - - {t('home.components.currentProxy.labels.group')} - - - + +