mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-18 16:30:32 +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-i18next": "17.0.2",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.13.1",
|
||||||
"react-virtuoso": "^4.18.3",
|
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert",
|
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert",
|
||||||
"types-pac": "^1.0.3",
|
"types-pac": "^1.0.3",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@ -131,9 +131,6 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: ^7.13.1
|
specifier: ^7.13.1
|
||||||
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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:
|
rehype-raw:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
@ -3169,12 +3166,6 @@ packages:
|
|||||||
react: '>=16.6.0'
|
react: '>=16.6.0'
|
||||||
react-dom: '>=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:
|
react@19.2.4:
|
||||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -6878,11 +6869,6 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(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: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|||||||
@ -14,3 +14,4 @@ export { BaseStyledSelect } from './base-styled-select'
|
|||||||
export { BaseStyledTextField } from './base-styled-text-field'
|
export { BaseStyledTextField } from './base-styled-text-field'
|
||||||
export { Switch } from './base-switch'
|
export { Switch } from './base-switch'
|
||||||
export { TooltipIcon } from './base-tooltip-icon'
|
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'
|
} from 'react'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { GroupItem } from '@/components/profile/group-item'
|
||||||
import {
|
import {
|
||||||
getNetworkInterfaces,
|
getNetworkInterfaces,
|
||||||
@ -185,6 +184,92 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
[appendSeq, match],
|
[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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
@ -1002,109 +1087,15 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||||
<Virtuoso
|
<VirtualList
|
||||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
count={
|
||||||
totalCount={
|
|
||||||
filteredGroupList.length +
|
filteredGroupList.length +
|
||||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||||
}
|
}
|
||||||
increaseViewportBy={256}
|
estimateSize={56}
|
||||||
itemContent={(index) => {
|
renderItem={renderItem}
|
||||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -35,9 +35,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { ProxyItem } from '@/components/profile/proxy-item'
|
||||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
@ -81,6 +80,92 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
[appendSeq, match],
|
[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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
@ -366,109 +451,15 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||||
<Virtuoso
|
<VirtualList
|
||||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
count={
|
||||||
totalCount={
|
|
||||||
filteredProxyList.length +
|
filteredProxyList.length +
|
||||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||||
}
|
}
|
||||||
increaseViewportBy={256}
|
estimateSize={56}
|
||||||
itemContent={(index) => {
|
renderItem={renderItem}
|
||||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -37,9 +37,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { RuleItem } from '@/components/profile/rule-item'
|
||||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
@ -283,6 +282,87 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
[appendSeq, match],
|
[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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
@ -672,103 +752,15 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||||
<Virtuoso
|
<VirtualList
|
||||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
count={
|
||||||
totalCount={
|
|
||||||
filteredRuleList.length +
|
filteredRuleList.length +
|
||||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||||
}
|
}
|
||||||
increaseViewportBy={256}
|
estimateSize={56}
|
||||||
itemContent={(index) => {
|
renderItem={renderItem}
|
||||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import {
|
|||||||
Snackbar,
|
Snackbar,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { useLockFn } from 'ahooks'
|
import { useLockFn } from 'ahooks'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
|
||||||
import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
|
import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
|
||||||
|
|
||||||
import { BaseEmpty } from '@/components/base'
|
import { BaseEmpty } from '@/components/base'
|
||||||
@ -46,8 +46,6 @@ interface ProxyChainItem {
|
|||||||
delay?: number
|
delay?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtuosoFooter = () => <div style={{ height: '8px' }} />
|
|
||||||
|
|
||||||
export const ProxyGroups = (props: Props) => {
|
export const ProxyGroups = (props: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { mode, isChainMode = false, chainConfigData } = props
|
const { mode, isChainMode = false, chainConfigData } = props
|
||||||
@ -129,10 +127,17 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
|
|
||||||
const timeout = verge?.default_latency_timeout || 10000
|
const timeout = verge?.default_latency_timeout || 10000
|
||||||
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
const parentRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollPositionRef = useRef<Record<string, number>>({})
|
const scrollPositionRef = useRef<Record<string, number>>({})
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false)
|
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 恢复滚动位置
|
// 从 localStorage 恢复滚动位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -149,10 +154,9 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
|
|
||||||
if (savedPosition !== undefined) {
|
if (savedPosition !== undefined) {
|
||||||
restoreTimer = setTimeout(() => {
|
restoreTimer = setTimeout(() => {
|
||||||
virtuosoRef.current?.scrollTo({
|
if (parentRef.current) {
|
||||||
top: savedPosition,
|
parentRef.current.scrollTop = savedPosition
|
||||||
behavior: 'auto',
|
}
|
||||||
})
|
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -198,7 +202,7 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
|
|
||||||
// 添加和清理滚动事件监听器
|
// 添加和清理滚动事件监听器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = scrollerRef.current
|
const node = parentRef.current
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
const listener = handleScroll as EventListener
|
const listener = handleScroll as EventListener
|
||||||
@ -213,7 +217,7 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
|
|
||||||
// 滚动到顶部
|
// 滚动到顶部
|
||||||
const scrollToTop = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
virtuosoRef.current?.scrollTo?.({
|
parentRef.current?.scrollTo?.({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
@ -362,11 +366,7 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
virtuosoRef.current?.scrollToIndex?.({
|
virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' })
|
||||||
index,
|
|
||||||
align: 'center',
|
|
||||||
behavior: 'smooth',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -378,14 +378,10 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
virtuosoRef.current?.scrollToIndex?.({
|
virtualizer.scrollToIndex(index, { align: 'start', behavior: 'smooth' })
|
||||||
index,
|
|
||||||
align: 'start',
|
|
||||||
behavior: 'smooth',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[renderList],
|
[renderList, virtualizer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const proxyGroupNames = useMemo(() => {
|
const proxyGroupNames = useMemo(() => {
|
||||||
@ -475,39 +471,49 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Virtuoso
|
<div
|
||||||
ref={virtuosoRef}
|
ref={parentRef}
|
||||||
style={{
|
style={{
|
||||||
height:
|
height:
|
||||||
mode === 'rule' && proxyGroups.length > 0
|
mode === 'rule' && proxyGroups.length > 0
|
||||||
? 'calc(100% - 80px)' // 只有标题的高度
|
? 'calc(100% - 80px)' // 只有标题的高度
|
||||||
: 'calc(100% - 14px)',
|
: 'calc(100% - 14px)',
|
||||||
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
totalCount={renderList.length}
|
>
|
||||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
<div
|
||||||
overscan={150}
|
style={{
|
||||||
defaultItemHeight={56}
|
height: virtualizer.getTotalSize(),
|
||||||
scrollerRef={(ref) => {
|
position: 'relative',
|
||||||
scrollerRef.current = ref as Element
|
}}
|
||||||
}}
|
>
|
||||||
components={{
|
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||||
Footer: VirtuosoFooter,
|
<div
|
||||||
}}
|
key={virtualItem.key}
|
||||||
initialScrollTop={scrollPositionRef.current[mode]}
|
data-index={virtualItem.index}
|
||||||
computeItemKey={(index) => renderList[index].key}
|
ref={virtualizer.measureElement}
|
||||||
itemContent={(index) => (
|
style={{
|
||||||
<ProxyRender
|
position: 'absolute',
|
||||||
key={renderList[index].key}
|
top: 0,
|
||||||
item={renderList[index]}
|
left: 0,
|
||||||
indent={mode === 'rule' || mode === 'script'}
|
width: '100%',
|
||||||
onLocation={handleLocation}
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
onCheckAll={handleCheckAll}
|
}}
|
||||||
onHeadState={onHeadState}
|
>
|
||||||
onChangeProxy={handleChangeProxy}
|
<ProxyRender
|
||||||
isChainMode={isChainMode}
|
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} />
|
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -603,34 +609,42 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Virtuoso
|
<div
|
||||||
ref={virtuosoRef}
|
ref={parentRef}
|
||||||
style={{ height: 'calc(100% - 14px)' }}
|
style={{ height: 'calc(100% - 14px)', overflow: 'auto' }}
|
||||||
totalCount={renderList.length}
|
>
|
||||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
<div
|
||||||
overscan={150}
|
style={{
|
||||||
defaultItemHeight={56}
|
height: virtualizer.getTotalSize(),
|
||||||
scrollerRef={(ref) => {
|
position: 'relative',
|
||||||
scrollerRef.current = ref as Element
|
}}
|
||||||
}}
|
>
|
||||||
components={{
|
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||||
Footer: VirtuosoFooter,
|
<div
|
||||||
}}
|
key={virtualItem.key}
|
||||||
// 添加平滑滚动设置
|
data-index={virtualItem.index}
|
||||||
initialScrollTop={scrollPositionRef.current[mode]}
|
ref={virtualizer.measureElement}
|
||||||
computeItemKey={(index) => renderList[index].key}
|
style={{
|
||||||
itemContent={(index) => (
|
position: 'absolute',
|
||||||
<ProxyRender
|
top: 0,
|
||||||
key={renderList[index].key}
|
left: 0,
|
||||||
item={renderList[index]}
|
width: '100%',
|
||||||
indent={mode === 'rule' || mode === 'script'}
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
onLocation={handleLocation}
|
}}
|
||||||
onCheckAll={handleCheckAll}
|
>
|
||||||
onHeadState={onHeadState}
|
<ProxyRender
|
||||||
onChangeProxy={handleChangeProxy}
|
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} />
|
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
import { useLockFn } from 'ahooks'
|
import { useLockFn } from 'ahooks'
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Virtuoso } from 'react-virtuoso'
|
|
||||||
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
|
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -23,6 +22,7 @@ import {
|
|||||||
BasePage,
|
BasePage,
|
||||||
BaseSearchBox,
|
BaseSearchBox,
|
||||||
BaseStyledSelect,
|
BaseStyledSelect,
|
||||||
|
VirtualList,
|
||||||
} from '@/components/base'
|
} from '@/components/base'
|
||||||
import {
|
import {
|
||||||
ConnectionDetail,
|
ConnectionDetail,
|
||||||
@ -243,23 +243,27 @@ const ConnectionsPage = () => {
|
|||||||
onCloseColumnManager={() => setIsColumnManagerOpen(false)}
|
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={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
overscrollBehavior: 'contain',
|
overscrollBehavior: 'contain',
|
||||||
}}
|
}}
|
||||||
data={filterConn}
|
|
||||||
itemContent={(_, item) => (
|
|
||||||
<ConnectionItem
|
|
||||||
value={item}
|
|
||||||
closed={connectionsType === 'closed'}
|
|
||||||
onShowDetail={() =>
|
|
||||||
detailRef.current?.open(item, connectionsType === 'closed')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ConnectionDetail ref={detailRef} />
|
<ConnectionDetail ref={detailRef} />
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import {
|
|||||||
SwapVertRounded,
|
SwapVertRounded,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Button, IconButton, MenuItem } from '@mui/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 { useTranslation } from 'react-i18next'
|
||||||
import { Virtuoso } from 'react-virtuoso'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BaseEmpty,
|
BaseEmpty,
|
||||||
@ -14,6 +13,8 @@ import {
|
|||||||
BaseSearchBox,
|
BaseSearchBox,
|
||||||
BaseStyledSelect,
|
BaseStyledSelect,
|
||||||
type SearchState,
|
type SearchState,
|
||||||
|
VirtualList,
|
||||||
|
type VirtualListHandle,
|
||||||
} from '@/components/base'
|
} from '@/components/base'
|
||||||
import LogItem from '@/components/log/log-item'
|
import LogItem from '@/components/log/log-item'
|
||||||
import { useClashLog } from '@/hooks/use-clash-log'
|
import { useClashLog } from '@/hooks/use-clash-log'
|
||||||
@ -60,6 +61,16 @@ const LogPage = () => {
|
|||||||
[filterLogs, isDescending],
|
[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) => {
|
const handleLogLevelChange = (newLevel: string) => {
|
||||||
setClashLog((pre: any) => ({ ...pre, logFilter: newLevel }))
|
setClashLog((pre: any) => ({ ...pre, logFilter: newLevel }))
|
||||||
}
|
}
|
||||||
@ -170,16 +181,14 @@ const LogPage = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
<Virtuoso
|
<VirtualList
|
||||||
initialTopMostItemIndex={isDescending ? 0 : 999}
|
ref={virtuosoRef}
|
||||||
data={filteredLogs}
|
count={filteredLogs.length}
|
||||||
style={{
|
estimateSize={50}
|
||||||
flex: 1,
|
renderItem={(i) => (
|
||||||
}}
|
<LogItem value={filteredLogs[i]} searchState={searchState} />
|
||||||
itemContent={(index, item) => (
|
|
||||||
<LogItem value={item} searchState={searchState} />
|
|
||||||
)}
|
)}
|
||||||
followOutput={isDescending ? false : 'smooth'}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BaseEmpty />
|
<BaseEmpty />
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { Box } from '@mui/material'
|
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 { 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 { ScrollTopButton } from '@/components/layout/scroll-top-button'
|
||||||
import { ProviderButton } from '@/components/rule/provider-button'
|
import { ProviderButton } from '@/components/rule/provider-button'
|
||||||
import RuleItem from '@/components/rule/rule-item'
|
import RuleItem from '@/components/rule/rule-item'
|
||||||
@ -14,7 +19,7 @@ const RulesPage = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { rules = [], refreshRules, refreshRuleProviders } = useAppData()
|
const { rules = [], refreshRules, refreshRuleProviders } = useAppData()
|
||||||
const [match, setMatch] = useState(() => (_: string) => true)
|
const [match, setMatch] = useState(() => (_: string) => true)
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
const virtuosoRef = useRef<VirtualListHandle>(null)
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false)
|
const [showScrollTop, setShowScrollTop] = useState(false)
|
||||||
const pageVisible = useVisibility()
|
const pageVisible = useVisibility()
|
||||||
|
|
||||||
@ -39,15 +44,12 @@ const RulesPage = () => {
|
|||||||
return rulesWithLineNo.filter((item) => match(item.payload ?? ''))
|
return rulesWithLineNo.filter((item) => match(item.payload ?? ''))
|
||||||
}, [rules, match])
|
}, [rules, match])
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const handleScroll = useCallback((e: Event) => {
|
||||||
virtuosoRef.current?.scrollTo({
|
setShowScrollTop((e.target as HTMLElement).scrollTop > 100)
|
||||||
top: 0,
|
}, [])
|
||||||
behavior: 'smooth',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll = (e: any) => {
|
const scrollToTop = () => {
|
||||||
setShowScrollTop(e.target.scrollTop > 100)
|
virtuosoRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -81,17 +83,13 @@ const RulesPage = () => {
|
|||||||
|
|
||||||
{filteredRules && filteredRules.length > 0 ? (
|
{filteredRules && filteredRules.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Virtuoso
|
<VirtualList
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
data={filteredRules}
|
count={filteredRules.length}
|
||||||
style={{
|
estimateSize={40}
|
||||||
flex: 1,
|
renderItem={(i) => <RuleItem value={filteredRules[i]} />}
|
||||||
}}
|
style={{ flex: 1 }}
|
||||||
itemContent={(_index, item) => <RuleItem value={item} />}
|
onScroll={handleScroll}
|
||||||
followOutput={'smooth'}
|
|
||||||
scrollerRef={(ref) => {
|
|
||||||
if (ref) ref.addEventListener('scroll', handleScroll)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
|
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user