mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-29 00:35:38 +08:00
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:
@@ -13,6 +13,7 @@
|
||||
- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭)
|
||||
- 允许独立控制订阅自动更新
|
||||
- 托盘 `更多` 中新增 `关闭所有连接` 按钮
|
||||
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
|
||||
|
||||
### 🚀 优化改进
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "锁定菜单排序"
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
{t(router.label)}
|
||||
</LayoutItem>
|
||||
))}
|
||||
</List>
|
||||
{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("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 />
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
1
src/services/types.d.ts
vendored
1
src/services/types.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user