mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-07 09:42:07 +08:00
feat(proxy): support dragging to swap proxy group header sections
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+6
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Vendored
+1
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user