feat(proxy): support dragging to swap proxy group header sections

This commit is contained in:
wonfen
2026-09-10 21:39:46 +08:00
parent bf6f43f15b
commit ed0e1b38f0
10 changed files with 343 additions and 144 deletions
+2
View File
@@ -41,6 +41,8 @@
"perf:test": "node --test scripts/perf/report.test.mjs"
},
"dependencies": {
"@dnd-kit/collision": "^0.5.0",
"@dnd-kit/dom": "^0.5.0",
"@dnd-kit/helpers": "^0.5.0",
"@dnd-kit/react": "^0.5.0",
"@emotion/react": "^11.14.0",
+6
View File
@@ -109,6 +109,12 @@ importers:
.:
dependencies:
'@dnd-kit/collision':
specifier: ^0.5.0
version: 0.5.0
'@dnd-kit/dom':
specifier: ^0.5.0
version: 0.5.0
'@dnd-kit/helpers':
specifier: ^0.5.0
version: 0.5.0
+4
View File
@@ -56,6 +56,9 @@ pub struct IVerge {
#[serde(skip_serializing_if = "Option::is_none")]
pub menu_order: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_group_tools_position: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notice_position: Option<String>,
@@ -425,6 +428,7 @@ impl IVerge {
patch!(tray_icon);
patch!(menu_icon);
patch!(menu_order);
patch!(proxy_group_tools_position);
patch!(notice_position);
patch!(collapse_navbar);
patch!(common_tray_icon);
@@ -0,0 +1,85 @@
import { pointerIntersection } from '@dnd-kit/collision'
import { Feedback } from '@dnd-kit/dom'
import { DOMRectangle, supportsPopover } from '@dnd-kit/dom/utilities'
import { useDraggable, useDroppable } from '@dnd-kit/react'
import { alpha, Box, type SxProps, type Theme } from '@mui/material'
import { type ReactNode, useCallback } from 'react'
const headerFeedbackPlugins = [
Feedback.configure({
feedback: (source, manager) => {
if (supportsPopover(document.body)) return 'default'
// Virtual rows offset fixed feedback without Popover. Collision detection
// still needs a moving shape when the visible blocks stay in place.
if (source.element && manager.dragOperation.status.initialized) {
const { x, y } = manager.dragOperation.transform
manager.dragOperation.shape = new DOMRectangle(
source.element,
).translate(x, y)
}
return 'none'
},
}),
]
interface ProxyGroupHeaderBlockProps {
group: string
block: 'name' | 'tools'
sx?: SxProps<Theme>
children: ReactNode
}
export const ProxyGroupHeaderBlock = ({
group,
block,
sx,
children,
}: ProxyGroupHeaderBlockProps) => {
const id = `${group}:${block}`
const counterpart = `${group}:${block === 'name' ? 'tools' : 'name'}`
const { ref: draggableRef, isDragging } = useDraggable({
id,
type: id,
plugins: headerFeedbackPlugins,
})
// A block only accepts its own counterpart: every rendered header is its own
// scope because the sticky copy and the hidden measuring copy of a group
// overlap exactly. Pointer-only detection keeps the swap decision the same
// however different the two blocks' widths are.
const { ref: droppableRef, isDropTarget } = useDroppable({
id,
accept: counterpart,
collisionDetector: pointerIntersection,
})
const setRef = useCallback(
(element: Element | null) => {
draggableRef(element)
droppableRef(element)
},
[draggableRef, droppableRef],
)
return (
<Box
ref={setRef}
// The header button already handles keyboard use; keep the block out of the tab order.
tabIndex={-1}
sx={[
{
display: 'flex',
alignItems: 'center',
minWidth: 0,
borderRadius: 2,
},
isDragging && { bgcolor: 'background.paper' },
isDropTarget && {
bgcolor: (theme: Theme) => alpha(theme.palette.primary.main, 0.12),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{children}
</Box>
)
}
+22 -13
View File
@@ -25,6 +25,7 @@ import type { HeadState } from './use-head-state'
interface Props {
sx?: SxProps
side?: 'left' | 'right'
url?: string
groupName: string
headState: HeadState
@@ -36,6 +37,7 @@ interface Props {
export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const {
sx,
side = 'right',
url,
groupName,
headState,
@@ -88,19 +90,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
}, [textState, flushFilter])
useEffect(() => () => flushFilter(), [flushFilter])
return (
<Box
sx={{
display: 'flex',
justifyContent: 'end',
alignItems: 'center',
gap: 0.5,
height: 36,
flex: 1,
ml: 2,
...sx,
}}
>
const textInput = (
<>
{textState === 'filter' && (
<Box sx={{ flex: '1 1 auto' }}>
<BaseSearchBox
@@ -136,6 +127,23 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
</>
)
return (
<Box
sx={{
display: 'flex',
justifyContent: side === 'left' ? 'start' : 'end',
alignItems: 'center',
gap: 0.5,
height: 36,
flex: 1,
ml: side === 'left' ? 0 : 2,
...sx,
}}
>
{side === 'right' && textInput}
<IconButton
size="small"
color="inherit"
@@ -262,6 +270,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
<SearchRounded fontSize="inherit" />
)}
</IconButton>
{side === 'left' && textInput}
</Box>
)
})
+25 -14
View File
@@ -1,3 +1,4 @@
import { DragDropProvider } from '@dnd-kit/react'
import { ExpandMoreRounded } from '@mui/icons-material'
import {
Alert,
@@ -35,6 +36,10 @@ import { ProxyChain } from './proxy-chain'
import { type ProxyChainItem, rebindProxyChainItems } from './proxy-chain-model'
import { ProxyRender } from './proxy-render'
import type { HeadState } from './use-head-state'
import {
PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout,
} from './use-proxy-group-header-layout'
import type { IRenderItem } from './use-render-list'
// ---- Types ----
@@ -315,6 +320,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
onScrollToTop,
} = props
const { proxyView } = useProxiesData()
const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout()
// Chain-specific state
const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>(() => {
@@ -455,20 +461,25 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
// Render virtual list for chain mode
const renderProxyList = (height: string) => (
<ProxyVirtualList
parentRef={parentRef}
height={height}
totalSize={totalSize}
virtualItems={virtualItems}
renderList={renderList}
activeStickyIndex={activeStickyIndex}
isChainMode
measureElement={measureElement}
onLocation={onLocation}
onCheckAll={onCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
<DragDropProvider
sensors={PROXY_GROUP_HEADER_SENSORS}
onDragEnd={onHeaderDragEnd}
>
<ProxyVirtualList
parentRef={parentRef}
height={height}
totalSize={totalSize}
virtualItems={virtualItems}
renderList={renderList}
activeStickyIndex={activeStickyIndex}
isChainMode
measureElement={measureElement}
onLocation={onLocation}
onCheckAll={onCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
</DragDropProvider>
)
const showRuleHeader = mode === 'rule' && availableGroups.length > 0
+21 -10
View File
@@ -1,3 +1,4 @@
import { DragDropProvider } from '@dnd-kit/react'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { useLockFn } from 'ahooks'
import { throttle } from 'lodash-es'
@@ -41,6 +42,10 @@ import {
ProxyGroupNavigator,
} from './proxy-group-navigator'
import { ProxyRender } from './proxy-render'
import {
PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout,
} from './use-proxy-group-header-layout'
import {
hasRenderableItems,
type IRenderItem,
@@ -365,6 +370,7 @@ function NormalProxyGroups(props: { mode: string }) {
saveScrollPosition,
} = useProxyRenderState(mode, false, null)
const emptyList = useEmptyRenderList()
const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout()
const renderFirstRef = useRef(true)
// Do not persist intermediate positions produced while restoring virtual scroll.
const isRestoringRef = useRef(false)
@@ -574,16 +580,21 @@ function NormalProxyGroups(props: { mode: string }) {
return (
<div style={{ position: 'relative', height: '100%' }}>
<StickyVirtualList
ref={stickyListRef}
items={renderList}
isGroupItem={(item) => item.type === 0}
getItemKey={(item) => item.key}
estimateGroupItemHeight={76}
estimateItemHeight={64}
renderGroupItem={renderGroupItem}
renderItem={renderProxyItem}
/>
<DragDropProvider
sensors={PROXY_GROUP_HEADER_SENSORS}
onDragEnd={onHeaderDragEnd}
>
<StickyVirtualList
ref={stickyListRef}
items={renderList}
isGroupItem={(item) => item.type === 0}
getItemKey={(item) => item.key}
estimateGroupItemHeight={76}
estimateItemHeight={64}
renderGroupItem={renderGroupItem}
renderItem={renderProxyItem}
/>
</DragDropProvider>
{mode === 'rule' && (
<ProxyGroupNavigator
+128 -107
View File
@@ -13,7 +13,7 @@ import {
Chip,
Tooltip,
} from '@mui/material'
import { memo, useMemo } from 'react'
import { memo, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useIconCache } from '@/hooks/use-icon-cache'
@@ -21,6 +21,7 @@ import { useVerge } from '@/hooks/use-verge'
import { useThemeMode } from '@/services/states'
import type { ResolvedProxyMember } from '@/types/proxy-view'
import { ProxyGroupHeaderBlock } from './proxy-group-header-block'
import { ProxyGroupTools } from './proxy-group-tools'
import { ProxyHead } from './proxy-head'
import { ProxyItem } from './proxy-item'
@@ -57,6 +58,8 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
const { type, group, headState, member, memberCol } = item
const { verge } = useVerge()
const enable_group_icon = verge?.enable_group_icon ?? true
const toolsOnLeft = verge?.proxy_group_tools_position === 'left'
const headerId = useId()
const mode = useThemeMode()
const isDark = mode === 'dark'
const itembackgroundcolor = isDark ? '#282A36' : '#ffffff'
@@ -85,6 +88,127 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
}, [type, memberCol, item.key, group, showType, onChangeProxy])
if (type === 0) {
const nameBlock = (
<ProxyGroupHeaderBlock
key="name"
group={headerId}
block="name"
sx={{ flex: '0 1 auto', mr: toolsOnLeft ? 1 : 0 }}
>
{enable_group_icon && group.icon?.trim().startsWith('http') && (
<img
src={iconCachePath === '' ? group.icon : iconCachePath}
alt={group.name}
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('data') && (
<img
src={group.icon}
alt={group.name}
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('<svg') && (
<img
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(group.icon)}`}
alt={group.name}
width="32px"
/>
)}
<ListItemText
sx={{ flex: '0 1 auto', minWidth: 0 }}
primary={<StyledPrimary>{group.name}</StyledPrimary>}
secondary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
pt: '2px',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
<Box
component="span"
sx={{
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: 'text.secondary' }}>
{group.now}
</StyledSubtitle>
</Box>
</Box>
}
slotProps={{
secondary: {
component: 'div',
sx: {
display: 'flex',
alignItems: 'center',
color: '#ccc',
},
},
}}
/>
</ProxyGroupHeaderBlock>
)
const proxyCount = (
<Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
<div
style={{
minWidth: '50px',
display: 'flex',
justifyContent: toolsOnLeft ? 'start' : 'end',
alignItems: 'center',
}}
>
<Chip
size="small"
label={`${group.members.length}`}
sx={{
mr: toolsOnLeft ? 0 : 1,
backgroundColor: (theme) =>
alpha(theme.palette.primary.main, 0.1),
color: (theme) => theme.palette.primary.main,
}}
/>
</div>
</Tooltip>
)
const toolsBlock = (
<ProxyGroupHeaderBlock
key="tools"
group={headerId}
block="tools"
sx={{
flex: '1 1 auto',
justifyContent: toolsOnLeft ? 'start' : 'end',
mr: toolsOnLeft ? 2 : 0,
}}
>
{toolsOnLeft && proxyCount}
<ProxyGroupTools
side={toolsOnLeft ? 'left' : 'right'}
url={group.testUrl}
groupName={group.name}
headState={headState!}
onLocation={() => onLocation(group)}
onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)}
/>
{!toolsOnLeft && proxyCount}
</ProxyGroupHeaderBlock>
)
return (
<div style={{ padding: '4px 8px' }}>
<ListItemButton
@@ -109,112 +233,9 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
>
<Box sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
{enable_group_icon && group.icon?.trim().startsWith('http') && (
<img
src={iconCachePath === '' ? group.icon : iconCachePath}
alt={group.name}
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('data') && (
<img
src={group.icon}
alt={group.name}
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('<svg') && (
<img
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(group.icon)}`}
alt={group.name}
width="32px"
/>
)}
<ListItemText
sx={{ flex: '0 1 auto', minWidth: 0 }}
primary={<StyledPrimary>{group.name}</StyledPrimary>}
secondary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
pt: '2px',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
<Box
component="span"
sx={{
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: 'text.secondary' }}>
{group.now}
</StyledSubtitle>
</Box>
</Box>
}
slotProps={{
secondary: {
component: 'div',
sx: {
display: 'flex',
alignItems: 'center',
color: '#ccc',
},
},
}}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
flex: '1 1 auto',
minWidth: 0,
}}
>
<ProxyGroupTools
url={group.testUrl}
groupName={group.name}
headState={headState!}
onLocation={() => onLocation(group)}
onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)}
/>
<Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
<div
style={{
minWidth: '50px',
display: 'flex',
justifyContent: 'end',
alignItems: 'center',
}}
>
<Chip
size="small"
label={`${group.members.length}`}
sx={{
mr: 1,
backgroundColor: (theme) =>
alpha(theme.palette.primary.main, 0.1),
color: (theme) => theme.palette.primary.main,
}}
/>
</div>
</Tooltip>
{headState?.open ? (
<ExpandLessRounded />
) : (
<ExpandMoreRounded />
)}
</Box>
{toolsOnLeft ? toolsBlock : nameBlock}
{toolsOnLeft ? nameBlock : toolsBlock}
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</Box>
</Box>
</ListItemButton>
@@ -0,0 +1,49 @@
import { PointerActivationConstraints } from '@dnd-kit/dom'
import { PointerSensor, type DragEndEvent } from '@dnd-kit/react'
import { useCallback } from 'react'
import { useVerge } from '@/hooks/use-verge'
// The header row is itself a click target and hosts buttons and text inputs,
// so a drag may only start once the pointer has travelled; text inputs never
// start one so their selection gestures keep working.
const headerPointerSensor = PointerSensor.configure({
activationConstraints: (event) =>
event.pointerType === 'touch'
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
: [new PointerActivationConstraints.Distance({ value: 6 })],
preventActivation: (event) =>
event.target instanceof Element &&
event.target.closest('input, textarea, [contenteditable]') !== null,
})
export const PROXY_GROUP_HEADER_SENSORS = [headerPointerSensor]
export const useProxyGroupHeaderLayout = () => {
const { verge, mutateVerge, patchVerge } = useVerge()
const toolsOnLeft = verge?.proxy_group_tools_position === 'left'
const onDragEnd = useCallback(
async (event: DragEndEvent) => {
// Blocks only accept their counterpart, so any drop target means a swap.
const { operation, canceled } = event
if (canceled || !operation.source || !operation.target) return
const position = toolsOnLeft ? 'right' : 'left'
mutateVerge(
(prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
false,
)
try {
await patchVerge({ proxy_group_tools_position: position })
} catch (error) {
console.error('Failed to save proxy group header layout:', error)
mutateVerge()
}
},
[toolsOnLeft, mutateVerge, patchVerge],
)
return { onDragEnd }
}
+1
View File
@@ -872,6 +872,7 @@ interface IVergeConfig {
pause_render_traffic_stats_on_blur?: boolean
menu_icon?: 'monochrome' | 'colorful' | 'disable'
menu_order?: string[]
proxy_group_tools_position?: 'left' | 'right'
notice_position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
collapse_navbar?: boolean
tray_icon?: 'monochrome' | 'colorful'