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
+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>
)
}