mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-03 13:32:16 +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:
@@ -1,5 +1,4 @@
|
||||
import { Box, CircularProgress } from '@mui/material'
|
||||
import React from 'react'
|
||||
|
||||
interface BaseLoadingOverlayProps {
|
||||
isLoading: boolean
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ClearRounded } from '@mui/icons-material'
|
||||
import { Box, SvgIcon, TextField, styled, IconButton } from '@mui/material'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import {
|
||||
ChangeEvent,
|
||||
type ChangeEvent,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -35,6 +36,7 @@ type SearchProps = {
|
||||
useRegularExpression?: boolean
|
||||
searchState?: Partial<SearchOptionState>
|
||||
onSearch: (match: (content: string) => boolean, state: SearchState) => void
|
||||
onClick?: (e: MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
const StyledTextField = styled(TextField)(({ theme }) => ({
|
||||
@@ -80,6 +82,7 @@ export const BaseSearchBox = ({
|
||||
matchWholeWord: defaultMatchWholeWord = false,
|
||||
useRegularExpression: defaultUseRegularExpression = false,
|
||||
onSearch,
|
||||
onClick,
|
||||
}: SearchProps) => {
|
||||
const { t } = useTranslation()
|
||||
const onSearchRef = useRef(onSearch)
|
||||
@@ -199,6 +202,7 @@ export const BaseSearchBox = ({
|
||||
placeholder={placeholder ?? t('shared.placeholders.filter')}
|
||||
sx={{ input: { py: 0.65, px: 1.25 } }}
|
||||
value={text}
|
||||
onClick={onClick}
|
||||
onChange={handleChangeText}
|
||||
error={!!effectiveErrorMessage}
|
||||
slotProps={{
|
||||
|
||||
@@ -16,3 +16,7 @@ export { MonacoEditor } from './monaco-editor'
|
||||
export { Switch } from './base-switch'
|
||||
export { TooltipIcon } from './base-tooltip-icon'
|
||||
export { VirtualList, type VirtualListHandle } from './virtual-list'
|
||||
export {
|
||||
StickyVirtualList,
|
||||
type StickyVirtualListHandle,
|
||||
} from './sticky-virtual-list'
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
forwardRef,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
|
||||
type ScrollToIndexOptions = {
|
||||
align?: 'auto' | 'center' | 'end' | 'start'
|
||||
behavior?: ScrollBehavior
|
||||
}
|
||||
|
||||
const findGroupSectionIndex = (groupIndexes: number[], itemIndex: number) => {
|
||||
let low = 0
|
||||
let high = groupIndexes.length - 1
|
||||
let matchedIndex = -1
|
||||
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2)
|
||||
|
||||
if (groupIndexes[middle] <= itemIndex) {
|
||||
matchedIndex = middle
|
||||
low = middle + 1
|
||||
} else {
|
||||
high = middle - 1
|
||||
}
|
||||
}
|
||||
|
||||
return matchedIndex
|
||||
}
|
||||
|
||||
export interface StickyVirtualListHandle {
|
||||
getScrollElement: () => HTMLDivElement | null
|
||||
isItemScrolledPastStart: (index: number, tolerance?: number) => boolean
|
||||
scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void
|
||||
isScrolling: () => boolean
|
||||
waitForScrollEnd: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface StickyVirtualListProps<TItem> {
|
||||
initialOffset?: number
|
||||
items: TItem[]
|
||||
isGroupItem: (item: TItem, index: number) => boolean
|
||||
getItemKey: (item: TItem, index: number) => React.Key
|
||||
// 组项预估高度
|
||||
estimateGroupItemHeight: number
|
||||
// 非组项预估高度
|
||||
estimateItemHeight: number
|
||||
renderGroupItem: (item: TItem, index: number, stickyed: boolean) => ReactNode
|
||||
renderItem: (item: TItem, index: number) => ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
overscan?: number
|
||||
}
|
||||
|
||||
export const StickyVirtualList = forwardRef(function StickyVirtualListInner<
|
||||
TItem,
|
||||
>(
|
||||
props: StickyVirtualListProps<TItem>,
|
||||
ref: React.ForwardedRef<StickyVirtualListHandle>,
|
||||
) {
|
||||
const {
|
||||
initialOffset = 0,
|
||||
items,
|
||||
isGroupItem,
|
||||
getItemKey,
|
||||
estimateGroupItemHeight,
|
||||
estimateItemHeight,
|
||||
renderGroupItem,
|
||||
renderItem,
|
||||
className,
|
||||
style,
|
||||
overscan = 8,
|
||||
} = props
|
||||
const scrollParentRef = useRef<HTMLDivElement>(null)
|
||||
const getEstimatedItemHeight = useCallback(
|
||||
(index: number) =>
|
||||
isGroupItem(items[index], index)
|
||||
? estimateGroupItemHeight
|
||||
: estimateItemHeight,
|
||||
[estimateGroupItemHeight, estimateItemHeight, isGroupItem, items],
|
||||
)
|
||||
|
||||
const groupIndexes = useMemo(
|
||||
() =>
|
||||
items.reduce<number[]>((indexes, item, index) => {
|
||||
if (isGroupItem(item, index)) indexes.push(index)
|
||||
return indexes
|
||||
}, []),
|
||||
[isGroupItem, items],
|
||||
)
|
||||
|
||||
const groupSections = useMemo(
|
||||
() =>
|
||||
groupIndexes.map((groupIndex, index) => ({
|
||||
groupIndex,
|
||||
nextGroupIndex: groupIndexes[index + 1] ?? items.length,
|
||||
})),
|
||||
[groupIndexes, items.length],
|
||||
)
|
||||
|
||||
const estimatedOffsets = useMemo(() => {
|
||||
const offsets = new Array<number>(items.length + 1)
|
||||
offsets[0] = 0
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
offsets[i + 1] = offsets[i] + getEstimatedItemHeight(i)
|
||||
}
|
||||
|
||||
return offsets
|
||||
}, [getEstimatedItemHeight, items.length])
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
initialOffset,
|
||||
count: items.length,
|
||||
estimateSize: getEstimatedItemHeight,
|
||||
getItemKey: (index) => getItemKey(items[index], index),
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
overscan,
|
||||
})
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
const getVirtualOffset = useCallback(
|
||||
(index: number) => {
|
||||
return (
|
||||
rowVirtualizer.measurementsCache[index]?.start ??
|
||||
estimatedOffsets[index]
|
||||
)
|
||||
},
|
||||
[estimatedOffsets, rowVirtualizer],
|
||||
)
|
||||
|
||||
const visibleGroupSections = useMemo(() => {
|
||||
if (!virtualItems.length || !groupSections.length) return []
|
||||
|
||||
const firstVirtualIndex = virtualItems[0].index
|
||||
const lastVirtualIndex = virtualItems[virtualItems.length - 1].index
|
||||
const matchedFirstSectionIndex = findGroupSectionIndex(
|
||||
groupIndexes,
|
||||
firstVirtualIndex,
|
||||
)
|
||||
const lastSectionIndex = findGroupSectionIndex(
|
||||
groupIndexes,
|
||||
lastVirtualIndex,
|
||||
)
|
||||
|
||||
if (lastSectionIndex < 0) return []
|
||||
|
||||
const firstSectionIndex =
|
||||
matchedFirstSectionIndex >= 0 ? matchedFirstSectionIndex : 0
|
||||
|
||||
return groupSections.slice(
|
||||
firstSectionIndex,
|
||||
Math.min(lastSectionIndex + 2, groupSections.length),
|
||||
)
|
||||
}, [groupIndexes, groupSections, virtualItems])
|
||||
|
||||
const isGroupSticky = useCallback(
|
||||
(groupIndex: number, tolerance = 0) => {
|
||||
const scroller = scrollParentRef.current
|
||||
if (!scroller) return false
|
||||
|
||||
return scroller.scrollTop > getVirtualOffset(groupIndex) + tolerance
|
||||
},
|
||||
[getVirtualOffset],
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
isItemScrolledPastStart: (index, tolerance = 0) => {
|
||||
return isGroupSticky(index, tolerance)
|
||||
},
|
||||
scrollToIndex: (index, options) => {
|
||||
rowVirtualizer.scrollToIndex(index, options)
|
||||
},
|
||||
isScrolling: () => rowVirtualizer.isScrolling,
|
||||
waitForScrollEnd: () => {
|
||||
return new Promise((resolve) => {
|
||||
let maxCheckCount = 5
|
||||
const interval = setInterval(() => {
|
||||
if (!rowVirtualizer.isScrolling || maxCheckCount < 0) {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
maxCheckCount -= 1
|
||||
}, 100)
|
||||
})
|
||||
},
|
||||
}),
|
||||
[isGroupSticky, rowVirtualizer],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
className={className}
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
contain: 'strict',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflowAnchor: 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{visibleGroupSections.map(({ groupIndex, nextGroupIndex }) => {
|
||||
const group = items[groupIndex]
|
||||
const start = getVirtualOffset(groupIndex)
|
||||
const end =
|
||||
nextGroupIndex < items.length
|
||||
? getVirtualOffset(nextGroupIndex)
|
||||
: rowVirtualizer.getTotalSize()
|
||||
const stickyed = isGroupSticky(groupIndex, 1)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={getItemKey(group, groupIndex)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: start,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: Math.max(end - start, estimateGroupItemHeight),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-index={groupIndex}
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{renderGroupItem(group, groupIndex, stickyed)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = items[virtualRow.index]
|
||||
const isGroup = isGroupItem(item, virtualRow.index)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
style={{
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
...(isGroup && {
|
||||
opacity: 0,
|
||||
zIndex: -10,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{isGroup
|
||||
? renderGroupItem(item, virtualRow.index, false) // 渲染组,以便动态计算组高度
|
||||
: renderItem(item, virtualRow.index)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}) as <TItem>(
|
||||
props: StickyVirtualListProps<TItem> & {
|
||||
ref?: React.Ref<StickyVirtualListHandle>
|
||||
},
|
||||
) => React.ReactElement
|
||||
Reference in New Issue
Block a user