Ver código fonte

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

* feat: Support Drag to Reorder the Profile

* style: Remove unnecessary styles
Pylogmon 1 ano atrás
pai
commit
887f92babe

+ 5 - 2
package.json

@@ -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",

+ 68 - 1
pnpm-lock.yaml

@@ -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:

+ 6 - 2
src-tauri/src/cmds.rs

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

+ 30 - 1
src-tauri/src/config/profiles.rs

@@ -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![]);

+ 1 - 0
src-tauri/src/main.rs

@@ -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,

+ 33 - 16
src/components/profile/profile-item.tsx

@@ -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>
   );
 };
 

+ 58 - 18
src/pages/profiles.tsx

@@ -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>

+ 7 - 0
src/services/cmds.ts

@@ -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 });
 }