From fc2ff69bdbc9e412fea5fabcb86b6f0f2eb3d3e5 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sat, 16 May 2026 02:43:33 +0800 Subject: [PATCH] fix(proxy): stabilize proxy group sticky scroll #6982 Close #6982 --- Changelog.md | 2 +- src/components/proxy/proxy-groups.tsx | 197 +++++++++++++------------- 2 files changed, 100 insertions(+), 99 deletions(-) diff --git a/Changelog.md b/Changelog.md index feac56b1a..cc8db4f23 100644 --- a/Changelog.md +++ b/Changelog.md @@ -32,4 +32,4 @@ ### 👙 界面样式 -- 代理组实现sticky scroll的效果 +- 代理组实现 Sticky Scroll 效果 diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index d40f2d46b..1db0d7cd1 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -9,8 +9,9 @@ import { Snackbar, Typography, } from '@mui/material' +import { useTheme } from '@mui/material/styles' import { useQuery } from '@tanstack/react-query' -import { useVirtualizer } from '@tanstack/react-virtual' +import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' import { useLockFn } from 'ahooks' import { type Key, @@ -18,11 +19,13 @@ import { type RefObject, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router' import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api' import { BaseEmpty } from '@/components/base' @@ -65,6 +68,7 @@ interface ProxyChainItem { export const ProxyGroups = (props: Props) => { const { t } = useTranslation() + const { pathname } = useLocation() const { mode, isChainMode = false, chainConfigData } = props // Drive 3s polling on the shared TQ cache; data is read via granular context below @@ -158,8 +162,40 @@ export const ProxyGroups = (props: Props) => { const parentRef = useRef(null) const scrollPositionRef = useRef>({}) + const scrollTopRef = useRef(0) const showScrollTopRef = useRef(false) + const activeStickyIndexRef = useRef(null) + const restoredScrollKeyRef = useRef(null) const [showScrollTop, setShowScrollTop] = useState(false) + const scrollPositionKey = useMemo( + () => + isChainMode + ? `${mode}:chain:${activeSelectedGroup ?? 'all'}` + : `${mode}:normal`, + [activeSelectedGroup, isChainMode, mode], + ) + const stickyGroupIndexes = useMemo( + () => + renderList.flatMap((item, index) => + item.type === 0 && !item.group.hidden ? [index] : [], + ), + [renderList], + ) + + const rangeExtractor = useCallback( + (range: Parameters[0]) => { + const activeStickyIndex = [...stickyGroupIndexes] + .reverse() + .find((index) => index <= range.startIndex) + activeStickyIndexRef.current = activeStickyIndex ?? null + + const indexes = defaultRangeExtractor(range) + return activeStickyIndex == null || indexes.includes(activeStickyIndex) + ? indexes + : [activeStickyIndex, ...indexes] + }, + [stickyGroupIndexes], + ) const virtualizer = useVirtualizer({ count: renderList.length, @@ -167,58 +203,49 @@ export const ProxyGroups = (props: Props) => { estimateSize: () => 56, overscan: 15, getItemKey: (index) => renderList[index]?.key ?? index, + rangeExtractor, }) const virtualItems = virtualizer.getVirtualItems() - const stickyGroupByIndex = useMemo( - () => getStickyGroupByIndex(renderList), - [renderList], - ) - const stickyGroupItem = getStickyGroupItem( - stickyGroupByIndex, - virtualItems, - virtualizer.scrollOffset ?? 0, - ) + const activeStickyIndex = activeStickyIndexRef.current // 从 localStorage 恢复滚动位置 - useEffect(() => { + useLayoutEffect(() => { if (renderList.length === 0) return - - let restoreTimer: ReturnType | null = null + const node = parentRef.current + if (!node) return + if ( + restoredScrollKeyRef.current === scrollPositionKey && + node.scrollTop === scrollTopRef.current + ) { + return + } try { const savedPositions = localStorage.getItem('proxy-scroll-positions') if (savedPositions) { const positions = JSON.parse(savedPositions) scrollPositionRef.current = positions - const savedPosition = positions[mode] + const savedPosition = positions[scrollPositionKey] if (savedPosition !== undefined) { - restoreTimer = setTimeout(() => { - if (parentRef.current) { - parentRef.current.scrollTop = savedPosition - const nextShowScrollTop = savedPosition > 100 - showScrollTopRef.current = nextShowScrollTop - setShowScrollTop(nextShowScrollTop) - } - }, 100) + node.scrollTop = savedPosition + scrollTopRef.current = savedPosition + const nextShowScrollTop = savedPosition > 100 + showScrollTopRef.current = nextShowScrollTop + queueMicrotask(() => setShowScrollTop(nextShowScrollTop)) } } } catch (e) { console.error('Error restoring scroll position:', e) } - - return () => { - if (restoreTimer) { - clearTimeout(restoreTimer) - } - } - }, [mode, renderList.length]) + restoredScrollKeyRef.current = scrollPositionKey + }, [pathname, renderList.length, scrollPositionKey]) // 改为使用节流函数保存滚动位置 const saveScrollPosition = useCallback( (scrollTop: number) => { try { - scrollPositionRef.current[mode] = scrollTop + scrollPositionRef.current[scrollPositionKey] = scrollTop localStorage.setItem( 'proxy-scroll-positions', JSON.stringify(scrollPositionRef.current), @@ -227,7 +254,7 @@ export const ProxyGroups = (props: Props) => { console.error('Error saving scroll position:', e) } }, - [mode], + [scrollPositionKey], ) const saveScrollPositionThrottled = useMemo( @@ -240,6 +267,7 @@ export const ProxyGroups = (props: Props) => { const target = event.target as HTMLElement | null const nextScrollTop = target?.scrollTop ?? 0 const nextShowScrollTop = nextScrollTop > 100 + scrollTopRef.current = nextScrollTop if (showScrollTopRef.current !== nextShowScrollTop) { showScrollTopRef.current = nextShowScrollTop @@ -262,9 +290,12 @@ export const ProxyGroups = (props: Props) => { node.addEventListener('scroll', listener, options) return () => { + if (restoredScrollKeyRef.current === scrollPositionKey) { + saveScrollPosition(scrollTopRef.current) + } node.removeEventListener('scroll', listener, options) } - }, [handleScroll]) + }, [handleScroll, saveScrollPosition, scrollPositionKey]) // 滚动到顶部 const scrollToTop = useCallback(() => { @@ -272,6 +303,7 @@ export const ProxyGroups = (props: Props) => { top: 0, behavior: 'smooth', }) + scrollTopRef.current = 0 saveScrollPosition(0) }, [saveScrollPosition]) @@ -453,7 +485,7 @@ export const ProxyGroups = (props: Props) => { totalSize={virtualizer.getTotalSize()} virtualItems={virtualItems} renderList={renderList} - stickyItem={stickyGroupItem} + activeStickyIndex={activeStickyIndex} indent={mode === 'rule' || mode === 'script'} isChainMode={isChainMode} measureElement={virtualizer.measureElement} @@ -564,7 +596,7 @@ interface ProxyVirtualListProps { totalSize: number virtualItems: VirtualListItem[] renderList: IRenderItem[] - stickyItem: IRenderItem | null + activeStickyIndex: number | null indent: boolean isChainMode?: boolean measureElement: (node: Element | null) => void @@ -728,7 +760,7 @@ function ProxyVirtualList({ totalSize, virtualItems, renderList, - stickyItem, + activeStickyIndex, indent, isChainMode, measureElement, @@ -737,39 +769,12 @@ function ProxyVirtualList({ onHeadState, onChangeProxy, }: ProxyVirtualListProps) { + const theme = useTheme() + const stickyBackground = + theme.palette.mode === 'dark' ? '#1e1f27' : 'var(--background-color)' + return (
- {stickyItem && ( - - - - - - )} -
{virtualItems.map((virtualItem) => (
{ - if (item?.type === 0 && item.group && !item.group.hidden) { - stickyGroup = item - } - return stickyGroup - }) -} - -function getStickyGroupItem( - stickyGroupByIndex: (IRenderItem | null)[], - virtualItems: VirtualListItem[], - scrollTop: number, -) { - const firstVisibleItem = - virtualItems.find((item) => item.end > scrollTop + 1) ?? virtualItems[0] - - if (!firstVisibleItem) return null - - return stickyGroupByIndex[firstVisibleItem.index] ?? null -} - // 替换简单防抖函数为更优的节流函数 function throttle any>( func: T, @@ -832,23 +826,30 @@ function throttle any>( ): (...args: Parameters) => void { let timer: ReturnType | null = null let previous = 0 + let lastArgs: Parameters | null = null + + const run = (args: Parameters) => { + previous = Date.now() + timer = null + lastArgs = null + func(...args) + } return function (...args: Parameters) { const now = Date.now() const remaining = wait - (now - previous) + lastArgs = args if (remaining <= 0 || remaining > wait) { if (timer) { clearTimeout(timer) - timer = null } - previous = now - func(...args) + run(args) } else if (!timer) { timer = setTimeout(() => { - previous = Date.now() - timer = null - func(...args) + if (lastArgs) { + run(lastArgs) + } }, remaining) } }