feat: Support Drag to Reorder the Profile (#29)

* feat: Support Drag to Reorder the Profile

* style: Remove unnecessary styles
This commit is contained in:
Pylogmon 2023-11-29 08:54:02 +08:00 committed by GitHub
parent 197f942b3f
commit 887f92babe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 208 additions and 40 deletions

View File

@ -18,6 +18,9 @@
"prepare": "husky install"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@juggle/resize-observer": "^3.4.0",
@ -42,8 +45,8 @@
"react-virtuoso": "^3.1.3",
"recoil": "^0.7.6",
"snarkdown": "^2.0.0",
"tar": "^6.2.0",
"swr": "^1.3.0"
"swr": "^1.3.0",
"tar": "^6.2.0"
},
"devDependencies": {
"@actions/github": "^5.0.3",

View File

@ -5,6 +5,15 @@ settings:
excludeLinksFromLockfile: false
dependencies:
"@dnd-kit/core":
specifier: ^6.1.0
version: 6.1.0(react-dom@18.2.0)(react@18.2.0)
"@dnd-kit/sortable":
specifier: ^8.0.0
version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0)
"@dnd-kit/utilities":
specifier: ^3.2.2
version: 3.2.2(react@18.2.0)
"@emotion/react":
specifier: ^11.11.1
version: 11.11.1(@types/react@18.2.37)(react@18.2.0)
@ -482,6 +491,61 @@ packages:
"@babel/helper-validator-identifier": 7.22.20
to-fast-properties: 2.0.0
/@dnd-kit/accessibility@3.1.0(react@18.2.0):
resolution:
{
integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==,
}
peerDependencies:
react: ">=16.8.0"
dependencies:
react: 18.2.0
tslib: 2.6.2
dev: false
/@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0):
resolution:
{
integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==,
}
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
dependencies:
"@dnd-kit/accessibility": 3.1.0(react@18.2.0)
"@dnd-kit/utilities": 3.2.2(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tslib: 2.6.2
dev: false
/@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0):
resolution:
{
integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==,
}
peerDependencies:
"@dnd-kit/core": ^6.1.0
react: ">=16.8.0"
dependencies:
"@dnd-kit/core": 6.1.0(react-dom@18.2.0)(react@18.2.0)
"@dnd-kit/utilities": 3.2.2(react@18.2.0)
react: 18.2.0
tslib: 2.6.2
dev: false
/@dnd-kit/utilities@3.2.2(react@18.2.0):
resolution:
{
integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==,
}
peerDependencies:
react: ">=16.8.0"
dependencies:
react: 18.2.0
tslib: 2.6.2
dev: false
/@emotion/babel-plugin@11.11.0:
resolution:
{
@ -1656,6 +1720,7 @@ packages:
engines: { node: ">= 10" }
cpu: [arm64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: true
optional: true
@ -1668,6 +1733,7 @@ packages:
engines: { node: ">= 10" }
cpu: [arm64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: true
optional: true
@ -1680,6 +1746,7 @@ packages:
engines: { node: ">= 10" }
cpu: [x64]
os: [linux]
libc: [glibc]
requiresBuild: true
dev: true
optional: true
@ -1692,6 +1759,7 @@ packages:
engines: { node: ">= 10" }
cpu: [x64]
os: [linux]
libc: [musl]
requiresBuild: true
dev: true
optional: true
@ -3797,7 +3865,6 @@ packages:
{
integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==,
}
dev: true
/tunnel@0.0.6:
resolution:

View File

@ -30,6 +30,11 @@ pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult
wrap_err!(Config::profiles().data().append_item(item))
}
#[tauri::command]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
wrap_err!(Config::profiles().data().reorder(active_id, over_id))
}
#[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
let item = wrap_err!(PrfItem::from(item, file_data).await)?;
@ -229,7 +234,6 @@ pub fn open_web_url(url: String) -> CmdResult<()> {
wrap_err!(open::that(url))
}
#[cfg(windows)]
pub mod uwp {
use super::*;
@ -299,4 +303,4 @@ pub mod uwp {
pub async fn invoke_uwp_tool() -> CmdResult {
Ok(())
}
}
}

View File

@ -55,7 +55,12 @@ impl IProfiles {
pub fn template() -> Self {
Self {
valid: Some(vec!["dns".into(), "sub-rules".into(), "unified-delay".into(), "tcp-concurrent".into()]),
valid: Some(vec![
"dns".into(),
"sub-rules".into(),
"unified-delay".into(),
"tcp-concurrent".into(),
]),
items: Some(vec![]),
..Self::default()
}
@ -151,6 +156,30 @@ impl IProfiles {
self.save_file()
}
/// reorder items
pub fn reorder(&mut self, active_id: String, over_id: String) -> Result<()> {
let mut items = self.items.take().unwrap_or(vec![]);
let mut old_index = None;
let mut new_index = None;
for i in 0..items.len() {
if items[i].uid == Some(active_id.clone()) {
old_index = Some(i);
}
if items[i].uid == Some(over_id.clone()) {
new_index = Some(i);
}
}
if old_index.is_none() || new_index.is_none() {
return Ok(());
}
let item = items.remove(old_index.unwrap());
items.insert(new_index.unwrap(), item);
self.items = Some(items);
self.save_file()
}
/// update the item value
pub fn patch_item(&mut self, uid: String, item: PrfItem) -> Result<()> {
let mut items = self.items.take().unwrap_or(vec![]);

View File

@ -59,6 +59,7 @@ fn main() -> std::io::Result<()> {
cmds::patch_profile,
cmds::create_profile,
cmds::import_profile,
cmds::reorder_profile,
cmds::update_profile,
cmds::delete_profile,
cmds::read_profile_file,

View File

@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import { useLockFn } from "ahooks";
import { useRecoilState } from "recoil";
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Box,
Typography,
@ -14,7 +16,7 @@ import {
Menu,
CircularProgress,
} from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { RefreshRounded, DragIndicator } from "@mui/icons-material";
import { atomLoadingCache } from "@/services/states";
import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
import { Notice } from "@/components/base";
@ -28,6 +30,7 @@ const round = keyframes`
`;
interface Props {
id: string;
selected: boolean;
activating: boolean;
itemData: IProfileItem;
@ -37,6 +40,8 @@ interface Props {
export const ProfileItem = (props: Props) => {
const { selected, activating, itemData, onSelect, onEdit } = props;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: props.id });
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
@ -183,7 +188,12 @@ export const ProfileItem = (props: Props) => {
};
return (
<>
<Box
sx={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<ProfileBox
aria-selected={selected}
onClick={() => onSelect(false)}
@ -212,17 +222,27 @@ export const ProfileItem = (props: Props) => {
<CircularProgress size={20} />
</Box>
)}
<Box position="relative">
<Typography
width="calc(100% - 36px)"
variant="h6"
component="h2"
noWrap
title={name}
>
{name}
</Typography>
<Box sx={{ display: "flex", justifyContent: "start" }}>
<Box
ref={setNodeRef}
sx={{ display: "flex", margin: "auto 0" }}
{...attributes}
{...listeners}
>
<DragIndicator sx={{ cursor: "grab" }} />
</Box>
<Typography
width="calc(100% - 36px)"
variant="h6"
component="h2"
noWrap
title={name}
>
{name}
</Typography>
</Box>
{/* only if has url can it be updated */}
{hasUrl && (
@ -246,7 +266,6 @@ export const ProfileItem = (props: Props) => {
</IconButton>
)}
</Box>
{/* the second line show url's info or description */}
<Box sx={boxStyle}>
{hasUrl ? (
@ -271,7 +290,6 @@ export const ProfileItem = (props: Props) => {
</Typography>
)}
</Box>
{/* the third line show extra info or last updated time */}
{hasExtra ? (
<Box sx={{ ...boxStyle, fontSize: 14 }}>
@ -285,7 +303,6 @@ export const ProfileItem = (props: Props) => {
<span title="Updated Time">{parseExpire(updated)}</span>
</Box>
)}
<LinearProgress
variant="determinate"
value={progress}
@ -324,7 +341,7 @@ export const ProfileItem = (props: Props) => {
mode="yaml"
onClose={() => setFileOpen(false)}
/>
</>
</Box>
);
};

View File

@ -3,6 +3,19 @@ import { useMemo, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { useSetRecoilState } from "recoil";
import { Box, Button, Grid, IconButton, Stack, TextField } from "@mui/material";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { LoadingButton } from "@mui/lab";
import {
ClearRounded,
@ -19,6 +32,7 @@ import {
getRuntimeLogs,
deleteProfile,
updateProfile,
reorderProfile,
} from "@/services/cmds";
import { atomLoadingCache } from "@/services/states";
import { closeAllConnections } from "@/services/api";
@ -40,7 +54,12 @@ const ProfilePage = () => {
const [disabled, setDisabled] = useState(false);
const [activating, setActivating] = useState("");
const [loading, setLoading] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const {
profiles = {},
activateSelected,
@ -106,6 +125,16 @@ 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 onSelect = useLockFn(async (current: string, force: boolean) => {
if (!force && current === profiles.current) return;
// 避免大多数情况下loading态闪烁
@ -293,22 +322,34 @@ const ProfilePage = () => {
{t("New")}
</Button>
</Stack>
<Box sx={{ mb: 4.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
{regularItems.map((item) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
<ProfileItem
selected={profiles.current === item.uid}
activating={activating === item.uid}
itemData={item}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
/>
</Grid>
))}
</Grid>
</Box>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<Box sx={{ mb: 4.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<SortableContext
items={regularItems.map((x) => {
return x.uid;
})}
>
{regularItems.map((item) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
<ProfileItem
id={item.uid}
selected={profiles.current === item.uid}
activating={activating === item.uid}
itemData={item}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}
/>
</Grid>
))}
</SortableContext>
</Grid>
</Box>
</DndContext>
{enhanceItems.length > 0 && (
<Grid container spacing={{ xs: 2, lg: 2 }}>
@ -330,7 +371,6 @@ const ProfilePage = () => {
))}
</Grid>
)}
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
<ConfigViewer ref={configRef} />
</BasePage>

View File

@ -64,6 +64,13 @@ export async function importProfile(url: string) {
});
}
export async function reorderProfile(activeId: string, overId: string) {
return invoke<void>("reorder_profile", {
activeId,
overId,
});
}
export async function updateProfile(index: string, option?: IProfileOption) {
return invoke<void>("update_profile", { index, option });
}