refactor: migrate react-virtuoso to @tanstack/react-virtual

This commit is contained in:
Tunglies 2026-04-03 16:40:06 +08:00
parent a73fafaf9f
commit d6d15652ca
No known key found for this signature in database
GPG Key ID: B9B01B389469B3E8
11 changed files with 516 additions and 434 deletions

View File

@ -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",

14
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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'

View File

@ -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<VirtualListHandle, VirtualListProps>(
(
{
count,
estimateSize,
overscan = 5,
getItemKey,
renderItem,
style,
footer,
onScroll,
},
ref,
) => {
const parentRef = useRef<HTMLDivElement>(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 (
<div ref={parentRef} style={{ ...style, overflow: 'auto' }}>
<div
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{renderItem(vi.index)}
</div>
))}
{footer != null && <div style={{ height: footer }} />}
</div>
</div>
)
},
)
VirtualList.displayName = 'VirtualList'

View File

@ -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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredGroupList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@ -1002,109 +1087,15 @@ export const GroupsEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={
<VirtualList
count={
filteredGroupList.length +
(filteredPrependSeq.length > 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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredGroupList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>

View File

@ -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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredProxyList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@ -366,109 +451,15 @@ export const ProxiesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={
<VirtualList
count={
filteredProxyList.length +
(filteredPrependSeq.length > 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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredProxyList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>

View File

@ -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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x
})}
>
{filteredPrependSeq.map((item) => {
return (
<RuleItem
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x
})}
>
{filteredAppendSeq.map((item) => {
return (
<RuleItem
key={item}
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@ -672,103 +752,15 @@ export const RulesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={
<VirtualList
count={
filteredRuleList.length +
(filteredPrependSeq.length > 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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x
})}
>
{filteredPrependSeq.map((item) => {
return (
<RuleItem
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v !== item),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredRuleList[newIndex],
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredRuleList[newIndex],
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x
})}
>
{filteredAppendSeq.map((item) => {
return (
<RuleItem
key={item}
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter((v) => v !== item),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>

View File

@ -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 = () => <div style={{ height: '8px' }} />
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<VirtuosoHandle>(null)
const parentRef = useRef<HTMLDivElement>(null)
const scrollPositionRef = useRef<Record<string, number>>({})
const [showScrollTop, setShowScrollTop] = useState(false)
const scrollerRef = useRef<Element | null>(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) => {
</Box>
)}
<Virtuoso
ref={virtuosoRef}
<div
ref={parentRef}
style={{
height:
mode === 'rule' && proxyGroups.length > 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) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
isChainMode={isChainMode}
/>
)}
/>
>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProxyRender
item={renderList[virtualItem.index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
isChainMode={isChainMode}
/>
</div>
))}
<div style={{ height: 8 }} />
</div>
</div>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</Box>
@ -603,34 +609,42 @@ export const ProxyGroups = (props: Props) => {
/>
)}
<Virtuoso
ref={virtuosoRef}
style={{ height: 'calc(100% - 14px)' }}
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) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
)}
/>
<div
ref={parentRef}
style={{ height: 'calc(100% - 14px)', overflow: 'auto' }}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProxyRender
item={renderList[virtualItem.index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
</div>
))}
<div style={{ height: 8 }} />
</div>
</div>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</div>
)

View File

@ -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)}
/>
) : (
<Virtuoso
<VirtualList
count={filterConn.length}
estimateSize={56}
renderItem={(i) => (
<ConnectionItem
value={filterConn[i]}
closed={connectionsType === 'closed'}
onShowDetail={() =>
detailRef.current?.open(
filterConn[i],
connectionsType === 'closed',
)
}
/>
)}
style={{
flex: 1,
borderRadius: '8px',
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
}}
data={filterConn}
itemContent={(_, item) => (
<ConnectionItem
value={item}
closed={connectionsType === 'closed'}
onShowDetail={() =>
detailRef.current?.open(item, connectionsType === 'closed')
}
/>
)}
/>
)}
<ConnectionDetail ref={detailRef} />

View File

@ -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<VirtualListHandle>(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 = () => {
</Box>
{filteredLogs.length > 0 ? (
<Virtuoso
initialTopMostItemIndex={isDescending ? 0 : 999}
data={filteredLogs}
style={{
flex: 1,
}}
itemContent={(index, item) => (
<LogItem value={item} searchState={searchState} />
<VirtualList
ref={virtuosoRef}
count={filteredLogs.length}
estimateSize={50}
renderItem={(i) => (
<LogItem value={filteredLogs[i]} searchState={searchState} />
)}
followOutput={isDescending ? false : 'smooth'}
style={{ flex: 1 }}
/>
) : (
<BaseEmpty />

View File

@ -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<VirtuosoHandle>(null)
const virtuosoRef = useRef<VirtualListHandle>(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 ? (
<>
<Virtuoso
<VirtualList
ref={virtuosoRef}
data={filteredRules}
style={{
flex: 1,
}}
itemContent={(_index, item) => <RuleItem value={item} />}
followOutput={'smooth'}
scrollerRef={(ref) => {
if (ref) ref.addEventListener('scroll', handleScroll)
}}
count={filteredRules.length}
estimateSize={40}
renderItem={(i) => <RuleItem value={filteredRules[i]} />}
style={{ flex: 1 }}
onScroll={handleScroll}
/>
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
</>