diff --git a/src/components/setting/mods/hotkey-input.tsx b/src/components/setting/mods/hotkey-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..63b53eb103aaaeaca57d5b2a928e7d5d29d70280 --- /dev/null +++ b/src/components/setting/mods/hotkey-input.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { alpha, Box, IconButton, styled } from "@mui/material"; +import { DeleteRounded } from "@mui/icons-material"; +import parseHotkey from "@/utils/parse-hotkey"; + +const KeyWrapper = styled("div")(({ theme }) => ({ + position: "relative", + width: 165, + minHeight: 36, + + "> input": { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + zIndex: 1, + opacity: 0, + }, + "> input:focus + .list": { + borderColor: alpha(theme.palette.primary.main, 0.75), + }, + ".list": { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + width: "100%", + height: "100%", + minHeight: 36, + boxSizing: "border-box", + padding: "3px 4px", + border: "1px solid", + borderRadius: 4, + borderColor: alpha(theme.palette.text.secondary, 0.15), + "&:last-child": { + marginRight: 0, + }, + }, + ".item": { + color: theme.palette.text.primary, + border: "1px solid", + borderColor: alpha(theme.palette.text.secondary, 0.2), + borderRadius: "2px", + padding: "1px 1px", + margin: "2px 0", + marginRight: 8, + }, +})); + +interface Props { + value: string[]; + onChange: (value: string[]) => void; +} + +const HotkeyInput = (props: Props) => { + const { value, onChange } = props; + + return ( + <Box sx={{ display: "flex", alignItems: "center" }}> + <KeyWrapper> + <input + onKeyDown={(e) => { + const evt = e.nativeEvent; + e.preventDefault(); + e.stopPropagation(); + + const key = parseHotkey(evt.key); + if (key === "UNIDENTIFIED") return; + + const newList = [...new Set([...value, key])]; + onChange(newList); + }} + /> + + <div className="list"> + {value.map((key) => ( + <div key={key} className="item"> + {key} + </div> + ))} + </div> + </KeyWrapper> + + <IconButton + size="small" + title="Delete" + color="inherit" + onClick={() => onChange([])} + > + <DeleteRounded fontSize="inherit" /> + </IconButton> + </Box> + ); +}; + +export default HotkeyInput; diff --git a/src/components/setting/mods/hotkey-viewer.tsx b/src/components/setting/mods/hotkey-viewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a66f4f2b092233ea75b35fd9789ccca33d73154 --- /dev/null +++ b/src/components/setting/mods/hotkey-viewer.tsx @@ -0,0 +1,132 @@ +import useSWR from "swr"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLockFn } from "ahooks"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + styled, + Typography, +} from "@mui/material"; +import { getVergeConfig, patchVergeConfig } from "@/services/cmds"; +import { ModalHandler } from "@/hooks/use-modal-handler"; +import Notice from "@/components/base/base-notice"; +import HotkeyInput from "./hotkey-input"; + +const ItemWrapper = styled("div")` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +`; + +const HOTKEY_FUNC = [ + "clash_mode_rule", + "clash_mode_direct", + "clash_mode_global", + "clash_moda_script", + "toggle_system_proxy", + "enable_system_proxy", + "disable_system_proxy", + "toggle_tun_mode", + "enable_tun_mode", + "disable_tun_mode", +]; + +interface Props { + handler: ModalHandler; +} + +const HotkeyViewer = ({ handler }: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + if (handler) { + handler.current = { + open: () => setOpen(true), + close: () => setOpen(false), + }; + } + + const { data: vergeConfig, mutate: mutateVerge } = useSWR( + "getVergeConfig", + getVergeConfig + ); + + const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({}); + + useEffect(() => { + if (!open) return; + const map = {} as typeof hotkeyMap; + + vergeConfig?.hotkeys?.forEach((text) => { + const [func, key] = text.split(",").map((e) => e.trim()); + + if (!func || !key) return; + + map[func] = key + .split("+") + .map((e) => e.trim()) + .map((k) => (k === "PLUS" ? "+" : k)); + }); + + setHotkeyMap(map); + }, [vergeConfig?.hotkeys, open]); + + const onSave = useLockFn(async () => { + const hotkeys = Object.entries(hotkeyMap) + .map(([func, keys]) => { + if (!func || !keys?.length) return ""; + + const key = keys + .map((k) => k.trim()) + .filter(Boolean) + .map((k) => (k === "+" ? "PLUS" : k)) + .join("+"); + + if (!key) return ""; + return `${func},${key}`; + }) + .filter(Boolean); + + try { + patchVergeConfig({ hotkeys }); + setOpen(false); + mutateVerge(); + } catch (err: any) { + Notice.error(err.message || err.toString()); + } + }); + + return ( + <Dialog open={open} onClose={() => setOpen(false)}> + <DialogTitle>{t("Hotkey Viewer")}</DialogTitle> + + <DialogContent sx={{ width: 450, maxHeight: 330 }}> + {HOTKEY_FUNC.map((func) => ( + <ItemWrapper key={func}> + <Typography>{t(func)}</Typography> + <HotkeyInput + value={hotkeyMap[func] ?? []} + onChange={(v) => setHotkeyMap((m) => ({ ...m, [func]: v }))} + /> + </ItemWrapper> + ))} + </DialogContent> + + <DialogActions> + <Button variant="outlined" onClick={() => setOpen(false)}> + {t("Cancel")} + </Button> + <Button onClick={onSave} variant="contained"> + {t("Save")} + </Button> + </DialogActions> + </Dialog> + ); +}; + +export default HotkeyViewer; diff --git a/src/components/setting/setting-verge.tsx b/src/components/setting/setting-verge.tsx index ae3b69d6073783e2cb3b0f372f3aacb1e5eb4ec5..8eaff19c63ae83c4431d3ac306ab65c8e466e548 100644 --- a/src/components/setting/setting-verge.tsx +++ b/src/components/setting/setting-verge.tsx @@ -17,8 +17,10 @@ import { import { ArrowForward } from "@mui/icons-material"; import { SettingList, SettingItem } from "./setting"; import { version } from "@root/package.json"; +import useModalHandler from "@/hooks/use-modal-handler"; import ThemeModeSwitch from "./mods/theme-mode-switch"; import ConfigViewer from "./mods/config-viewer"; +import HotkeyViewer from "./mods/hotkey-viewer"; import GuardState from "./mods/guard-state"; import SettingTheme from "./setting-theme"; @@ -43,8 +45,12 @@ const SettingVerge = ({ onError }: Props) => { mutateVerge({ ...vergeConfig, ...patch }, false); }; + const hotkeyHandler = useModalHandler(); + return ( <SettingList title={t("Verge Setting")}> + <HotkeyViewer handler={hotkeyHandler} /> + <SettingItem label={t("Language")}> <GuardState value={language ?? "en"} @@ -108,6 +114,17 @@ const SettingVerge = ({ onError }: Props) => { </IconButton> </SettingItem> + <SettingItem label={t("Hotkey Setting")}> + <IconButton + color="inherit" + size="small" + sx={{ my: "2px" }} + onClick={() => hotkeyHandler.current.open()} + > + <ArrowForward /> + </IconButton> + </SettingItem> + <SettingItem label={t("Runtime Config")}> <IconButton color="inherit" diff --git a/src/services/types.d.ts b/src/services/types.d.ts index fa2604813d9fb444e80fe9f491a076f0360114cb..6d18acdf001afac61a9dc75e843bc2deb052e77e 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -147,6 +147,7 @@ declare namespace CmdType { proxy_guard_duration?: number; system_proxy_bypass?: string; web_ui_list?: string[]; + hotkeys?: string[]; theme_setting?: { primary_color?: string; secondary_color?: string; diff --git a/src/utils/parse-hotkey.ts b/src/utils/parse-hotkey.ts new file mode 100644 index 0000000000000000000000000000000000000000..01addd0583a86b8490fdae995aa3dffd94b8a243 --- /dev/null +++ b/src/utils/parse-hotkey.ts @@ -0,0 +1,29 @@ +const parseHotkey = (key: string) => { + let temp = key.toUpperCase(); + + if (temp.startsWith("ARROW")) { + temp = temp.slice(5); + } else if (temp.startsWith("DIGIT")) { + temp = temp.slice(5); + } else if (temp.startsWith("KEY")) { + temp = temp.slice(3); + } else if (temp.endsWith("LEFT")) { + temp = temp.slice(0, -4); + } else if (temp.endsWith("RIGHT")) { + temp = temp.slice(0, -5); + } + + switch (temp) { + case "CONTROL": + return "CTRL"; + case "META": + return "CMD"; + case " ": + return "SPACE"; + + default: + return temp; + } +}; + +export default parseHotkey;