From ed0e1b38f069f6927ed3524092a53e5cde700e66 Mon Sep 17 00:00:00 2001 From: wonfen Date: Thu, 10 Sep 2026 21:31:55 +0800 Subject: [PATCH] feat(proxy): support dragging to swap proxy group header sections --- package.json | 2 + pnpm-lock.yaml | 6 + src-tauri/src/config/verge.rs | 4 + .../proxy/proxy-group-header-block.tsx | 85 +++++++ src/components/proxy/proxy-group-tools.tsx | 35 ++- src/components/proxy/proxy-groups-chain.tsx | 39 +-- src/components/proxy/proxy-groups.tsx | 31 ++- src/components/proxy/proxy-render.tsx | 235 ++++++++++-------- .../proxy/use-proxy-group-header-layout.ts | 49 ++++ src/types/global.d.ts | 1 + 10 files changed, 343 insertions(+), 144 deletions(-) create mode 100644 src/components/proxy/proxy-group-header-block.tsx create mode 100644 src/components/proxy/use-proxy-group-header-layout.ts diff --git a/package.json b/package.json index ae78f7dc0..70e233893 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d590e8426..27a234c36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 849065709..0b04635c0 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -56,6 +56,9 @@ pub struct IVerge { #[serde(skip_serializing_if = "Option::is_none")] pub menu_order: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_group_tools_position: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub notice_position: Option, @@ -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); diff --git a/src/components/proxy/proxy-group-header-block.tsx b/src/components/proxy/proxy-group-header-block.tsx new file mode 100644 index 000000000..5f14f37a4 --- /dev/null +++ b/src/components/proxy/proxy-group-header-block.tsx @@ -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 + 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 ( + alpha(theme.palette.primary.main, 0.12), + }, + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {children} + + ) +} diff --git a/src/components/proxy/proxy-group-tools.tsx b/src/components/proxy/proxy-group-tools.tsx index 55eca4cbf..903664de5 100644 --- a/src/components/proxy/proxy-group-tools.tsx +++ b/src/components/proxy/proxy-group-tools.tsx @@ -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 ( - + const textInput = ( + <> {textState === 'filter' && ( )} + + ) + + return ( + + {side === 'right' && textInput} )} + {side === 'left' && textInput} ) }) diff --git a/src/components/proxy/proxy-groups-chain.tsx b/src/components/proxy/proxy-groups-chain.tsx index 2b405e1a9..f60c0447d 100644 --- a/src/components/proxy/proxy-groups-chain.tsx +++ b/src/components/proxy/proxy-groups-chain.tsx @@ -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(() => { @@ -455,20 +461,25 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) { // Render virtual list for chain mode const renderProxyList = (height: string) => ( - + + + ) const showRuleHeader = mode === 'rule' && availableGroups.length > 0 diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index b2e36b2cb..2489b2ab0 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -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 (
- item.type === 0} - getItemKey={(item) => item.key} - estimateGroupItemHeight={76} - estimateItemHeight={64} - renderGroupItem={renderGroupItem} - renderItem={renderProxyItem} - /> + + item.type === 0} + getItemKey={(item) => item.key} + estimateGroupItemHeight={76} + estimateItemHeight={64} + renderGroupItem={renderGroupItem} + renderItem={renderProxyItem} + /> + {mode === 'rule' && ( + {enable_group_icon && group.icon?.trim().startsWith('http') && ( + {group.name} + )} + {enable_group_icon && group.icon?.trim().startsWith('data') && ( + {group.name} + )} + {enable_group_icon && group.icon?.trim().startsWith(' + )} + {group.name}} + secondary={ + + + {group.type} + + {group.now} + + + + } + slotProps={{ + secondary: { + component: 'div', + sx: { + display: 'flex', + alignItems: 'center', + color: '#ccc', + }, + }, + }} + /> + + ) + + const proxyCount = ( + +
+ + alpha(theme.palette.primary.main, 0.1), + color: (theme) => theme.palette.primary.main, + }} + /> +
+
+ ) + + const toolsBlock = ( + + {toolsOnLeft && proxyCount} + onLocation(group)} + onCheckDelay={() => onCheckAll(group.name)} + onHeadState={(p) => onHeadState(group.name, p)} + /> + {!toolsOnLeft && proxyCount} + + ) + return (
- {enable_group_icon && group.icon?.trim().startsWith('http') && ( - {group.name} - )} - {enable_group_icon && group.icon?.trim().startsWith('data') && ( - {group.name} - )} - {enable_group_icon && group.icon?.trim().startsWith(' - )} - {group.name}} - secondary={ - - - {group.type} - - {group.now} - - - - } - slotProps={{ - secondary: { - component: 'div', - sx: { - display: 'flex', - alignItems: 'center', - color: '#ccc', - }, - }, - }} - /> - - onLocation(group)} - onCheckDelay={() => onCheckAll(group.name)} - onHeadState={(p) => onHeadState(group.name, p)} - /> - -
- - alpha(theme.palette.primary.main, 0.1), - color: (theme) => theme.palette.primary.main, - }} - /> -
-
- {headState?.open ? ( - - ) : ( - - )} -
+ {toolsOnLeft ? toolsBlock : nameBlock} + {toolsOnLeft ? nameBlock : toolsBlock} + {headState?.open ? : }
diff --git a/src/components/proxy/use-proxy-group-header-layout.ts b/src/components/proxy/use-proxy-group-header-layout.ts new file mode 100644 index 000000000..d3bb1b1c4 --- /dev/null +++ b/src/components/proxy/use-proxy-group-header-layout.ts @@ -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 } +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index bbbf8db5d..24bef39ee 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -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'