From 4f7d069f1964dc4d04c13d3c74aaf1f36417be27 Mon Sep 17 00:00:00 2001 From: Sline Date: Wed, 22 Oct 2025 21:39:12 +0800 Subject: [PATCH] feat(ui): add left menu lock/unlock with reorder mode and context menu (#5168) * feat: free menu * feat(ui): add left menu lock/unlock with reorder mode and context menu * docs: UPDATELOG.md --- UPDATELOG.md | 1 + src-tauri/src/config/verge.rs | 6 + src/components/layout/layout-item.tsx | 35 ++- src/locales/en.json | 5 +- src/locales/zh.json | 5 +- src/pages/_layout.tsx | 350 ++++++++++++++++++++++++-- src/providers/app-data-provider.tsx | 1 + src/services/types.d.ts | 1 + 8 files changed, 376 insertions(+), 28 deletions(-) diff --git a/UPDATELOG.md b/UPDATELOG.md index 7d240f2f1..82c4129d3 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -13,6 +13,7 @@ - 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭) - 允许独立控制订阅自动更新 - 托盘 `更多` 中新增 `关闭所有连接` 按钮 +- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏) ### 🚀 优化改进 diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index c0bad3165..083d071f3 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -57,6 +57,9 @@ pub struct IVerge { /// menu icon pub menu_icon: Option, + /// menu order + pub menu_order: Option>, + /// sysproxy tray icon pub sysproxy_tray_icon: Option, @@ -456,6 +459,7 @@ impl IVerge { #[cfg(target_os = "macos")] patch!(tray_icon); patch!(menu_icon); + patch!(menu_order); patch!(common_tray_icon); patch!(sysproxy_tray_icon); patch!(tun_tray_icon); @@ -554,6 +558,7 @@ pub struct IVergeResponse { #[cfg(target_os = "macos")] pub tray_icon: Option, pub menu_icon: Option, + pub menu_order: Option>, pub sysproxy_tray_icon: Option, pub tun_tray_icon: Option, pub enable_tun_mode: Option, @@ -629,6 +634,7 @@ impl From for IVergeResponse { #[cfg(target_os = "macos")] tray_icon: verge.tray_icon, menu_icon: verge.menu_icon, + menu_order: verge.menu_order, sysproxy_tray_icon: verge.sysproxy_tray_icon, tun_tray_icon: verge.tun_tray_icon, enable_tun_mode: verge.enable_tun_mode, diff --git a/src/components/layout/layout-item.tsx b/src/components/layout/layout-item.tsx index 13ebe4037..e1c233ba0 100644 --- a/src/components/layout/layout-item.tsx +++ b/src/components/layout/layout-item.tsx @@ -1,3 +1,7 @@ +import type { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; import { alpha, ListItem, @@ -5,24 +9,46 @@ import { ListItemText, ListItemIcon, } from "@mui/material"; +import type { CSSProperties, ReactNode } from "react"; import { useMatch, useResolvedPath, useNavigate } from "react-router"; 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: React.ReactNode[]; + icon: ReactNode[]; + sortable?: SortableProps; } export const LayoutItem = (props: Props) => { - const { to, children, icon } = props; + const { to, children, icon, sortable } = props; const { verge } = useVerge(); const { menu_icon } = verge ?? {}; const resolved = useResolvedPath(to); const match = useMatch({ path: resolved.pathname, end: true }); const navigate = useNavigate(); + const { setNodeRef, attributes, listeners, style, isDragging } = + sortable ?? {}; + return ( - + { paddingLeft: 1, paddingRight: 1, marginRight: 1.25, + cursor: sortable && !sortable.disabled ? "grab" : "pointer", "& .MuiListItemText-primary": { color: "text.primary", fontWeight: "700", @@ -52,6 +79,8 @@ export const LayoutItem = (props: Props) => { }, ]} onClick={() => navigate(to)} + {...(attributes ?? {})} + {...(listeners ?? {})} > {(menu_icon === "monochrome" || !menu_icon) && ( diff --git a/src/locales/en.json b/src/locales/en.json index ca44d3fb8..aeac2c2d9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -708,5 +708,8 @@ "Prefer System Titlebar": "Prefer System Titlebar", "App Log Max Size": "App Log Max Size", "App Log Max Count": "App Log Max Count", - "Allow Auto Update": "Allow Auto Update" + "Allow Auto Update": "Allow Auto Update", + "Menu reorder mode": "Menu reorder mode", + "Unlock menu order": "Unlock menu order", + "Lock menu order": "Lock menu order" } diff --git a/src/locales/zh.json b/src/locales/zh.json index e1ffa04c3..0d63e5bcb 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -708,5 +708,8 @@ "Prefer System Titlebar": "优先使用系统标题栏", "App Log Max Size": "应用日志最大大小", "App Log Max Count": "应用日志最大数量", - "Allow Auto Update": "允许自动更新" + "Allow Auto Update": "允许自动更新", + "Menu reorder mode": "菜单排序模式", + "Unlock menu order": "解锁菜单排序", + "Lock menu order": "锁定菜单排序" } diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index f7605824d..4111e3ef5 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,7 +1,39 @@ -import { List, Paper, SvgIcon, ThemeProvider } from "@mui/material"; +import { + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + Box, + List, + Menu, + MenuItem, + Paper, + SvgIcon, + ThemeProvider, +} from "@mui/material"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, useNavigate } from "react-router"; import { SWRConfig } from "swr"; @@ -16,11 +48,7 @@ import { LayoutItem } from "@/components/layout/layout-item"; import { LayoutTraffic } from "@/components/layout/layout-traffic"; import { UpdateButton } from "@/components/layout/update-button"; import { useCustomTheme } from "@/components/layout/use-custom-theme"; -import { useConnectionData } from "@/hooks/use-connection-data"; import { useI18n } from "@/hooks/use-i18n"; -import { useLogData } from "@/hooks/use-log-data"; -import { useMemoryData } from "@/hooks/use-memory-data"; -import { useTrafficData } from "@/hooks/use-traffic-data"; import { useVerge } from "@/hooks/use-verge"; import { useWindowDecorations } from "@/hooks/use-window"; import { useThemeMode } from "@/services/states"; @@ -37,29 +65,217 @@ import "dayjs/locale/zh-cn"; export const portableFlag = false; +type NavItem = (typeof navItems)[number]; + +const createNavLookup = (items: NavItem[]) => { + const map = new Map(items.map((item) => [item.path, item])); + const defaultOrder = items.map((item) => item.path); + return { map, defaultOrder }; +}; + +const resolveMenuOrder = ( + order: string[] | null | undefined, + defaultOrder: string[], + map: Map, +) => { + const seen = new Set(); + const resolved: string[] = []; + + if (Array.isArray(order)) { + for (const path of order) { + if (map.has(path) && !seen.has(path)) { + resolved.push(path); + seen.add(path); + } + } + } + + for (const path of defaultOrder) { + if (!seen.has(path)) { + resolved.push(path); + seen.add(path); + } + } + + return resolved; +}; + +const areOrdersEqual = (a: string[], b: string[]) => + a.length === b.length && a.every((value, index) => value === b[index]); + +type MenuContextPosition = { top: number; left: number }; +type MenuOrderAction = { type: "sync"; payload: string[] }; + +const menuOrderReducer = (state: string[], action: MenuOrderAction) => { + const next = action.payload; + if (areOrdersEqual(state, next)) { + return state; + } + return [...next]; +}; + +interface SortableNavMenuItemProps { + item: NavItem; + label: string; +} + +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; + } + + return ( + + {label} + + ); +}; + dayjs.extend(relativeTime); const OS = getSystem(); const Layout = () => { - const trafficData = useTrafficData(); - const memoryData = useMemoryData(); - const connectionData = useConnectionData(); - const logData = useLogData(); - const mode = useThemeMode(); const isDark = mode !== "light"; const { t } = useTranslation(); const { theme } = useCustomTheme(); - const { verge } = useVerge(); + const { verge, mutateVerge, patchVerge } = useVerge(); const { language } = verge ?? {}; const { switchLanguage } = useI18n(); const navigate = useNavigate(); const themeReady = useMemo(() => Boolean(theme), [theme]); + const [menuUnlocked, setMenuUnlocked] = useState(false); + const [menuContextPosition, setMenuContextPosition] = + useState(null); + const windowControls = useRef(null); const { decorated } = useWindowDecorations(); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 6, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const { map: navItemMap, defaultOrder: defaultMenuOrder } = useMemo( + () => createNavLookup(navItems), + [], + ); + + const configMenuOrder = useMemo( + () => resolveMenuOrder(verge?.menu_order, defaultMenuOrder, navItemMap), + [verge?.menu_order, defaultMenuOrder, navItemMap], + ); + + const [menuOrder, dispatchMenuOrder] = useReducer( + menuOrderReducer, + configMenuOrder, + ); + + useEffect(() => { + dispatchMenuOrder({ type: "sync", payload: configMenuOrder }); + }, [configMenuOrder]); + + const handleMenuDragEnd = useCallback( + async (event: DragEndEvent) => { + if (!menuUnlocked) { + return; + } + + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const activeId = String(active.id); + const overId = String(over.id); + + const oldIndex = menuOrder.indexOf(activeId); + const newIndex = menuOrder.indexOf(overId); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + const previousOrder = [...menuOrder]; + const nextOrder = arrayMove(menuOrder, oldIndex, newIndex); + + dispatchMenuOrder({ type: "sync", payload: nextOrder }); + mutateVerge( + (prev) => (prev ? { ...prev, menu_order: nextOrder } : prev), + false, + ); + + try { + await patchVerge({ menu_order: nextOrder }); + } catch (error) { + console.error("Failed to update menu order:", error); + dispatchMenuOrder({ type: "sync", payload: previousOrder }); + mutateVerge( + (prev) => (prev ? { ...prev, menu_order: previousOrder } : prev), + false, + ); + } + }, + [menuUnlocked, menuOrder, mutateVerge, patchVerge], + ); + + const handleMenuContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setMenuContextPosition({ top: event.clientY, left: event.clientX }); + }, + [], + ); + + const handleMenuContextClose = useCallback(() => { + setMenuContextPosition(null); + }, []); + + const handleUnlockMenu = useCallback(() => { + setMenuUnlocked(true); + setMenuContextPosition(null); + }, []); + + const handleLockMenu = useCallback(() => { + setMenuUnlocked(false); + setMenuContextPosition(null); + }, []); + const customTitlebar = useMemo( () => !decorated ? ( @@ -202,17 +418,105 @@ const Layout = () => { - - {navItems.map((router) => ( - - {t(router.label)} - - ))} - + {menuUnlocked && ( + ({ + px: 1.5, + py: 0.75, + mx: "auto", + mb: 1, + maxWidth: 250, + borderRadius: 1.5, + fontSize: 12, + fontWeight: 600, + textAlign: "center", + color: theme.palette.warning.contrastText, + bgcolor: + theme.palette.mode === "light" + ? theme.palette.warning.main + : theme.palette.warning.dark, + })} + > + {t("Menu reorder mode")} + + )} + + {menuUnlocked ? ( + + + + {menuOrder.map((path) => { + const item = navItemMap.get(path); + if (!item) { + return null; + } + return ( + + ); + })} + + + + ) : ( + + {menuOrder.map((path) => { + const item = navItemMap.get(path); + if (!item) { + return null; + } + return ( + + {t(item.label)} + + ); + })} + + )} + + + + {menuUnlocked ? t("Lock menu order") : t("Unlock menu order")} + +
diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index 1944722f4..c71528c40 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -82,6 +82,7 @@ export const AppDataProvider = ({ }; const addWindowListener = (eventName: string, handler: EventListener) => { + // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener window.addEventListener(eventName, handler); return () => window.removeEventListener(eventName, handler); }; diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 1a2a2926c..c6f0b58b4 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -797,6 +797,7 @@ interface IVergeConfig { enable_memory_usage?: boolean; enable_group_icon?: boolean; menu_icon?: "monochrome" | "colorful" | "disable"; + menu_order?: string[]; tray_icon?: "monochrome" | "colorful"; common_tray_icon?: boolean; sysproxy_tray_icon?: boolean;