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
@@ -53,5 +53,6 @@
- 配置变更后统一广播刷新,界面开关与托盘状态保持同步
- 重构命令层错误返回为结构化格式,保留完整诊断链
- 优化首页界面中当前节点固定状态显示
- 优化拖动排序样式及动画效果
</details>
+3 -4
View File
@@ -41,9 +41,8 @@
"knip:report": "knip --no-exit-code"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@dnd-kit/helpers": "^0.5.0",
"@dnd-kit/react": "^0.5.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
@@ -132,7 +131,7 @@
"eslint --fix --max-warnings=0",
"biome format --write"
],
"*.{css,scss,json}": [
"*.{css,json}": [
"biome format --write"
]
},
+63 -40
View File
@@ -109,15 +109,12 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.8)
'@dnd-kit/helpers':
specifier: ^0.5.0
version: 0.5.0
'@dnd-kit/react':
specifier: ^0.5.0
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@emotion/react':
specifier: ^11.14.0
version: 11.14.0(@types/react@19.2.18)(react@19.2.8)
@@ -933,27 +930,29 @@ packages:
cpu: [x64]
os: [win32]
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/abstract@0.5.0':
resolution: {integrity: sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==}
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/collision@0.5.0':
resolution: {integrity: sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==}
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/dom@0.5.0':
resolution: {integrity: sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==}
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
'@dnd-kit/geometry@0.5.0':
resolution: {integrity: sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==}
'@dnd-kit/helpers@0.5.0':
resolution: {integrity: sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg==}
'@dnd-kit/react@0.5.0':
resolution: {integrity: sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw==}
peerDependencies:
react: '>=16.8.0'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@dnd-kit/state@0.5.0':
resolution: {integrity: sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -1641,6 +1640,9 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@preact/signals-core@1.14.4':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
'@rolldown/binding-android-arm-eabi@1.2.6':
resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4775,29 +4777,48 @@ snapshots:
'@biomejs/cli-win32-x64@2.5.10':
optional: true
'@dnd-kit/accessibility@3.1.1(react@19.2.8)':
'@dnd-kit/abstract@0.5.0':
dependencies:
react: 19.2.8
'@dnd-kit/geometry': 0.5.0
'@dnd-kit/state': 0.5.0
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@dnd-kit/collision@0.5.0':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.8)
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
'@dnd-kit/abstract': 0.5.0
'@dnd-kit/geometry': 0.5.0
tslib: 2.8.1
'@dnd-kit/dom@0.5.0':
dependencies:
'@dnd-kit/abstract': 0.5.0
'@dnd-kit/collision': 0.5.0
'@dnd-kit/geometry': 0.5.0
'@dnd-kit/state': 0.5.0
tslib: 2.8.1
'@dnd-kit/geometry@0.5.0':
dependencies:
'@dnd-kit/state': 0.5.0
tslib: 2.8.1
'@dnd-kit/helpers@0.5.0':
dependencies:
'@dnd-kit/abstract': 0.5.0
tslib: 2.8.1
'@dnd-kit/react@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@dnd-kit/abstract': 0.5.0
'@dnd-kit/dom': 0.5.0
'@dnd-kit/state': 0.5.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)':
'@dnd-kit/state@0.5.0':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
react: 19.2.8
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.8)':
dependencies:
react: 19.2.8
'@preact/signals-core': 1.14.4
tslib: 2.8.1
'@emnapi/core@1.10.0':
@@ -5441,6 +5462,8 @@ snapshots:
'@popperjs/core@2.11.8': {}
'@preact/signals-core@1.14.4': {}
'@rolldown/binding-android-arm-eabi@1.2.6':
optional: true
+9 -5
View File
@@ -1,12 +1,12 @@
@use './layout.scss';
@use './page.scss';
@use './font.scss';
@use "./layout.scss";
@use "./page.scss";
@use "./font.scss";
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
@@ -59,3 +59,7 @@ body {
-moz-user-select: none;
-ms-user-select: none;
}
[data-dnd-dragging] {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32) !important;
}
+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') && (
+36 -87
View File
@@ -1,17 +1,4 @@
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { DragDropProvider, KeyboardSensor, PointerSensor } from '@dnd-kit/react'
import {
Box,
List,
@@ -23,7 +10,6 @@ import {
} from '@mui/material'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import type { CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useNavigate } from 'react-router'
@@ -31,7 +17,7 @@ import { Outlet, useNavigate } from 'react-router'
import iconDark from '@/assets/image/icon_dark.svg?react'
import iconLight from '@/assets/image/icon_light.svg?react'
import LogoSvg from '@/assets/image/logo.svg?react'
import { BaseErrorBoundary } from '@/components/base'
import { BaseErrorBoundary, SortableItem } from '@/components/base'
import { LayoutItem } from '@/components/layout/layout-item'
import { LayoutTraffic } from '@/components/layout/layout-traffic'
import { NoticeManager } from '@/components/layout/notice-manager'
@@ -68,43 +54,22 @@ type MenuContextPosition = { top: number; left: number }
interface SortableNavMenuItemProps {
item: NavItem
label: string
index: number
}
const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.path,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
}
if (isDragging) {
style.zIndex = 100
}
const SortableNavMenuItem = ({
item,
label,
index,
}: SortableNavMenuItemProps) => {
return (
<LayoutItem
to={item.path}
icon={item.icon}
sortable={{
setNodeRef,
attributes,
listeners,
style,
isDragging,
}}
>
{label}
</LayoutItem>
<SortableItem id={item.path} index={index}>
{(sortable) => (
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
{label}
</LayoutItem>
)}
</SortableItem>
)
}
@@ -131,17 +96,6 @@ const Layout = () => {
const windowControlsRef = useRef<any>(null)
const { decorated } = useWindowDecorations()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const handleMenuOrderOptimisticUpdate = useCallback(
(order: string[]) => {
mutateVerge(
@@ -365,32 +319,27 @@ const Layout = () => {
)}
{menuUnlocked ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
>
<SortableContext items={menuOrder}>
<List
className="the-menu"
onContextMenu={handleMenuContextMenu}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path)
if (!item) {
return null
}
return (
<SortableNavMenuItem
key={item.path}
item={item}
label={t(item.label)}
/>
)
})}
</List>
</SortableContext>
</DndContext>
<List className="the-menu" onContextMenu={handleMenuContextMenu}>
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragEnd={handleMenuDragEnd}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path)
if (!item) {
return null
}
return (
<SortableNavMenuItem
key={item.path}
item={item}
label={t(item.label)}
index={menuOrder.indexOf(path)}
/>
)
})}
</DragDropProvider>
</List>
) : (
<List className="the-menu" onContextMenu={handleMenuContextMenu}>
{menuOrder.map((path) => {
+18 -12
View File
@@ -1,5 +1,6 @@
import type { DragEndEvent } from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import { arrayMove } from '@dnd-kit/helpers'
import type { DragEndEvent } from '@dnd-kit/react'
import { isSortable } from '@dnd-kit/react/sortable'
import { useCallback, useEffect, useMemo, useReducer } from 'react'
type MenuOrderAction = { type: 'sync'; payload: string[] }
@@ -92,21 +93,26 @@ export const useNavMenuOrder = <T extends { path: string }>({
if (!enabled) {
return
}
const { operation, canceled } = event
const { source, target } = operation
const { active, over } = event
if (!over || active.id === over.id) {
if (canceled || !target || !isSortable(source)) return
const { index: newIndex, initialIndex: oldIndex } = source.sortable
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= menuOrder.length ||
newIndex >= menuOrder.length ||
oldIndex === newIndex
) {
return
}
const activeId = String(active.id)
const overId = String(over.id)
const activeId = menuOrder[oldIndex]
const overId = menuOrder[newIndex]
const oldIndex = menuOrder.indexOf(activeId)
const newIndex = menuOrder.indexOf(overId)
if (oldIndex === -1 || newIndex === -1) {
return
}
if (activeId == null || overId == null || activeId === overId) return
const previousOrder = [...menuOrder]
const nextOrder = arrayMove(menuOrder, oldIndex, newIndex)
+121 -161
View File
@@ -1,20 +1,10 @@
import {
closestCenter,
type CollisionDetection,
DndContext,
type DragEndEvent,
DragOverlay,
DragDropProvider,
KeyboardSensor,
pointerWithin,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
type SortingStrategy,
} from '@dnd-kit/sortable'
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable } from '@dnd-kit/react/sortable'
import {
CheckBoxOutlineBlankRounded,
CheckBoxRounded,
@@ -42,12 +32,12 @@ import {
BaseStyledTextField,
type DialogRef,
} from '@/components/base'
import { ProfileItem } from '@/components/profile/profile-item'
import { ProfileMore } from '@/components/profile/profile-more'
import {
ProfileViewer,
type ProfileViewerRef,
} from '@/components/profile/profile-viewer'
import { SortableProfileItem } from '@/components/profile/sortable-profile-item'
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
import { useListen } from '@/hooks/use-listen'
import { useProfiles } from '@/hooks/use-profiles'
@@ -78,48 +68,9 @@ import { debugLog } from '@/utils/debug'
// 与 src-tauri/src/main.rs 的 worker_limit 上限(8)保持一致,避免前后端更新风暴不对齐
const PROFILE_UPDATE_WORKER_LIMIT = 8
const PROFILE_SWITCH_LOADING_DELAY = 400
const profileCollisionDetection: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args)
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args)
}
// Equivalent to rectSortingStrategy without copying the full rect array for every item.
const profileRectSortingStrategy: SortingStrategy = ({
rects,
activeIndex,
overIndex,
index,
}) => {
let newIndex = index
if (index === activeIndex) {
newIndex = overIndex
} else if (
activeIndex < overIndex &&
index > activeIndex &&
index <= overIndex
) {
newIndex = index - 1
} else if (
activeIndex > overIndex &&
index >= overIndex &&
index < activeIndex
) {
newIndex = index + 1
}
const oldRect = rects[index]
const newRect = rects[newIndex]
if (!oldRect || !newRect) return null
return {
x: newRect.left - oldRect.left,
y: newRect.top - oldRect.top,
scaleX: newRect.width / oldRect.width,
scaleY: newRect.height / oldRect.height,
}
}
const profilePointerSensor = PointerSensor.configure({
activationConstraints: () => undefined,
})
interface ProfileSwitchRequest {
profile: string
@@ -138,6 +89,7 @@ const ProfilePage = () => {
const { addListener } = useListen()
const [url, setUrl] = useState('')
const [disabled, setDisabled] = useState(false)
const [profileDndRevision, setProfileDndRevision] = useState(0)
const [activatings, setActivatings] = useState<string[]>([])
const [switchTarget, setSwitchTarget] = useState<string | null>(null)
const [visibleSwitchingProfile, setVisibleSwitchingProfile] = useState<
@@ -165,14 +117,6 @@ const ProfilePage = () => {
)
const currentProfileRef = useRef<string | undefined>(undefined)
const profilePageMountedRef = useRef(true)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const { current } = location.state || {}
const {
@@ -268,7 +212,7 @@ const ProfilePage = () => {
const type1 = ['local', 'remote']
return items.filter((i) => i && type1.includes(i.type!))
return items.filter((i) => i?.type && type1.includes(i.type))
}, [profiles])
const currentActivatings = () => {
@@ -366,12 +310,31 @@ const ProfilePage = () => {
}
const onDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (over) {
if (active.id !== over.id) {
await reorderProfile(active.id.toString(), over.id.toString())
mutateProfiles()
}
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: newIndex, initialIndex: oldIndex } = source.sortable
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= profileItems.length ||
newIndex >= profileItems.length ||
oldIndex === newIndex
) {
return
}
const activeId = profileItems[oldIndex]?.uid
const overId = profileItems[newIndex]?.uid
if (activeId == null || overId == null || activeId === overId) return
try {
await reorderProfile(activeId, overId)
mutateProfiles()
} catch (error) {
setProfileDndRevision((revision) => revision + 1)
showNotice.error(error)
}
}
@@ -933,100 +896,97 @@ const ProfilePage = () => {
</Button>
</Stack>
<DndContext
sensors={sensors}
collisionDetection={profileCollisionDetection}
onDragEnd={onDragEnd}
<Box
sx={{
pl: '10px',
pr: '10px',
height: 'calc(100% - 48px)',
overflowY: 'auto',
}}
>
<Box
sx={{
pl: '10px',
pr: '10px',
height: 'calc(100% - 48px)',
overflowY: 'auto',
}}
<DragDropProvider
key={profileDndRevision}
sensors={[profilePointerSensor, KeyboardSensor]}
onDragEnd={onDragEnd}
>
<Box sx={{ mb: 1.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<SortableContext
strategy={profileRectSortingStrategy}
items={profileItems.map((x) => {
return x.uid
})}
>
{profileItems.map((item) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={item.file}>
<SortableProfileItem
id={item.uid}
selected={(switchTarget ?? profiles.current) === item.uid}
activating={
activatings.includes(item.uid) ||
visibleSwitchingProfile === item.uid
}
itemData={item}
timerUpdateRevision={
timerUpdateRevisions.get(item.uid) ?? 0
}
completedUpdateRevision={
completedUpdateRevisions.get(item.uid) ?? 0
}
mutateProfiles={mutateProfiles}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance(false)
}
}}
onDelete={() => {
if (batchMode) {
toggleProfileSelection(item.uid)
} else {
onDelete(item.uid)
}
}}
batchMode={batchMode}
isSelected={selectedProfiles.has(item.uid)}
onSelectionChange={() => toggleProfileSelection(item.uid)}
/>
</Grid>
))}
</SortableContext>
</Grid>
<Box
sx={{
mb: 1.5,
display: 'grid',
overflow: 'hidden',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 1,
px: 0.5,
}}
>
{profileItems.map((item, index) => (
<ProfileItem
key={item.uid}
id={item.uid}
index={index}
selected={(switchTarget ?? profiles.current) === item.uid}
activating={
activatings.includes(item.uid) ||
visibleSwitchingProfile === item.uid
}
itemData={item}
timerUpdateRevision={timerUpdateRevisions.get(item.uid) ?? 0}
completedUpdateRevision={
completedUpdateRevisions.get(item.uid) ?? 0
}
mutateProfiles={mutateProfiles}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance(false)
}
}}
onDelete={() => {
if (batchMode) {
toggleProfileSelection(item.uid)
} else {
onDelete(item.uid)
}
}}
batchMode={batchMode}
isSelected={selectedProfiles.has(item.uid)}
onSelectionChange={() => toggleProfileSelection(item.uid)}
/>
))}
</Box>
<Divider
variant="middle"
flexItem
sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
></Divider>
<Box sx={{ mt: 1.5, mb: '10px' }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<Grid size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
id="Merge"
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false)
}
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
id="Script"
logInfo={chainLogs['Script']}
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false)
}
}}
/>
</Grid>
</DragDropProvider>
<Divider
variant="middle"
flexItem
sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
></Divider>
<Box sx={{ mt: 1.5, mb: '10px' }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<Grid size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
id="Merge"
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false)
}
}}
/>
</Grid>
</Box>
<Grid size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
id="Script"
logInfo={chainLogs['Script']}
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false)
}
}}
/>
</Grid>
</Grid>
</Box>
<DragOverlay />
</DndContext>
</Box>
<ProfileViewer
ref={viewerRef}