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:
oomeow
2026-08-28 08:17:02 +00:00
committed by GitHub
co-authored by Tunglies
parent 181dfb87a4
commit b6de05ffe4
22 changed files with 1105 additions and 1115 deletions
+1
View File
@@ -17,3 +17,4 @@ export {
StickyVirtualList,
type StickyVirtualListHandle,
} from './sticky-virtual-list'
export { SortableItem } from './sortable-item'
+60
View File
@@ -0,0 +1,60 @@
import { useSortable } from '@dnd-kit/react/sortable'
import { useTheme } from '@mui/material'
import { type CSSProperties, type ReactNode, useCallback } from 'react'
export interface SortableItemRenderProps {
ref: (element: HTMLElement | null) => void
handleRef: (element: HTMLElement | null) => void
style: CSSProperties
}
interface SortableItemProps {
id: string
index: number
group?: string
disabled?: boolean
style?: CSSProperties
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
}
export const SortableItem = (props: SortableItemProps) => {
const { id, index, group, disabled, children, style } = props
const { ref, handleRef, sortable, isDragging } = useSortable({
id,
index,
group,
disabled,
})
const theme = useTheme()
const setRef = useCallback(
(element: HTMLDivElement | null) => {
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
ref(element)
},
[handleRef, ref],
)
const mergedStyle: CSSProperties = {
position: 'relative',
borderRadius: 8,
...style,
...(sortable.transition && {
transition: `${sortable.transition.duration ?? 300}ms ${sortable.transition.easing ?? 'ease'} transform`,
}),
...(isDragging && {
zIndex: 100,
// 拖拽中的元素置为不透明背景,避免半透明卡片透出下层内容。 投影由全局 [data-dnd-dragging] 规则统一施加。
backgroundColor: theme.palette.background.paper,
}),
}
if (typeof children === 'function') {
return children({ ref, handleRef, style: mergedStyle })
}
return (
<div ref={setRef} style={mergedStyle}>
{children}
</div>
)
}
@@ -1,13 +1,10 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
closestCenter,
DndContext,
DragDropProvider,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
} from '@dnd-kit/react'
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
import { DragIndicatorRounded } from '@mui/icons-material'
import {
Button,
@@ -21,9 +18,13 @@ import {
ListItem,
ListItemText,
} from '@mui/material'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const columnPointerSensor = PointerSensor.configure({
activationConstraints: () => undefined,
})
export interface ConnectionColumnOption {
id: string
label: string
@@ -46,14 +47,8 @@ export const ConnectionColumnManager = ({
onOrderChange,
onReset,
}: Props) => {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
)
const { t } = useTranslation()
const items = useMemo(() => columns.map((column) => column.id), [columns])
const visibleCount = useMemo(
() => columns.filter((column) => column.visible).length,
[columns],
@@ -61,13 +56,21 @@ export const ConnectionColumnManager = ({
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const order = columns.map((column) => column.id)
const oldIndex = order.indexOf(active.id as string)
const newIndex = order.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
const { index: newIndex, initialIndex: oldIndex } = source.sortable
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= order.length ||
newIndex >= order.length ||
oldIndex === newIndex
) {
return
}
onOrderChange(arrayMove(order, oldIndex, newIndex))
},
@@ -80,30 +83,29 @@ export const ConnectionColumnManager = ({
{t('connections.components.columnManager.title')}
</DialogTitle>
<DialogContent sx={{ pt: 1 }}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
<DragDropProvider
sensors={[columnPointerSensor]}
onDragEnd={handleDragEnd}
>
<SortableContext items={items}>
<List
dense
disablePadding
sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}
>
{columns.map((column) => (
<SortableColumnItem
key={column.id}
column={column}
dragHandleLabel={t(
'connections.components.columnManager.dragHandle',
)}
disableToggle={column.visible && visibleCount <= 1}
/>
))}
</List>
</SortableContext>
</DndContext>
<List
dense
disablePadding
sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}
>
{columns.map((column, index) => (
<SortableColumnItem
key={column.id}
id={column.id}
index={index}
column={column}
dragHandleLabel={t(
'connections.components.columnManager.dragHandle',
)}
disableToggle={column.visible && visibleCount <= 1}
/>
))}
</List>
</DragDropProvider>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="text" onClick={onReset}>
@@ -118,48 +120,44 @@ export const ConnectionColumnManager = ({
}
interface SortableColumnItemProps {
id: string
index: number
column: ConnectionColumnOption
dragHandleLabel: string
disableToggle?: boolean
}
const SortableColumnItem = ({
id,
index,
column,
dragHandleLabel,
disableToggle = false,
}: SortableColumnItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: column.id })
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
}),
[transform, transition],
)
const [element, setElement] = useState<Element | null>(null)
const handleRef = useRef<HTMLButtonElement | null>(null)
const { isDragging } = useSortable({
id,
index,
element,
handle: handleRef,
})
return (
<ListItem
ref={setNodeRef}
ref={setElement}
disableGutters
sx={{
px: 1,
py: 0.5,
borderRadius: 1,
border: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: isDragging ? 'action.hover' : 'transparent',
// 拖拽中的行置为不透明背景,避免透出下方内容;投影由全局 [data-dnd-dragging] 规则统一施加。
backgroundColor: isDragging ? 'background.paper' : 'transparent',
display: 'flex',
alignItems: 'center',
gap: 1,
}}
style={style}
>
<Checkbox
edge="start"
@@ -175,10 +173,9 @@ const SortableColumnItem = ({
<IconButton
edge="end"
size="small"
ref={handleRef}
sx={{ cursor: isDragging ? 'grabbing' : 'grab' }}
aria-label={dragHandleLabel}
{...attributes}
{...listeners}
>
<DragIndicatorRounded fontSize="small" />
</IconButton>
+61 -56
View File
@@ -1,15 +1,11 @@
import {
DndContext,
closestCenter,
DragDropProvider,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
} from '@dnd-kit/core'
import { SortableContext } from '@dnd-kit/sortable'
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable } from '@dnd-kit/react/sortable'
import { Add, NetworkCheck } from '@mui/icons-material'
import { Box, IconButton, Tooltip, alpha, styled, Grid } from '@mui/material'
import { Box, IconButton, Tooltip, alpha, styled } from '@mui/material'
import { emit } from '@tauri-apps/api/event'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useMemo, useCallback } from 'react'
@@ -20,8 +16,9 @@ import apple from '@/assets/image/test/apple.svg?raw'
import github from '@/assets/image/test/github.svg?raw'
import google from '@/assets/image/test/google.svg?raw'
import youtube from '@/assets/image/test/youtube.svg?raw'
import { SortableItem } from '@/components/base'
import { TestItem } from '@/components/test/test-item'
import { TestViewer, TestViewerRef } from '@/components/test/test-viewer'
import { TestViewer, type TestViewerRef } from '@/components/test/test-viewer'
import { useVerge } from '@/hooks/use-verge'
import { EnhancedCard } from './enhanced-card'
@@ -70,11 +67,6 @@ const DEFAULT_TEST_LIST = [
export const TestCard = () => {
const { t } = useTranslation()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
)
const { verge, mutateVerge, patchVerge } = useVerge()
const viewerRef = useRef<TestViewerRef>(null)
@@ -111,29 +103,42 @@ export const TestCard = () => {
const onDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const { operation, canceled } = event
const { source, target } = operation
const old_index = testList.findIndex((x) => x.uid === active.id)
const new_index = testList.findIndex((x) => x.uid === over.id)
if (canceled || !target || !isSortable(source)) return
if (old_index >= 0 && new_index >= 0) {
const newList = [...testList]
const [removed] = newList.splice(old_index, 1)
newList.splice(new_index, 0, removed)
const { index: newIndex, initialIndex: oldIndex } = source.sortable
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= testList.length ||
newIndex >= testList.length ||
oldIndex === newIndex
) {
return
}
// 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList }, false)
const patchFn = () => {
try {
patchVerge({ test_list: newList })
} catch {}
}
if (window.requestIdleCallback) {
window.requestIdleCallback(patchFn)
} else {
setTimeout(patchFn, 0)
}
const activeId = testList[oldIndex].uid
const overId = testList[newIndex].uid
if (activeId == null || overId == null || activeId === overId) return
const newList = [...testList]
const [removed] = newList.splice(oldIndex, 1)
newList.splice(newIndex, 0, removed)
// 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList }, false)
const patchFn = () => {
try {
patchVerge({ test_list: newList })
} catch {}
}
if (window.requestIdleCallback) {
window.requestIdleCallback(patchFn)
} else {
setTimeout(patchFn, 0)
}
},
[testList, verge, mutateVerge, patchVerge],
@@ -149,20 +154,25 @@ export const TestCard = () => {
// 使用useMemo优化UI内容,减少渲染计算
const renderTestItems = useMemo(
() => (
<Grid container spacing={1} columns={12}>
<SortableContext items={testList.map((x) => x.uid)}>
{testList.map((item) => (
<Grid key={item.uid} size={3}>
<TestItem
id={item.uid}
itemData={item}
onEdit={() => viewerRef.current?.edit(item)}
onDelete={onDeleteTestListItem}
/>
</Grid>
))}
</SortableContext>
</Grid>
<Box
sx={{
mb: 1.5,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(80px, 1fr))',
gap: 1,
px: 1,
}}
>
{testList.map((item, index) => (
<SortableItem key={item.uid} id={item.uid} index={index}>
<TestItem
itemData={item}
onEdit={() => viewerRef.current?.edit(item)}
onDelete={onDeleteTestListItem}
/>
</SortableItem>
))}
</Box>
),
[testList, onDeleteTestListItem],
)
@@ -195,14 +205,9 @@ export const TestCard = () => {
}
>
<ScrollBox>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<DragDropProvider sensors={[PointerSensor]} onDragEnd={onDragEnd}>
{renderTestItems}
<DragOverlay />
</DndContext>
</DragDropProvider>
</ScrollBox>
<TestViewer ref={viewerRef} onChange={onTestListItemChange} />
+10 -46
View File
@@ -1,7 +1,3 @@
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from '@dnd-kit/core'
import {
alpha,
ListItem,
@@ -9,26 +5,17 @@ import {
ListItemIcon,
ListItemText,
} from '@mui/material'
import type { CSSProperties, PointerEvent, ReactNode } from 'react'
import { useCallback } from 'react'
import type { ReactNode } from 'react'
import { useMatch, useNavigate, useResolvedPath } from 'react-router'
import type { SortableItemRenderProps } from '@/components/base/sortable-item'
import { useVerge } from '@/hooks/use-verge'
interface SortableProps {
setNodeRef?: (element: HTMLElement | null) => void
attributes?: DraggableAttributes
listeners?: DraggableSyntheticListeners
style?: CSSProperties
isDragging?: boolean
disabled?: boolean
}
interface Props {
to: string
children: string
icon: ReactNode[]
sortable?: SortableProps
sortable?: SortableItemRenderProps
}
export const LayoutItem = (props: Props) => {
const { to, children, icon, sortable } = props
@@ -42,34 +29,15 @@ export const LayoutItem = (props: Props) => {
const effectiveMenuIcon =
navCollapsed && menu_icon === 'disable' ? 'monochrome' : menu_icon
const { setNodeRef, attributes, listeners, style, isDragging, disabled } =
sortable ?? {}
const draggable = Boolean(sortable) && !disabled
const { onPointerDown, ...otherListeners } = draggable
? (listeners ?? {})
: {}
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
onPointerDown?.(event)
},
[onPointerDown],
)
return (
<ListItem
ref={setNodeRef}
style={style}
sx={[
{ py: 0.5, maxWidth: 250, mx: 'auto', padding: '4px 0px' },
isDragging ? { opacity: 0.78 } : {},
]}
ref={sortable?.ref}
style={sortable?.style}
sx={{ py: 0.5, maxWidth: 250, mx: 'auto', padding: '4px 0px' }}
>
<ListItemButton
ref={sortable?.handleRef}
selected={!!match}
{...(draggable ? (attributes ?? {}) : {})}
{...(draggable ? otherListeners : {})}
sx={[
{
borderRadius: 2,
@@ -77,8 +45,7 @@ export const LayoutItem = (props: Props) => {
paddingLeft: 1,
paddingRight: 1,
marginRight: 1.25,
cursor: draggable ? 'grab' : 'pointer',
'&:active': draggable ? { cursor: 'grabbing' } : {},
cursor: 'pointer',
'& .MuiListItemText-primary': {
color: 'text.primary',
fontWeight: '700',
@@ -99,7 +66,6 @@ export const LayoutItem = (props: Props) => {
]}
title={navCollapsed ? children : undefined}
aria-label={navCollapsed ? children : undefined}
onPointerDown={handlePointerDown}
onClick={() => navigate(to)}
>
{(effectiveMenuIcon === 'monochrome' || !effectiveMenuIcon) && (
@@ -107,16 +73,14 @@ export const LayoutItem = (props: Props) => {
sx={{
color: 'text.primary',
marginLeft: '6px',
cursor: draggable ? 'grab' : 'inherit',
cursor: 'inherit',
}}
>
{icon[0]}
</ListItemIcon>
)}
{effectiveMenuIcon === 'colorful' && (
<ListItemIcon sx={{ cursor: draggable ? 'grab' : 'inherit' }}>
{icon[1]}
</ListItemIcon>
<ListItemIcon sx={{ cursor: 'inherit' }}>{icon[1]}</ListItemIcon>
)}
<ListItemText
sx={{
+9 -29
View File
@@ -1,5 +1,3 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import {
Box,
@@ -19,22 +17,7 @@ interface Props {
export const GroupItem = (props: Props) => {
const { type, group, onDelete } = props
const sortable = type === 'prepend' || type === 'append'
const {
attributes: sortableAttributes,
listeners: sortableListeners,
setNodeRef: sortableSetNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: group.name,
disabled: !sortable,
})
const dragAttributes = sortable ? sortableAttributes : undefined
const dragListeners = sortable ? sortableListeners : undefined
const dragNodeRef = sortable ? sortableSetNodeRef : undefined
const isSortable = type === 'prepend' || type === 'append'
const iconCachePath = useIconCache({
icon: group.icon,
@@ -55,15 +38,12 @@ export const GroupItem = (props: Props) => {
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: '100%',
margin: '8px 0',
borderRadius: '8px',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined,
})}
>
{group.icon && group.icon?.trim().startsWith('http') && (
{group?.icon?.trim().startsWith('http') && (
<img
alt="group icon"
src={iconCachePath === '' ? group.icon : iconCachePath}
width="32px"
style={{
@@ -72,8 +52,9 @@ export const GroupItem = (props: Props) => {
}}
/>
)}
{group.icon && group.icon?.trim().startsWith('data') && (
{group?.icon?.trim().startsWith('data') && (
<img
alt="group icon"
src={group.icon}
width="32px"
style={{
@@ -82,8 +63,9 @@ export const GroupItem = (props: Props) => {
}}
/>
)}
{group.icon && group.icon?.trim().startsWith('<svg') && (
{group?.icon?.trim().startsWith('<svg') && (
<img
alt="group icon"
src={`data:image/svg+xml;base64,${btoa(group.icon ?? '')}`}
width="32px"
style={{
@@ -93,10 +75,8 @@ export const GroupItem = (props: Props) => {
/>
)}
<ListItemText
{...(dragAttributes ?? {})}
{...(dragListeners ?? {})}
ref={dragNodeRef}
sx={{ cursor: sortable ? 'move' : '' }}
data-sortable-handle
sx={{ cursor: isSortable ? 'move' : undefined }}
primary={
<StyledPrimary
sx={{ textDecoration: type === 'delete' ? 'line-through' : '' }}
+156 -118
View File
@@ -1,17 +1,12 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DndContext,
DragEndEvent,
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -51,6 +46,7 @@ import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
@@ -141,6 +137,16 @@ const buildGroupsYaml = (
)
}
const findRealIndex = (
list: IProxyGroupConfig[],
filtered: IProxyGroupConfig[],
filteredIndex: number,
): number => {
const item = filtered[filteredIndex]
if (!item) return -1
return list.findIndex((group) => group.name === item.name)
}
export const GroupsEditorViewer = (props: Props) => {
const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } =
props
@@ -201,20 +207,17 @@ export const GroupsEditorViewer = (props: Props) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
@@ -223,109 +226,138 @@ export const GroupsEditorViewer = (props: Props) => {
)
}}
/>
)
})}
</SortableContext>
</DndContext>
</SortableItem>
)
})}
</>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredGroupList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
<Box sx={{ margin: '8px 0' }}>
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
}}
/>
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
</Box>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
</SortableItem>
)
})}
</>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over) {
if (active.id !== over.id) {
let activeIndex = 0
let overIndex = 0
prependSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index
}
if (item.name === over.id) {
overIndex = index
}
})
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
setPrependSeq(arrayMove(prependSeq, activeIndex, overIndex))
}
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over) {
if (active.id !== over.id) {
let activeIndex = 0
let overIndex = 0
appendSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index
}
if (item.name === over.id) {
overIndex = index
}
})
setAppendSeq(arrayMove(appendSeq, activeIndex, overIndex))
}
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
}
// prevent drag over if source and target are in different groups
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
@@ -756,7 +788,7 @@ export const GroupsEditorViewer = (props: Props) => {
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value))
field.onChange(parseInt(e.target.value, 10))
}}
/>
</Item>
@@ -779,7 +811,7 @@ export const GroupsEditorViewer = (props: Props) => {
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value))
field.onChange(parseInt(e.target.value, 10))
}}
slotProps={{
input: {
@@ -807,7 +839,7 @@ export const GroupsEditorViewer = (props: Props) => {
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value))
field.onChange(parseInt(e.target.value, 10))
}}
slotProps={{
input: {
@@ -839,7 +871,7 @@ export const GroupsEditorViewer = (props: Props) => {
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value))
field.onChange(parseInt(e.target.value, 10))
}}
/>
</Item>
@@ -882,7 +914,7 @@ export const GroupsEditorViewer = (props: Props) => {
size="small"
sx={{ width: 'calc(100% - 150px)' }}
onChange={(e) => {
field.onChange(parseInt(e.target.value))
field.onChange(parseInt(e.target.value, 10))
}}
/>
</Item>
@@ -1120,16 +1152,22 @@ export const GroupsEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<VirtualList
count={
filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
</List>
</>
) : (
+16 -14
View File
@@ -1,7 +1,4 @@
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from '@dnd-kit/core'
import { useSortable } from '@dnd-kit/react/sortable'
import {
CheckBoxOutlineBlankRounded,
CheckBoxRounded,
@@ -58,6 +55,8 @@ const round = keyframes`
`
export interface ProfileItemProps {
id: string
index: number
selected: boolean
activating: boolean
itemData: IProfileItem
@@ -71,13 +70,12 @@ export interface ProfileItemProps {
onSelectionChange?: () => void
timerUpdateRevision: number
completedUpdateRevision: number
dragHandleRef: (node: HTMLElement | null) => void
dragHandleAttributes: DraggableAttributes
dragHandleListeners: DraggableSyntheticListeners
}
const ProfileItemBase = (props: ProfileItemProps) => {
const {
id,
index,
selected,
activating,
itemData,
@@ -91,11 +89,17 @@ const ProfileItemBase = (props: ProfileItemProps) => {
onSelectionChange,
timerUpdateRevision,
completedUpdateRevision,
dragHandleRef,
dragHandleAttributes,
dragHandleListeners,
} = props
const [element, setElement] = useState<Element | null>(null)
const handleRef = useRef<HTMLButtonElement | null>(null)
useSortable({
id,
index,
element,
handle: handleRef,
})
const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [position, setPosition] = useState({ left: 0, top: 0 })
@@ -616,7 +620,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
})
return (
<Box sx={{ position: 'relative' }}>
<Box ref={setElement} sx={{ position: 'relative', borderRadius: '8px' }}>
<ProfileBox
aria-selected={selected}
onClick={(e) => {
@@ -678,14 +682,12 @@ const ProfileItemBase = (props: ProfileItemProps) => {
</IconButton>
)}
<Box
ref={dragHandleRef}
ref={handleRef}
sx={{
display: 'flex',
margin: 'auto 0',
...(batchMode && { marginLeft: '-4px' }),
}}
{...dragHandleAttributes}
{...dragHandleListeners}
>
<DragIndicatorRounded
sx={[
+161 -122
View File
@@ -1,17 +1,12 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DndContext,
DragEndEvent,
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -40,7 +35,12 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, MonacoEditor, VirtualList } from '@/components/base'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
VirtualList,
} from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -58,6 +58,24 @@ interface Props {
onSave?: (prev?: string, curr?: string) => void
}
const findRealIndex = (
list: IProxyConfig[],
filtered: IProxyConfig[],
filteredIndex: number,
): number => {
const item = filtered[filteredIndex]
if (!item) return -1
return list.findIndex((proxy) => proxy.name === item.name)
}
// 节点的 name 会被用作 sortable item id、React key 以及拖拽排序的
// 依据。当 name 为空/null(例如高级模式下粘贴了缺少 name 的节点)时,
// 无效 name 不能作为 sortable item id,否则会导致拖拽注册失败。
// 这里统一过滤掉没有有效 name 的节点,避免可视化编辑页崩溃;原始 YAML
// 数据仍然保留,用户可在高级(文本)模式中查看并修正这些节点。
const hasValidName = (proxy: IProxyConfig) =>
typeof proxy?.name === 'string' && proxy.name.length > 0
export const ProxiesEditorViewer = (props: Props) => {
const { profileUid, property, open, onClose, onSave } = props
const { t } = useTranslation()
@@ -75,14 +93,6 @@ export const ProxiesEditorViewer = (props: Props) => {
const [deleteSeq, setDeleteSeq] = useState<string[]>([])
const hasLoadedSeqConfigRef = useRef(false)
// 节点的 name 会被用作 SortableContext 的 item id、React key 以及拖拽排序的
// 依据。当 name 为空/null(例如高级模式下粘贴了缺少 name 的节点)时,
// @dnd-kit 的 SortableContext 会对 null 执行 `'id' in item` 从而崩溃
// (Cannot use 'in' operator to search for 'id' in null)。
// 这里统一过滤掉没有有效 name 的节点,避免可视化编辑页崩溃;原始 YAML
// 数据仍然保留,用户可在高级(文本)模式中查看并修正这些节点。
const hasValidName = (proxy: IProxyConfig) =>
typeof proxy?.name === 'string' && proxy.name.length > 0
const filteredPrependSeq = useMemo(
() =>
prependSeq.filter((proxy) => hasValidName(proxy) && match(proxy.name)),
@@ -101,20 +111,17 @@ export const ProxiesEditorViewer = (props: Props) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<ProxyItem
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
@@ -123,109 +130,135 @@ export const ProxiesEditorViewer = (props: Props) => {
)
}}
/>
)
})}
</SortableContext>
</DndContext>
</SortableItem>
)
})}
</>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredProxyList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
<Box sx={{ margin: '8px 0' }}>
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
}}
/>
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
</Box>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<ProxyItem
key={item.name}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
</SortableItem>
)
})}
</>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over) {
if (active.id !== over.id) {
let activeIndex = 0
let overIndex = 0
prependSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index
}
if (item.name === over.id) {
overIndex = index
}
})
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
setPrependSeq(arrayMove(prependSeq, activeIndex, overIndex))
}
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over) {
if (active.id !== over.id) {
let activeIndex = 0
let overIndex = 0
appendSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index
}
if (item.name === over.id) {
overIndex = index
}
})
setAppendSeq(arrayMove(appendSeq, activeIndex, overIndex))
}
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
@@ -489,16 +522,22 @@ export const ProxiesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<VirtualList
count={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
</List>
</>
) : (
+3 -26
View File
@@ -1,5 +1,3 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import {
Box,
@@ -18,22 +16,7 @@ interface Props {
export const ProxyItem = (props: Props) => {
const { type, proxy, onDelete } = props
const sortable = type === 'prepend' || type === 'append'
const {
attributes: sortableAttributes,
listeners: sortableListeners,
setNodeRef: sortableSetNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: proxy.name,
disabled: !sortable,
})
const dragAttributes = sortable ? sortableAttributes : undefined
const dragListeners = sortable ? sortableListeners : undefined
const dragNodeRef = sortable ? sortableSetNodeRef : undefined
const isSortable = type === 'prepend' || type === 'append'
return (
<ListItem
@@ -49,18 +32,12 @@ export const ProxyItem = (props: Props) => {
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: '100%',
margin: '8px 0',
borderRadius: '8px',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined,
})}
>
<ListItemText
{...(dragAttributes ?? {})}
{...(dragListeners ?? {})}
ref={dragNodeRef}
sx={{ cursor: sortable ? 'move' : '' }}
data-sortable-handle
sx={{ cursor: isSortable ? 'move' : undefined }}
primary={
<StyledPrimary
title={proxy.name}
+4 -30
View File
@@ -1,5 +1,3 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
DeleteForeverRounded,
UndoRounded,
@@ -14,6 +12,7 @@ import {
alpha,
styled,
} from '@mui/material'
interface Props {
type: 'prepend' | 'original' | 'delete' | 'append'
ruleRaw: string
@@ -24,32 +23,13 @@ interface Props {
export const RuleItem = (props: Props) => {
const { type, ruleRaw, onDelete, onPrepend, onAppend } = props
const sortable = type === 'prepend' || type === 'append'
const isSortable = type === 'prepend' || type === 'append'
const rule = ruleRaw.replace(',no-resolve', '')
const ruleType = rule.match(/^[^,]+/)?.[0] ?? ''
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? ''
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1)
const $sortable = useSortable({ id: ruleRaw })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = sortable
? $sortable
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
}
return (
<ListItem
dense
@@ -64,18 +44,12 @@ export const RuleItem = (props: Props) => {
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: '100%',
margin: '8px 0',
borderRadius: '8px',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined,
})}
>
<ListItemText
{...attributes}
{...listeners}
ref={setNodeRef}
sx={{ cursor: sortable ? 'move' : '' }}
data-sortable-handle
sx={{ cursor: isSortable ? 'move' : undefined }}
primary={
<StyledPrimary
title={ruleContent || '-'}
+143 -90
View File
@@ -1,13 +1,11 @@
import {
DndContext,
DragEndEvent,
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -41,6 +39,7 @@ import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
@@ -252,6 +251,16 @@ const PROXY_POLICY_LABEL_KEYS: Record<string, TranslationKey> =
{} as Record<string, TranslationKey>,
)
const findRealIndex = (
list: string[],
filtered: string[],
filteredIndex: number,
): number => {
const item = filtered[filteredIndex]
if (item === undefined) return -1
return list.indexOf(item)
}
export const RulesEditorViewer = (props: Props) => {
const { groupsUid, mergeUid, profileUid, property, open, onClose, onSave } =
props
@@ -296,20 +305,17 @@ export const RulesEditorViewer = (props: Props) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x
})}
>
{filteredPrependSeq.map((item) => {
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`prepend:${item}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<RuleItem
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
@@ -322,49 +328,48 @@ export const RulesEditorViewer = (props: Props) => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
</SortableItem>
)
})}
</>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
<Box sx={{ margin: '8px 0' }}>
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
}}
/>
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
</Box>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x
})}
>
{filteredAppendSeq.map((item) => {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`append:${item}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<RuleItem
key={item}
type="append"
ruleRaw={item}
onDelete={() => {
@@ -377,22 +382,14 @@ export const RulesEditorViewer = (props: Props) => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
</SortableItem>
)
})}
</>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const reorder = (list: string[], startIndex: number, endIndex: number) => {
const result = Array.from(list)
const [removed] = result.splice(startIndex, 1)
@@ -400,23 +397,73 @@ export const RulesEditorViewer = (props: Props) => {
return result
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over) {
if (active.id !== over.id) {
const activeIndex = prependSeq.indexOf(active.id.toString())
const overIndex = prependSeq.indexOf(over.id.toString())
setPrependSeq(reorder(prependSeq, activeIndex, overIndex))
}
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setPrependSeq(reorder(prependSeq, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over) {
if (active.id !== over.id) {
const activeIndex = appendSeq.indexOf(active.id.toString())
const overIndex = appendSeq.indexOf(over.id.toString())
setAppendSeq(reorder(appendSeq, activeIndex, overIndex))
}
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(reorder(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
@@ -806,16 +853,22 @@ export const RulesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<VirtualList
count={
filteredRuleList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredRuleList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
</List>
</>
) : (
@@ -1,55 +0,0 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Box } from '@mui/material'
import { ProfileItem, type ProfileItemProps } from './profile-item'
const SORT_TRANSITION = 'transform 160ms cubic-bezier(0.2, 0, 0, 1)'
type SortableProfileItemProps = Omit<
ProfileItemProps,
'dragHandleRef' | 'dragHandleAttributes' | 'dragHandleListeners'
> & {
id: string
}
export const SortableProfileItem = ({
id,
...profileItemProps
}: SortableProfileItemProps) => {
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
isSorting,
isDragging,
isOver,
} = useSortable({ id, transition: null })
const isDiagonalMove =
transform !== null && transform.x !== 0 && transform.y !== 0
return (
<Box
ref={setNodeRef}
sx={{
position: 'relative',
transform: CSS.Translate.toString(transform),
transition:
isSorting && !isDragging && isOver && !isDiagonalMove
? SORT_TRANSITION
: undefined,
zIndex: isDragging ? 'calc(infinity)' : undefined,
}}
>
<ProfileItem
{...profileItemProps}
dragHandleRef={setActivatorNodeRef}
dragHandleAttributes={attributes}
dragHandleListeners={listeners}
/>
</Box>
)
}
+168 -124
View File
@@ -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>
+3 -34
View File
@@ -1,5 +1,3 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { LanguageRounded } from '@mui/icons-material'
import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material'
import { useLockFn } from 'ahooks'
@@ -16,29 +14,12 @@ import { showNotice } from '@/services/notice-service'
import { TestBox } from './test-box'
interface Props {
id: string
itemData: IVergeTestItem
onEdit: () => void
onDelete: (uid: string) => void
}
export const TestItem = ({
id,
itemData,
onEdit,
onDelete: removeTest,
}: Props) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id,
})
export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<any>(null)
const [position, setPosition] = useState({ left: 0, top: 0 })
@@ -77,14 +58,7 @@ export const TestItem = ({
)
return (
<Box
sx={{
position: 'relative',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined,
}}
>
<Box>
<TestBox
onContextMenu={(event) => {
const { clientX, clientY } = event
@@ -93,12 +67,7 @@ export const TestItem = ({
event.preventDefault()
}}
>
<Box
sx={{ position: 'relative', cursor: 'move' }}
ref={setNodeRef}
{...attributes}
{...listeners}
>
<Box data-sortable-handle sx={{ position: 'relative', cursor: 'move' }}>
{icon && icon.trim() !== '' ? (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{icon.trim().startsWith('http') && (