diff --git a/package.json b/package.json index 2d322e6c2..7bf44f760 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "react-i18next": "17.0.2", "react-markdown": "10.1.0", "react-router": "^7.13.1", - "react-virtuoso": "^4.18.3", "rehype-raw": "^7.0.0", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert", "types-pac": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 381dfbb65..3b94b6b3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,9 +131,6 @@ importers: react-router: specifier: ^7.13.1 version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react-virtuoso: - specifier: ^4.18.3 - version: 4.18.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -3169,12 +3166,6 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' - react-virtuoso@4.18.3: - resolution: {integrity: sha512-fLz/peHAx4Eu0DLHurFEEI7Y6n5CqEoxBh04rgJM9yMuOJah2a9zWg/MUOmZLcp7zuWYorXq5+5bf3IRgkNvWg==} - peerDependencies: - react: '>=16 || >=17 || >= 18 || >= 19' - react-dom: '>=16 || >=17 || >= 18 || >=19' - react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -6878,11 +6869,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-virtuoso@4.18.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react@19.2.4: {} readdirp@4.1.2: {} diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 054f05f68..950773a5f 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -14,3 +14,4 @@ export { BaseStyledSelect } from './base-styled-select' export { BaseStyledTextField } from './base-styled-text-field' export { Switch } from './base-switch' export { TooltipIcon } from './base-tooltip-icon' +export { VirtualList, type VirtualListHandle } from './virtual-list' diff --git a/src/components/base/virtual-list.tsx b/src/components/base/virtual-list.tsx new file mode 100644 index 000000000..e4c04bb66 --- /dev/null +++ b/src/components/base/virtual-list.tsx @@ -0,0 +1,97 @@ +import { useVirtualizer } from '@tanstack/react-virtual' +import { + CSSProperties, + forwardRef, + ReactNode, + useEffect, + useImperativeHandle, + useRef, +} from 'react' + +export interface VirtualListHandle { + scrollToIndex: ( + index: number, + options?: { + align?: 'start' | 'center' | 'end' | 'auto' + behavior?: ScrollBehavior + }, + ) => void + scrollTo: (options: ScrollToOptions) => void +} + +interface VirtualListProps { + count: number + estimateSize: number + overscan?: number + getItemKey?: (index: number) => React.Key + renderItem: (index: number) => ReactNode + style?: CSSProperties + footer?: number + onScroll?: (e: Event) => void +} + +export const VirtualList = forwardRef( + ( + { + count, + estimateSize, + overscan = 5, + getItemKey, + renderItem, + style, + footer, + onScroll, + }, + ref, + ) => { + const parentRef = useRef(null) + const virtualizer = useVirtualizer({ + count, + getScrollElement: () => parentRef.current, + estimateSize: () => estimateSize, + overscan, + getItemKey, + }) + + useEffect(() => { + const el = parentRef.current + if (!el || !onScroll) return + el.addEventListener('scroll', onScroll, { passive: true }) + return () => el.removeEventListener('scroll', onScroll) + }, [onScroll]) + + useImperativeHandle(ref, () => ({ + scrollToIndex: (index, options) => + virtualizer.scrollToIndex(index, options), + scrollTo: (options) => parentRef.current?.scrollTo(options), + })) + + return ( +
+
+ {virtualizer.getVirtualItems().map((vi) => ( +
+ {renderItem(vi.index)} +
+ ))} + {footer != null &&
} +
+
+ ) + }, +) + +VirtualList.displayName = 'VirtualList' diff --git a/src/components/profile/groups-editor-viewer.tsx b/src/components/profile/groups-editor-viewer.tsx index 2152bc5fa..6ca2009e2 100644 --- a/src/components/profile/groups-editor-viewer.tsx +++ b/src/components/profile/groups-editor-viewer.tsx @@ -43,9 +43,8 @@ import { } from 'react' import { Controller, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { Virtuoso } from 'react-virtuoso' -import { BaseSearchBox, Switch } from '@/components/base' +import { BaseSearchBox, Switch, VirtualList } from '@/components/base' import { GroupItem } from '@/components/profile/group-item' import { getNetworkInterfaces, @@ -185,6 +184,92 @@ export const GroupsEditorViewer = (props: Props) => { [appendSeq, match], ) + const renderItem = (index: number): React.ReactNode => { + const shift = filteredPrependSeq.length > 0 ? 1 : 0 + if (filteredPrependSeq.length > 0 && index === 0) { + return ( + + { + return x.name + })} + > + {filteredPrependSeq.map((item) => { + return ( + { + setPrependSeq( + prependSeq.filter((v) => v.name !== item.name), + ) + }} + /> + ) + })} + + + ) + } else if (index < filteredGroupList.length + shift) { + const newIndex = index - shift + return ( + { + if (deleteSeq.includes(filteredGroupList[newIndex].name)) { + setDeleteSeq( + deleteSeq.filter((v) => v !== filteredGroupList[newIndex].name), + ) + } else { + setDeleteSeq((prev) => [ + ...prev, + filteredGroupList[newIndex].name, + ]) + } + }} + /> + ) + } else { + return ( + + { + return x.name + })} + > + {filteredAppendSeq.map((item) => { + return ( + { + setAppendSeq(appendSeq.filter((v) => v.name !== item.name)) + }} + /> + ) + })} + + + ) + } + } + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, @@ -1002,109 +1087,15 @@ export const GroupsEditorViewer = (props: Props) => { }} > setMatch(() => match)} /> - 0 ? 1 : 0) + (filteredAppendSeq.length > 0 ? 1 : 0) } - increaseViewportBy={256} - itemContent={(index) => { - const shift = filteredPrependSeq.length > 0 ? 1 : 0 - if (filteredPrependSeq.length > 0 && index === 0) { - return ( - - { - return x.name - })} - > - {filteredPrependSeq.map((item) => { - return ( - { - setPrependSeq( - prependSeq.filter( - (v) => v.name !== item.name, - ), - ) - }} - /> - ) - })} - - - ) - } else if (index < filteredGroupList.length + shift) { - const newIndex = index - shift - return ( - { - if ( - deleteSeq.includes(filteredGroupList[newIndex].name) - ) { - setDeleteSeq( - deleteSeq.filter( - (v) => v !== filteredGroupList[newIndex].name, - ), - ) - } else { - setDeleteSeq((prev) => [ - ...prev, - filteredGroupList[newIndex].name, - ]) - } - }} - /> - ) - } else { - return ( - - { - return x.name - })} - > - {filteredAppendSeq.map((item) => { - return ( - { - setAppendSeq( - appendSeq.filter( - (v) => v.name !== item.name, - ), - ) - }} - /> - ) - })} - - - ) - } - }} + estimateSize={56} + renderItem={renderItem} + style={{ height: 'calc(100% - 24px)', marginTop: '8px' }} /> diff --git a/src/components/profile/proxies-editor-viewer.tsx b/src/components/profile/proxies-editor-viewer.tsx index 4e8e8a2da..4431bb6fc 100644 --- a/src/components/profile/proxies-editor-viewer.tsx +++ b/src/components/profile/proxies-editor-viewer.tsx @@ -35,9 +35,8 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { Virtuoso } from 'react-virtuoso' -import { BaseSearchBox } from '@/components/base' +import { BaseSearchBox, VirtualList } from '@/components/base' import { ProxyItem } from '@/components/profile/proxy-item' import { readProfileFile, saveProfileFile } from '@/services/cmds' import { showNotice } from '@/services/notice-service' @@ -81,6 +80,92 @@ export const ProxiesEditorViewer = (props: Props) => { [appendSeq, match], ) + const renderItem = (index: number): React.ReactNode => { + const shift = filteredPrependSeq.length > 0 ? 1 : 0 + if (filteredPrependSeq.length > 0 && index === 0) { + return ( + + { + return x.name + })} + > + {filteredPrependSeq.map((item) => { + return ( + { + setPrependSeq( + prependSeq.filter((v) => v.name !== item.name), + ) + }} + /> + ) + })} + + + ) + } else if (index < filteredProxyList.length + shift) { + const newIndex = index - shift + return ( + { + if (deleteSeq.includes(filteredProxyList[newIndex].name)) { + setDeleteSeq( + deleteSeq.filter((v) => v !== filteredProxyList[newIndex].name), + ) + } else { + setDeleteSeq((prev) => [ + ...prev, + filteredProxyList[newIndex].name, + ]) + } + }} + /> + ) + } else { + return ( + + { + return x.name + })} + > + {filteredAppendSeq.map((item) => { + return ( + { + setAppendSeq(appendSeq.filter((v) => v.name !== item.name)) + }} + /> + ) + })} + + + ) + } + } + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, @@ -366,109 +451,15 @@ export const ProxiesEditorViewer = (props: Props) => { }} > setMatch(() => match)} /> - 0 ? 1 : 0) + (filteredAppendSeq.length > 0 ? 1 : 0) } - increaseViewportBy={256} - itemContent={(index) => { - const shift = filteredPrependSeq.length > 0 ? 1 : 0 - if (filteredPrependSeq.length > 0 && index === 0) { - return ( - - { - return x.name - })} - > - {filteredPrependSeq.map((item) => { - return ( - { - setPrependSeq( - prependSeq.filter( - (v) => v.name !== item.name, - ), - ) - }} - /> - ) - })} - - - ) - } else if (index < filteredProxyList.length + shift) { - const newIndex = index - shift - return ( - { - if ( - deleteSeq.includes(filteredProxyList[newIndex].name) - ) { - setDeleteSeq( - deleteSeq.filter( - (v) => v !== filteredProxyList[newIndex].name, - ), - ) - } else { - setDeleteSeq((prev) => [ - ...prev, - filteredProxyList[newIndex].name, - ]) - } - }} - /> - ) - } else { - return ( - - { - return x.name - })} - > - {filteredAppendSeq.map((item) => { - return ( - { - setAppendSeq( - appendSeq.filter( - (v) => v.name !== item.name, - ), - ) - }} - /> - ) - })} - - - ) - } - }} + estimateSize={56} + renderItem={renderItem} + style={{ height: 'calc(100% - 24px)', marginTop: '8px' }} /> diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index ecfd8008c..be190892a 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -37,9 +37,8 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { Virtuoso } from 'react-virtuoso' -import { BaseSearchBox, Switch } from '@/components/base' +import { BaseSearchBox, Switch, VirtualList } from '@/components/base' import { RuleItem } from '@/components/profile/rule-item' import { readProfileFile, saveProfileFile } from '@/services/cmds' import { showNotice } from '@/services/notice-service' @@ -283,6 +282,87 @@ export const RulesEditorViewer = (props: Props) => { [appendSeq, match], ) + const renderItem = (index: number): React.ReactNode => { + const shift = filteredPrependSeq.length > 0 ? 1 : 0 + if (filteredPrependSeq.length > 0 && index === 0) { + return ( + + { + return x + })} + > + {filteredPrependSeq.map((item) => { + return ( + { + setPrependSeq(prependSeq.filter((v) => v !== item)) + }} + /> + ) + })} + + + ) + } else if (index < filteredRuleList.length + shift) { + const newIndex = index - shift + return ( + { + if (deleteSeq.includes(filteredRuleList[newIndex])) { + setDeleteSeq( + deleteSeq.filter((v) => v !== filteredRuleList[newIndex]), + ) + } else { + setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]]) + } + }} + /> + ) + } else { + return ( + + { + return x + })} + > + {filteredAppendSeq.map((item) => { + return ( + { + setAppendSeq(appendSeq.filter((v) => v !== item)) + }} + /> + ) + })} + + + ) + } + } + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, @@ -672,103 +752,15 @@ export const RulesEditorViewer = (props: Props) => { }} > setMatch(() => match)} /> - 0 ? 1 : 0) + (filteredAppendSeq.length > 0 ? 1 : 0) } - increaseViewportBy={256} - itemContent={(index) => { - const shift = filteredPrependSeq.length > 0 ? 1 : 0 - if (filteredPrependSeq.length > 0 && index === 0) { - return ( - - { - return x - })} - > - {filteredPrependSeq.map((item) => { - return ( - { - setPrependSeq( - prependSeq.filter((v) => v !== item), - ) - }} - /> - ) - })} - - - ) - } else if (index < filteredRuleList.length + shift) { - const newIndex = index - shift - return ( - { - if (deleteSeq.includes(filteredRuleList[newIndex])) { - setDeleteSeq( - deleteSeq.filter( - (v) => v !== filteredRuleList[newIndex], - ), - ) - } else { - setDeleteSeq((prev) => [ - ...prev, - filteredRuleList[newIndex], - ]) - } - }} - /> - ) - } else { - return ( - - { - return x - })} - > - {filteredAppendSeq.map((item) => { - return ( - { - setAppendSeq( - appendSeq.filter((v) => v !== item), - ) - }} - /> - ) - })} - - - ) - } - }} + estimateSize={56} + renderItem={renderItem} + style={{ height: 'calc(100% - 24px)', marginTop: '8px' }} /> diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 19853d624..8d8df0da3 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -9,10 +9,10 @@ import { Snackbar, Typography, } from '@mui/material' +import { useVirtualizer } from '@tanstack/react-virtual' import { useLockFn } from 'ahooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api' import { BaseEmpty } from '@/components/base' @@ -46,8 +46,6 @@ interface ProxyChainItem { delay?: number } -const VirtuosoFooter = () =>
- export const ProxyGroups = (props: Props) => { const { t } = useTranslation() const { mode, isChainMode = false, chainConfigData } = props @@ -129,10 +127,17 @@ export const ProxyGroups = (props: Props) => { const timeout = verge?.default_latency_timeout || 10000 - const virtuosoRef = useRef(null) + const parentRef = useRef(null) const scrollPositionRef = useRef>({}) const [showScrollTop, setShowScrollTop] = useState(false) - const scrollerRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: renderList.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 56, + overscan: 15, + getItemKey: (index) => renderList[index]?.key ?? index, + }) // 从 localStorage 恢复滚动位置 useEffect(() => { @@ -149,10 +154,9 @@ export const ProxyGroups = (props: Props) => { if (savedPosition !== undefined) { restoreTimer = setTimeout(() => { - virtuosoRef.current?.scrollTo({ - top: savedPosition, - behavior: 'auto', - }) + if (parentRef.current) { + parentRef.current.scrollTop = savedPosition + } }, 100) } } @@ -198,7 +202,7 @@ export const ProxyGroups = (props: Props) => { // 添加和清理滚动事件监听器 useEffect(() => { - const node = scrollerRef.current + const node = parentRef.current if (!node) return const listener = handleScroll as EventListener @@ -213,7 +217,7 @@ export const ProxyGroups = (props: Props) => { // 滚动到顶部 const scrollToTop = useCallback(() => { - virtuosoRef.current?.scrollTo?.({ + parentRef.current?.scrollTo?.({ top: 0, behavior: 'smooth', }) @@ -362,11 +366,7 @@ export const ProxyGroups = (props: Props) => { ) if (index >= 0) { - virtuosoRef.current?.scrollToIndex?.({ - index, - align: 'center', - behavior: 'smooth', - }) + virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' }) } } @@ -378,14 +378,10 @@ export const ProxyGroups = (props: Props) => { ) if (index >= 0) { - virtuosoRef.current?.scrollToIndex?.({ - index, - align: 'start', - behavior: 'smooth', - }) + virtualizer.scrollToIndex(index, { align: 'start', behavior: 'smooth' }) } }, - [renderList], + [renderList, virtualizer], ) const proxyGroupNames = useMemo(() => { @@ -475,39 +471,49 @@ export const ProxyGroups = (props: Props) => { )} - 0 ? 'calc(100% - 80px)' // 只有标题的高度 : 'calc(100% - 14px)', + overflow: 'auto', }} - totalCount={renderList.length} - increaseViewportBy={{ top: 200, bottom: 200 }} - overscan={150} - defaultItemHeight={56} - scrollerRef={(ref) => { - scrollerRef.current = ref as Element - }} - components={{ - Footer: VirtuosoFooter, - }} - initialScrollTop={scrollPositionRef.current[mode]} - computeItemKey={(index) => renderList[index].key} - itemContent={(index) => ( - - )} - /> + > +
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ +
+ ))} +
+
+
@@ -603,34 +609,42 @@ export const ProxyGroups = (props: Props) => { /> )} - { - scrollerRef.current = ref as Element - }} - components={{ - Footer: VirtuosoFooter, - }} - // 添加平滑滚动设置 - initialScrollTop={scrollPositionRef.current[mode]} - computeItemKey={(index) => renderList[index].key} - itemContent={(index) => ( - - )} - /> +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ +
+ ))} +
+
+
) diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index ad42784d4..38b7c9cb4 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -15,7 +15,6 @@ import { import { useLockFn } from 'ahooks' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Virtuoso } from 'react-virtuoso' import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { @@ -23,6 +22,7 @@ import { BasePage, BaseSearchBox, BaseStyledSelect, + VirtualList, } from '@/components/base' import { ConnectionDetail, @@ -243,23 +243,27 @@ const ConnectionsPage = () => { onCloseColumnManager={() => setIsColumnManagerOpen(false)} /> ) : ( - ( + + detailRef.current?.open( + filterConn[i], + connectionsType === 'closed', + ) + } + /> + )} style={{ flex: 1, borderRadius: '8px', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', }} - data={filterConn} - itemContent={(_, item) => ( - - detailRef.current?.open(item, connectionsType === 'closed') - } - /> - )} /> )} diff --git a/src/pages/logs.tsx b/src/pages/logs.tsx index 24e1ba7dc..b104f81cf 100644 --- a/src/pages/logs.tsx +++ b/src/pages/logs.tsx @@ -4,9 +4,8 @@ import { SwapVertRounded, } from '@mui/icons-material' import { Box, Button, IconButton, MenuItem } from '@mui/material' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Virtuoso } from 'react-virtuoso' import { BaseEmpty, @@ -14,6 +13,8 @@ import { BaseSearchBox, BaseStyledSelect, type SearchState, + VirtualList, + type VirtualListHandle, } from '@/components/base' import LogItem from '@/components/log/log-item' import { useClashLog } from '@/hooks/use-clash-log' @@ -60,6 +61,16 @@ const LogPage = () => { [filterLogs, isDescending], ) + const virtuosoRef = useRef(null) + + useEffect(() => { + if (!isDescending && filteredLogs.length > 0) { + virtuosoRef.current?.scrollToIndex(filteredLogs.length - 1, { + behavior: 'smooth', + }) + } + }, [filteredLogs.length, isDescending]) + const handleLogLevelChange = (newLevel: string) => { setClashLog((pre: any) => ({ ...pre, logFilter: newLevel })) } @@ -170,16 +181,14 @@ const LogPage = () => { {filteredLogs.length > 0 ? ( - ( - + ( + )} - followOutput={isDescending ? false : 'smooth'} + style={{ flex: 1 }} /> ) : ( diff --git a/src/pages/rules.tsx b/src/pages/rules.tsx index c43f474fe..7635d44fb 100644 --- a/src/pages/rules.tsx +++ b/src/pages/rules.tsx @@ -1,9 +1,14 @@ import { Box } from '@mui/material' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' -import { BaseEmpty, BasePage, BaseSearchBox } from '@/components/base' +import { + BaseEmpty, + BasePage, + BaseSearchBox, + VirtualList, + type VirtualListHandle, +} from '@/components/base' import { ScrollTopButton } from '@/components/layout/scroll-top-button' import { ProviderButton } from '@/components/rule/provider-button' import RuleItem from '@/components/rule/rule-item' @@ -14,7 +19,7 @@ const RulesPage = () => { const { t } = useTranslation() const { rules = [], refreshRules, refreshRuleProviders } = useAppData() const [match, setMatch] = useState(() => (_: string) => true) - const virtuosoRef = useRef(null) + const virtuosoRef = useRef(null) const [showScrollTop, setShowScrollTop] = useState(false) const pageVisible = useVisibility() @@ -39,15 +44,12 @@ const RulesPage = () => { return rulesWithLineNo.filter((item) => match(item.payload ?? '')) }, [rules, match]) - const scrollToTop = () => { - virtuosoRef.current?.scrollTo({ - top: 0, - behavior: 'smooth', - }) - } + const handleScroll = useCallback((e: Event) => { + setShowScrollTop((e.target as HTMLElement).scrollTop > 100) + }, []) - const handleScroll = (e: any) => { - setShowScrollTop(e.target.scrollTop > 100) + const scrollToTop = () => { + virtuosoRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) } return ( @@ -81,17 +83,13 @@ const RulesPage = () => { {filteredRules && filteredRules.length > 0 ? ( <> - } - followOutput={'smooth'} - scrollerRef={(ref) => { - if (ref) ref.addEventListener('scroll', handleScroll) - }} + count={filteredRules.length} + estimateSize={40} + renderItem={(i) => } + style={{ flex: 1 }} + onScroll={handleScroll} />