mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 05:20:28 +08:00
refactor: migrate react-virtuoso to @tanstack/react-virtual
This commit is contained in:
parent
a73fafaf9f
commit
d6d15652ca
@ -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
14
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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'
|
||||
|
||||
97
src/components/base/virtual-list.tsx
Normal file
97
src/components/base/virtual-list.tsx
Normal 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'
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user