mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-30 10:42:09 +08:00
refactor: migrate to @dnd-kit/react and simplify sortable components (#7819)
* refactor: migrate to @dnd-kit/react and simplify sortable components * chore: enable css formatting in biome.json * refactor(profile): simplify icon rendering logic and add alt text * style(profiles): add overflow hidden to profile grid container * refactor(profile): add radix for parseInt and improve type safety in profiles * docs: update changelog with drag-and-drop styling improvements * refactor(profile): move hasValidName function out of ProxiesEditorViewer * refactor(proxy): refactor proxy chain into sortable components * feat(proxy): add drop state styling and arrow animations to proxy chain * style(profiles): reduce horizontal padding in profile grid * feat(profile): add vertical margin to editor viewer items * chore: refresh pnpm lockfile * fix: preserve drag and drop behavior --------- Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
@@ -1,20 +1,11 @@
|
||||
import { arrayMove } from '@dnd-kit/helpers'
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragDropProvider,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
|
||||
import {
|
||||
ArrowDownward,
|
||||
Delete as DeleteIcon,
|
||||
@@ -31,10 +22,18 @@ import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Typography,
|
||||
keyframes,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
closeAllConnections,
|
||||
@@ -54,6 +53,10 @@ import { debugLog } from '@/utils/debug'
|
||||
|
||||
import { rebindProxyChainItems, type ProxyChainItem } from './proxy-chain-model'
|
||||
|
||||
const chainPointerSensor = PointerSensor.configure({
|
||||
activationConstraints: () => undefined,
|
||||
})
|
||||
|
||||
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
|
||||
|
||||
interface ParsedChainConfig {
|
||||
@@ -73,7 +76,8 @@ interface ProxyChainProps {
|
||||
selectedGroup?: string | null
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
interface ProxyChainItemProps {
|
||||
id: string
|
||||
proxy: ProxyChainItem
|
||||
index: number
|
||||
isFirst: boolean
|
||||
@@ -81,6 +85,17 @@ interface SortableItemProps {
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
const arrowFadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
`
|
||||
|
||||
const toChainItems = (
|
||||
parsedConfig: ParsedChainConfig | null | undefined,
|
||||
): ProxyChainItem[] => {
|
||||
@@ -96,29 +111,29 @@ const toChainItems = (
|
||||
)
|
||||
}
|
||||
|
||||
const SortableItem = ({
|
||||
interface ChainCardProps {
|
||||
proxy: ProxyChainItem
|
||||
index: number
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
isDragging?: boolean
|
||||
isDropping?: boolean
|
||||
handleRef?: Ref<HTMLElement> | null
|
||||
onRemove?: (id: string) => void
|
||||
}
|
||||
|
||||
const ChainCard = ({
|
||||
proxy,
|
||||
index,
|
||||
isFirst,
|
||||
isLast,
|
||||
isDragging,
|
||||
isDropping,
|
||||
handleRef,
|
||||
onRemove,
|
||||
}: SortableItemProps) => {
|
||||
}: ChainCardProps) => {
|
||||
const theme = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: proxy.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const roleLabel = isFirst
|
||||
? t('proxies.page.chain.entryNode')
|
||||
@@ -134,37 +149,31 @@ const SortableItem = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
sx={{
|
||||
mb: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
backgroundColor: isDragging
|
||||
? theme.palette.action.selected
|
||||
: theme.palette.background.default,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
borderRadius: 1,
|
||||
border: roleColor
|
||||
? `1.5px solid ${roleColor}`
|
||||
: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: isDragging ? theme.shadows[4] : theme.shadows[1],
|
||||
transition: 'box-shadow 0.2s, background-color 0.2s',
|
||||
opacity: proxy.recordId === undefined ? 0.55 : undefined,
|
||||
transition: 'box-shadow 0.2s, background-color 0.2s',
|
||||
boxShadow: isDropping
|
||||
? `0 0 0 2px ${theme.palette.primary.main}66`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={handleRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 1,
|
||||
color: theme.palette.text.secondary,
|
||||
cursor: 'grab',
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
}}
|
||||
>
|
||||
<DragIndicator />
|
||||
@@ -229,18 +238,87 @@ const SortableItem = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemove(proxy.id)}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.error.light + '20',
|
||||
{onRemove && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemove(proxy.id)}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.error.light + '20',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableProxyChainItem = ({
|
||||
id,
|
||||
proxy,
|
||||
index,
|
||||
isFirst,
|
||||
isLast,
|
||||
onRemove,
|
||||
}: ProxyChainItemProps) => {
|
||||
const theme = useTheme()
|
||||
const [element, setElement] = useState<Element | null>(null)
|
||||
const handleRef = useRef<HTMLElement | null>(null)
|
||||
const { isDragging, isDropping } = useSortable({
|
||||
id,
|
||||
index,
|
||||
element,
|
||||
handle: handleRef,
|
||||
})
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setElement}
|
||||
sx={{
|
||||
width: '100%',
|
||||
'&[data-dnd-dragging]': {
|
||||
height: 'auto',
|
||||
boxShadow: 'none !important',
|
||||
'.proxy-chain-arrow': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
},
|
||||
'& .proxy-chain-arrow': {
|
||||
animation: `${arrowFadeIn} 0.25s ease`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ChainCard
|
||||
proxy={proxy}
|
||||
index={index}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
isDragging={isDragging}
|
||||
isDropping={isDropping}
|
||||
handleRef={handleRef}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
{!isLast && (
|
||||
<Box
|
||||
className="proxy-chain-arrow"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
py: 0.25,
|
||||
}}
|
||||
>
|
||||
<ArrowDownward
|
||||
sx={{
|
||||
fontSize: 20,
|
||||
color: theme.palette.primary.main,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -330,30 +408,25 @@ export const ProxyChain = ({
|
||||
chainLengthRef.current = currentProxyChain.length
|
||||
}, [currentProxyChain.length, markUnsavedChanges])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = currentProxyChain.findIndex(
|
||||
(item) => item.id === active.id,
|
||||
)
|
||||
const newIndex = currentProxyChain.findIndex(
|
||||
(item) => item.id === over?.id,
|
||||
)
|
||||
|
||||
onUpdateChain(arrayMove(currentProxyChain, oldIndex, newIndex))
|
||||
markUnsavedChanges()
|
||||
const { index: newIndex, initialIndex: oldIndex } = source.sortable
|
||||
if (
|
||||
oldIndex < 0 ||
|
||||
newIndex < 0 ||
|
||||
oldIndex >= currentProxyChain.length ||
|
||||
newIndex >= currentProxyChain.length ||
|
||||
oldIndex === newIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onUpdateChain(arrayMove(currentProxyChain, oldIndex, newIndex))
|
||||
markUnsavedChanges()
|
||||
},
|
||||
[currentProxyChain, onUpdateChain, markUnsavedChanges],
|
||||
)
|
||||
@@ -594,56 +667,27 @@ export const ProxyChain = ({
|
||||
<Typography>{t('proxies.page.chain.empty')}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
<DragDropProvider
|
||||
sensors={[chainPointerSensor, KeyboardSensor]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={currentProxyChain.map((proxy) => proxy.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
minHeight: 60,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
{currentProxyChain.map((proxy, index) => (
|
||||
<Box key={proxy.id}>
|
||||
<SortableItem
|
||||
proxy={proxy}
|
||||
index={index}
|
||||
isFirst={index === 0}
|
||||
isLast={
|
||||
index === currentProxyChain.length - 1 &&
|
||||
currentProxyChain.length > 1
|
||||
}
|
||||
onRemove={handleRemoveProxy}
|
||||
/>
|
||||
{index < currentProxyChain.length - 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
py: 0.25,
|
||||
}}
|
||||
>
|
||||
<ArrowDownward
|
||||
sx={{
|
||||
fontSize: 20,
|
||||
color: theme.palette.primary.main,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Box sx={{ borderRadius: 1, minHeight: 60, p: 1 }}>
|
||||
{currentProxyChain.map((proxy, index) => (
|
||||
<SortableProxyChainItem
|
||||
key={proxy.id}
|
||||
id={proxy.id}
|
||||
proxy={proxy}
|
||||
index={index}
|
||||
isFirst={index === 0}
|
||||
isLast={
|
||||
index === currentProxyChain.length - 1 &&
|
||||
currentProxyChain.length > 1
|
||||
}
|
||||
onRemove={handleRemoveProxy}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</DragDropProvider>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
Reference in New Issue
Block a user