mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
feat: sticky groups (#7195)
* feat(proxy): implement sticky virtual list and group tools - Add new StickyVirtualList component for better performance with sticky headers - Create ProxyGroupTools component for filtering, sorting and testing proxy groups - Enhance proxy groups with improved scrolling and group toggle functionality - Integrate search box with click event handling - Remove redundant ProxyHead component usage * style: remove unused import and update TypeScript types * refactor: convert function component to forwardRef component to fix biome lint error * feat(proxy): extract proxy chain UI to separate component * refactor: remove custom throttle implementation and simplify dependencies * feat(proxy): improve proxy groups UI and scrolling behavior - Add initialOffset prop to StickyVirtualList for better scroll position restoration - Refactor ProxyGroups component to separate chain and normal modes - Implement scroll position persistence for both chain and normal modes - Add sticky shadow effect for expanded proxy groups - Remove unnecessary ScrollTopButton component - Optimize scroll event handling with throttling - Improve code organization with custom hooks and component separation * docs(proxy-groups): Add TODO comment for initial render position issue * feat(proxy): improve scroll position handling and UI display * refactor(proxy): simplify scroll handling logic and remove unused code * fix(sticky-virtual-list): remove duplicate styles and add padding * refactor(proxy): rename scroll offset variables and functions for clarity * refactor: remove unnecessary refs and simplify onClick handlers * fix(proxy-groups): handle initial scroll position state * feat(zed): add biome formatter configuration * refactor(proxy): remove unused headState import and type annotations * style(proxy): improve type imports and conditional rendering * . * feat(proxy): add ProxyHead component to render in global mode * chore: ignore .zed directory * docs: update changelog
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import AccessTimeRounded from '@mui/icons-material/AccessTimeRounded'
|
||||
import FilterAltOffRounded from '@mui/icons-material/FilterAltOffRounded'
|
||||
import FilterAltRounded from '@mui/icons-material/FilterAltRounded'
|
||||
import MyLocationRounded from '@mui/icons-material/MyLocationRounded'
|
||||
import NetworkCheckRounded from '@mui/icons-material/NetworkCheckRounded'
|
||||
import SortByAlphaRounded from '@mui/icons-material/SortByAlphaRounded'
|
||||
import SortRounded from '@mui/icons-material/SortRounded'
|
||||
import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
|
||||
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
|
||||
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
|
||||
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
|
||||
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
|
||||
import { memo, useEffect } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import delayManager from '@/services/delay'
|
||||
|
||||
import { BaseSearchBox } from '../base'
|
||||
|
||||
import type { ProxySortType } from './use-filter-sort'
|
||||
import type { HeadState } from './use-head-state'
|
||||
|
||||
interface Props {
|
||||
sx?: SxProps
|
||||
url?: string
|
||||
groupName: string
|
||||
headState: HeadState
|
||||
onLocation: () => void
|
||||
onCheckDelay: () => void
|
||||
onHeadState: (val: Partial<HeadState>) => void
|
||||
}
|
||||
|
||||
export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
const {
|
||||
sx,
|
||||
url,
|
||||
groupName,
|
||||
headState,
|
||||
onCheckDelay,
|
||||
onHeadState,
|
||||
onLocation,
|
||||
} = props
|
||||
|
||||
const {
|
||||
showType,
|
||||
sortType,
|
||||
filterText,
|
||||
textState,
|
||||
testUrl,
|
||||
filterMatchCase,
|
||||
filterMatchWholeWord,
|
||||
filterUseRegularExpression,
|
||||
} = headState
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { verge } = useVerge()
|
||||
const defaultLatencyUrl =
|
||||
verge?.default_latency_test?.trim() ||
|
||||
'http://cp.cloudflare.com/generate_204'
|
||||
|
||||
useEffect(() => {
|
||||
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
|
||||
}, [groupName, testUrl, defaultLatencyUrl, url])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{textState === 'filter' && (
|
||||
<Box sx={{ display: 'inline-block', width: 180 }}>
|
||||
<BaseSearchBox
|
||||
value={filterText}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
searchState={{
|
||||
matchCase: filterMatchCase,
|
||||
matchWholeWord: filterMatchWholeWord,
|
||||
useRegularExpression: filterUseRegularExpression,
|
||||
}}
|
||||
onSearch={(_, state) =>
|
||||
onHeadState({
|
||||
filterText: state.text,
|
||||
filterMatchCase: state.matchCase,
|
||||
filterMatchWholeWord: state.matchWholeWord,
|
||||
filterUseRegularExpression: state.useRegularExpression,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{textState === 'url' && (
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
hiddenLabel
|
||||
autoSave="off"
|
||||
value={testUrl}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onChange={(e) => onHeadState({ testUrl: e.target.value })}
|
||||
sx={{ width: 180, input: { py: 0.65, px: 1 } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title={t('proxies.page.tooltips.locate')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!headState.open)
|
||||
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
|
||||
flushSync(() => onHeadState({ open: true }))
|
||||
onLocation()
|
||||
}}
|
||||
>
|
||||
<MyLocationRounded fontSize="inherit" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title={t('proxies.page.tooltips.delayCheck')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!headState.open)
|
||||
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
|
||||
flushSync(() => onHeadState({ open: true }))
|
||||
// Remind the user that it is custom test url
|
||||
if (testUrl?.trim() && textState !== 'filter') {
|
||||
onHeadState({ textState: 'url' })
|
||||
}
|
||||
onCheckDelay()
|
||||
}}
|
||||
>
|
||||
<NetworkCheckRounded fontSize="inherit" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title={
|
||||
[
|
||||
t('proxies.page.tooltips.sortDefault'),
|
||||
t('proxies.page.tooltips.sortDelay'),
|
||||
t('proxies.page.tooltips.sortName'),
|
||||
][sortType]
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!headState.open)
|
||||
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
|
||||
flushSync(() => onHeadState({ open: true }))
|
||||
onHeadState({
|
||||
sortType: ((sortType + 1) % 3) as ProxySortType,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{sortType !== 1 && sortType !== 2 && <SortRounded fontSize="inherit" />}
|
||||
{sortType === 1 && <AccessTimeRounded fontSize="inherit" />}
|
||||
{sortType === 2 && <SortByAlphaRounded fontSize="inherit" />}
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title={t('proxies.page.tooltips.delayCheckUrl')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!headState.open)
|
||||
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
|
||||
flushSync(() => onHeadState({ open: true }))
|
||||
onHeadState({
|
||||
textState: textState === 'url' ? null : 'url',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{textState === 'url' ? (
|
||||
<WifiTetheringRounded fontSize="inherit" />
|
||||
) : (
|
||||
<WifiTetheringOffRounded fontSize="inherit" />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title={
|
||||
showType
|
||||
? t('proxies.page.tooltips.showBasic')
|
||||
: t('proxies.page.tooltips.showDetail')
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!headState.open)
|
||||
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
|
||||
flushSync(() => onHeadState({ open: true }))
|
||||
onHeadState({ showType: !showType })
|
||||
}}
|
||||
>
|
||||
{showType ? (
|
||||
<VisibilityRounded fontSize="inherit" />
|
||||
) : (
|
||||
<VisibilityOffRounded fontSize="inherit" />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title={t('proxies.page.tooltips.filter')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!headState.open)
|
||||
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
|
||||
flushSync(() => onHeadState({ open: true }))
|
||||
onHeadState({
|
||||
textState: textState === 'filter' ? null : 'filter',
|
||||
filterText: '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{textState === 'filter' ? (
|
||||
<FilterAltRounded fontSize="inherit" />
|
||||
) : (
|
||||
<FilterAltOffRounded fontSize="inherit" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user