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
This commit is contained in:
Sline
2025-10-22 21:39:12 +08:00
committed by GitHub
parent 3bedf7ec35
commit 4f7d069f19
8 changed files with 376 additions and 28 deletions

View File

@@ -13,6 +13,7 @@
- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭)
- 允许独立控制订阅自动更新
- 托盘 `更多` 中新增 `关闭所有连接` 按钮
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
### 🚀 优化改进

View File

@@ -57,6 +57,9 @@ pub struct IVerge {
/// menu icon
pub menu_icon: Option<String>,
/// menu order
pub menu_order: Option<Vec<String>>,
/// sysproxy tray icon
pub sysproxy_tray_icon: Option<bool>,
@@ -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<String>,
pub menu_icon: Option<String>,
pub menu_order: Option<Vec<String>>,
pub sysproxy_tray_icon: Option<bool>,
pub tun_tray_icon: Option<bool>,
pub enable_tun_mode: Option<bool>,
@@ -629,6 +634,7 @@ impl From<IVerge> 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,

View File

@@ -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 (
<ListItem sx={{ py: 0.5, maxWidth: 250, mx: "auto", padding: "4px 0px" }}>
<ListItem
ref={setNodeRef}
style={style}
sx={[
{ py: 0.5, maxWidth: 250, mx: "auto", padding: "4px 0px" },
isDragging ? { opacity: 0.78 } : {},
]}
>
<ListItemButton
selected={!!match}
sx={[
@@ -32,6 +58,7 @@ export const LayoutItem = (props: Props) => {
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) && (
<ListItemIcon sx={{ color: "text.primary", marginLeft: "6px" }}>

View File

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

View File

@@ -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": "锁定菜单排序"
}

View File

@@ -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<string, NavItem>,
) => {
const seen = new Set<string>();
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 (
<LayoutItem
to={item.path}
icon={item.icon}
sortable={{
setNodeRef,
attributes,
listeners,
style,
isDragging,
}}
>
{label}
</LayoutItem>
);
};
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<MenuContextPosition | null>(null);
const windowControls = useRef<any>(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<HTMLElement>) => {
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 = () => {
<UpdateButton className="the-newbtn" />
</div>
<List className="the-menu">
{navItems.map((router) => (
<LayoutItem
key={router.label}
to={router.path}
icon={router.icon}
{menuUnlocked && (
<Box
sx={(theme) => ({
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(router.label)}
</LayoutItem>
))}
{t("Menu reorder mode")}
</Box>
)}
{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}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path);
if (!item) {
return null;
}
return (
<LayoutItem
key={item.path}
to={item.path}
icon={item.icon}
>
{t(item.label)}
</LayoutItem>
);
})}
</List>
)}
<Menu
open={Boolean(menuContextPosition)}
onClose={handleMenuContextClose}
anchorReference="anchorPosition"
anchorPosition={
menuContextPosition
? {
top: menuContextPosition.top,
left: menuContextPosition.left,
}
: undefined
}
transitionDuration={200}
slotProps={{
list: {
sx: { py: 0.5 },
},
}}
>
<MenuItem
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
dense
>
{menuUnlocked ? t("Lock menu order") : t("Unlock menu order")}
</MenuItem>
</Menu>
<div className="the-traffic">
<LayoutTraffic />

View File

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

View File

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