浏览代码

refactor: editor-viewer using react-monaco-editor

dongchengjie 11 月之前
父节点
当前提交
f69e1d2a0c

+ 127 - 119
src/components/profile/editor-viewer.tsx

@@ -9,176 +9,184 @@ import {
   DialogTitle,
 } from "@mui/material";
 import { useThemeMode } from "@/services/states";
-import { readProfileFile, saveProfileFile } from "@/services/cmds";
 import { Notice } from "@/components/base";
 import { nanoid } from "nanoid";
 import getSystem from "@/utils/get-system";
 
 import * as monaco from "monaco-editor";
-import { editor } from "monaco-editor/esm/vs/editor/editor.api";
+import MonacoEditor from "react-monaco-editor";
 import { configureMonacoYaml } from "monaco-yaml";
-
 import { type JSONSchema7 } from "json-schema";
 import metaSchema from "meta-json-schema/schemas/meta-json-schema.json";
 import mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json";
 import pac from "types-pac/pac.d.ts?raw";
 
-interface Props {
-  title?: string | ReactNode;
-  mode: "profile" | "text";
-  property: string;
+type Language = "yaml" | "javascript" | "css";
+type Schema<T extends Language> = LanguageSchemaMap[T];
+interface LanguageSchemaMap {
+  yaml: "clash" | "merge";
+  javascript: never;
+  css: never;
+}
+
+interface Props<T extends Language> {
   open: boolean;
+  title?: string | ReactNode;
+  initialData: Promise<string>;
   readOnly?: boolean;
-  language: "yaml" | "javascript" | "css";
-  schema?: "clash" | "merge";
-  onClose: () => void;
+  language: T;
+  schema?: Schema<T>;
   onChange?: (prev?: string, curr?: string) => void;
+  onSave?: (prev?: string, curr?: string) => void;
+  onClose: () => void;
 }
 
-// yaml worker
-configureMonacoYaml(monaco, {
-  validate: true,
-  enableSchemaRequest: true,
-  schemas: [
-    {
-      uri: "http://example.com/meta-json-schema.json",
-      fileMatch: ["**/*.clash.yaml"],
-      //@ts-ignore
-      schema: metaSchema as JSONSchema7,
-    },
-    {
-      uri: "http://example.com/clash-verge-merge-json-schema.json",
-      fileMatch: ["**/*.merge.yaml"],
-      //@ts-ignore
-      schema: mergeSchema as JSONSchema7,
-    },
-  ],
-});
-// PAC definition
-monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
-monaco.languages.registerCompletionItemProvider("javascript", {
-  provideCompletionItems: (model, position) => ({
-    suggestions: [
+let initialized = false;
+const monacoInitialization = () => {
+  if (initialized) return;
+
+  // configure yaml worker
+  configureMonacoYaml(monaco, {
+    validate: true,
+    enableSchemaRequest: true,
+    schemas: [
       {
-        label: "%mixed-port%",
-        kind: monaco.languages.CompletionItemKind.Text,
-        insertText: "%mixed-port%",
-        range: {
-          startLineNumber: position.lineNumber,
-          endLineNumber: position.lineNumber,
-          startColumn: model.getWordUntilPosition(position).startColumn - 1,
-          endColumn: model.getWordUntilPosition(position).endColumn - 1,
-        },
+        uri: "http://example.com/meta-json-schema.json",
+        fileMatch: ["**/*.clash.yaml"],
+        // @ts-ignore
+        schema: metaSchema as JSONSchema7,
+      },
+      {
+        uri: "http://example.com/clash-verge-merge-json-schema.json",
+        fileMatch: ["**/*.merge.yaml"],
+        // @ts-ignore
+        schema: mergeSchema as JSONSchema7,
       },
     ],
-  }),
-});
+  });
+  // configure PAC definition
+  monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
+
+  initialized = true;
+};
+
+export const EditorViewer = <T extends Language>(props: Props<T>) => {
+  const { t } = useTranslation();
+  const themeMode = useThemeMode();
 
-export const EditorViewer = (props: Props) => {
   const {
-    title,
-    mode,
-    property,
-    open,
-    readOnly,
-    language,
+    open = false,
+    title = t("Edit File"),
+    initialData = Promise.resolve(""),
+    readOnly = false,
+    language = "yaml",
     schema,
-    onClose,
     onChange,
+    onSave,
+    onClose,
   } = props;
-  const { t } = useTranslation();
-  const editorRef = useRef<any>();
-  const instanceRef = useRef<editor.IStandaloneCodeEditor | null>(null);
-  const themeMode = useThemeMode();
-  const prevData = useRef<string>();
 
-  useEffect(() => {
-    if (!open) return;
-
-    let fetchContent;
-    switch (mode) {
-      case "profile": // profile文件
-        fetchContent = readProfileFile(property);
-        break;
-      case "text": // 文本内容
-        fetchContent = Promise.resolve(property);
-        break;
-    }
-    fetchContent.then((data) => {
-      const dom = editorRef.current;
+  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
+  const prevData = useRef<string | undefined>("");
+  const currData = useRef<string | undefined>("");
 
-      if (!dom) return;
+  const editorWillMount = () => {
+    monacoInitialization(); // initialize monaco
+  };
 
-      if (instanceRef.current) instanceRef.current.dispose();
+  const editorDidMount = async (
+    editor: monaco.editor.IStandaloneCodeEditor
+  ) => {
+    editorRef.current = editor;
 
+    // retrieve initial data
+    await initialData.then((data) => {
+      prevData.current = data;
+      currData.current = data;
+
+      // create and set model
       const uri = monaco.Uri.parse(`${nanoid()}.${schema}.${language}`);
       const model = monaco.editor.createModel(data, language, uri);
-      instanceRef.current = editor.create(editorRef.current, {
-        model: model,
-        language: language,
-        tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
-        theme: themeMode === "light" ? "vs" : "vs-dark",
-        minimap: { enabled: dom.clientWidth >= 1000 }, // 超过一定宽度显示minimap滚动条
-        mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
-        readOnly: readOnly, // 只读模式
-        readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息
-        renderValidationDecorations: "on", // 只读模式下显示校验信息
-        quickSuggestions: {
-          strings: true, // 字符串类型的建议
-          comments: true, // 注释类型的建议
-          other: true, // 其他类型的建议
-        },
-        padding: {
-          top: 33, // 顶部padding防止遮挡snippets
-        },
-        fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
-          getSystem() === "windows" ? ", twemoji mozilla" : ""
-        }`,
-        fontLigatures: true, // 连字符
-        smoothScrolling: true, // 平滑滚动
-      });
-
-      prevData.current = data;
+      editorRef.current?.setModel(model);
     });
+  };
 
-    return () => {
-      if (instanceRef.current) {
-        instanceRef.current.dispose();
-        instanceRef.current = null;
-      }
-    };
-  }, [open]);
-
-  const onSave = useLockFn(async () => {
-    const currData = instanceRef.current?.getValue();
+  const handleChange = useLockFn(async (value: string | undefined) => {
+    try {
+      currData.current = value;
+      onChange?.(prevData.current, currData.current);
+    } catch (err: any) {
+      Notice.error(err.message || err.toString());
+    }
+  });
 
-    if (currData == null) return;
+  const handleSave = useLockFn(async () => {
+    try {
+      !readOnly && onSave?.(prevData.current, currData.current);
+      onClose();
+    } catch (err: any) {
+      Notice.error(err.message || err.toString());
+    }
+  });
 
+  const handleClose = useLockFn(async () => {
     try {
-      if (mode === "profile") {
-        await saveProfileFile(property, currData);
-      }
-      onChange?.(prevData.current, currData);
       onClose();
     } catch (err: any) {
       Notice.error(err.message || err.toString());
     }
   });
 
+  useEffect(() => {
+    return () => {
+      editorRef.current?.dispose();
+      editorRef.current = undefined;
+    };
+  }, []);
+
   return (
     <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
-      <DialogTitle>{title ?? t("Edit File")}</DialogTitle>
+      <DialogTitle>{title}</DialogTitle>
 
       <DialogContent sx={{ width: "auto", height: "100vh" }}>
-        <div style={{ width: "100%", height: "100%" }} ref={editorRef} />
+        <MonacoEditor
+          language={language}
+          theme={themeMode === "light" ? "vs" : "vs-dark"}
+          options={{
+            tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
+            minimap: {
+              enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
+            },
+            mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
+            readOnly: readOnly, // 只读模式
+            readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息
+            renderValidationDecorations: "on", // 只读模式下显示校验信息
+            quickSuggestions: {
+              strings: true, // 字符串类型的建议
+              comments: true, // 注释类型的建议
+              other: true, // 其他类型的建议
+            },
+            padding: {
+              top: 33, // 顶部padding防止遮挡snippets
+            },
+            fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
+              getSystem() === "windows" ? ", twemoji mozilla" : ""
+            }`,
+            fontLigatures: true, // 连字符
+            smoothScrolling: true, // 平滑滚动
+          }}
+          editorWillMount={editorWillMount}
+          editorDidMount={editorDidMount}
+          onChange={handleChange}
+        />
       </DialogContent>
 
       <DialogActions>
-        <Button onClick={onClose} variant="outlined">
+        <Button onClick={handleClose} variant="outlined">
           {t(readOnly ? "Close" : "Cancel")}
         </Button>
         {!readOnly && (
-          <Button onClick={onSave} variant="contained">
+          <Button onClick={handleSave} variant="contained">
             {t("Save")}
           </Button>
         )}

+ 35 - 27
src/components/profile/profile-item.tsx

@@ -17,7 +17,12 @@ import {
 } from "@mui/material";
 import { RefreshRounded, DragIndicator } from "@mui/icons-material";
 import { useLoadingCache, useSetLoadingCache } from "@/services/states";
-import { updateProfile, viewProfile } from "@/services/cmds";
+import {
+  viewProfile,
+  readProfileFile,
+  updateProfile,
+  saveProfileFile,
+} from "@/services/cmds";
 import { Notice } from "@/components/base";
 import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
 import { EditorViewer } from "@/components/profile/editor-viewer";
@@ -37,20 +42,13 @@ interface Props {
   itemData: IProfileItem;
   onSelect: (force: boolean) => void;
   onEdit: () => void;
-  onChange?: (prev?: string, curr?: string) => void;
+  onSave?: (prev?: string, curr?: string) => void;
   onDelete: () => void;
 }
 
 export const ProfileItem = (props: Props) => {
-  const {
-    selected,
-    activating,
-    itemData,
-    onSelect,
-    onEdit,
-    onChange,
-    onDelete,
-  } = props;
+  const { selected, activating, itemData, onSelect, onEdit, onSave, onDelete } =
+    props;
   const { attributes, listeners, setNodeRef, transform, transition } =
     useSortable({ id: props.id });
 
@@ -474,52 +472,62 @@ export const ProfileItem = (props: Props) => {
       </Menu>
 
       <EditorViewer
-        mode="profile"
-        property={uid}
         open={fileOpen}
+        initialData={readProfileFile(uid)}
         language="yaml"
         schema="clash"
-        onChange={onChange}
+        onSave={async (prev, curr) => {
+          await saveProfileFile(uid, curr ?? "");
+          onSave && onSave(prev, curr);
+        }}
         onClose={() => setFileOpen(false)}
       />
       <RulesEditorViewer
         profileUid={uid}
         property={option?.rules ?? ""}
         open={rulesOpen}
-        onChange={onChange}
+        onSave={onSave}
         onClose={() => setRulesOpen(false)}
       />
       <EditorViewer
-        mode="profile"
-        property={option?.proxies ?? ""}
         open={proxiesOpen}
+        initialData={readProfileFile(option?.proxies ?? "")}
         language="yaml"
-        onChange={onChange}
+        onSave={async (prev, curr) => {
+          await saveProfileFile(option?.proxies ?? "", curr ?? "");
+          onSave && onSave(prev, curr);
+        }}
         onClose={() => setProxiesOpen(false)}
       />
       <EditorViewer
-        mode="profile"
-        property={option?.groups ?? ""}
         open={groupsOpen}
+        initialData={readProfileFile(option?.proxies ?? "")}
         language="yaml"
-        onChange={onChange}
+        onSave={async (prev, curr) => {
+          await saveProfileFile(option?.proxies ?? "", curr ?? "");
+          onSave && onSave(prev, curr);
+        }}
         onClose={() => setGroupsOpen(false)}
       />
       <EditorViewer
-        mode="profile"
-        property={option?.merge ?? ""}
         open={mergeOpen}
+        initialData={readProfileFile(option?.merge ?? "")}
         language="yaml"
         schema="clash"
-        onChange={onChange}
+        onSave={async (prev, curr) => {
+          await saveProfileFile(option?.merge ?? "", curr ?? "");
+          onSave && onSave(prev, curr);
+        }}
         onClose={() => setMergeOpen(false)}
       />
       <EditorViewer
-        mode="profile"
-        property={option?.script ?? ""}
         open={scriptOpen}
+        initialData={readProfileFile(option?.script ?? "")}
         language="javascript"
-        onChange={onChange}
+        onSave={async (prev, curr) => {
+          await saveProfileFile(option?.script ?? "", curr ?? "");
+          onSave && onSave(prev, curr);
+        }}
         onClose={() => setScriptOpen(false)}
       />
       <ConfirmViewer

+ 10 - 7
src/components/profile/profile-more.tsx

@@ -11,7 +11,7 @@ import {
   IconButton,
 } from "@mui/material";
 import { FeaturedPlayListRounded } from "@mui/icons-material";
-import { viewProfile } from "@/services/cmds";
+import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
 import { Notice } from "@/components/base";
 import { EditorViewer } from "@/components/profile/editor-viewer";
 import { ProfileBox } from "./profile-box";
@@ -20,14 +20,14 @@ import { LogViewer } from "./log-viewer";
 interface Props {
   logInfo?: [string, string][];
   id: "Merge" | "Script";
-  onChange?: (prev?: string, curr?: string) => void;
+  onSave?: (prev?: string, curr?: string) => void;
 }
 
 // profile enhanced item
 export const ProfileMore = (props: Props) => {
-  const { id, logInfo = [], onChange } = props;
+  const { id, logInfo = [], onSave } = props;
 
-  const { t, i18n } = useTranslation();
+  const { t } = useTranslation();
   const [anchorEl, setAnchorEl] = useState<any>(null);
   const [position, setPosition] = useState({ left: 0, top: 0 });
   const [fileOpen, setFileOpen] = useState(false);
@@ -169,12 +169,15 @@ export const ProfileMore = (props: Props) => {
       </Menu>
 
       <EditorViewer
-        mode="profile"
-        property={id}
         open={fileOpen}
+        title={`${t("Global " + id)}`}
+        initialData={readProfileFile(id)}
         language={id === "Merge" ? "yaml" : "javascript"}
         schema={id === "Merge" ? "clash" : undefined}
-        onChange={onChange}
+        onSave={async (prev, curr) => {
+          await saveProfileFile(id, curr ?? "");
+          onSave && onSave(prev, curr);
+        }}
         onClose={() => setFileOpen(false)}
       />
 

+ 5 - 5
src/components/profile/rules-editor-viewer.tsx

@@ -42,7 +42,7 @@ interface Props {
   property: string;
   open: boolean;
   onClose: () => void;
-  onChange?: (prev?: string, curr?: string) => void;
+  onSave?: (prev?: string, curr?: string) => void;
 }
 
 const portValidator = (value: string): boolean => {
@@ -227,7 +227,7 @@ const rules: {
 const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
 
 export const RulesEditorViewer = (props: Props) => {
-  const { title, profileUid, property, open, onClose, onChange } = props;
+  const { title, profileUid, property, open, onClose, onSave } = props;
   const { t } = useTranslation();
 
   const [prevData, setPrevData] = useState("");
@@ -330,7 +330,7 @@ export const RulesEditorViewer = (props: Props) => {
     },${proxyPolicy}${ruleType.noResolve && noResolve ? ",no-resolve" : ""}`;
   };
 
-  const onSave = useLockFn(async () => {
+  const handleSave = useLockFn(async () => {
     try {
       let currData = yaml.dump({
         prepend: prependSeq,
@@ -338,7 +338,7 @@ export const RulesEditorViewer = (props: Props) => {
         delete: deleteSeq,
       });
       await saveProfileFile(property, currData);
-      onChange?.(prevData, currData);
+      onSave?.(prevData, currData);
       onClose();
     } catch (err: any) {
       Notice.error(err.message || err.toString());
@@ -575,7 +575,7 @@ export const RulesEditorViewer = (props: Props) => {
           {t("Cancel")}
         </Button>
 
-        <Button onClick={onSave} variant="contained">
+        <Button onClick={handleSave} variant="contained">
           {t("Save")}
         </Button>
       </DialogActions>

+ 2 - 3
src/components/setting/mods/config-viewer.tsx

@@ -22,15 +22,14 @@ export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
 
   return (
     <EditorViewer
+      open={open}
       title={
         <Box>
           {t("Runtime Config")}
           <Chip label={t("ReadOnly")} size="small" />
         </Box>
       }
-      mode="text"
-      property={runtimeConfig}
-      open={open}
+      initialData={Promise.resolve(runtimeConfig)}
       readOnly
       language="yaml"
       schema="clash"

+ 4 - 7
src/components/setting/mods/sysproxy-viewer.tsx

@@ -262,21 +262,18 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
                 {t("Edit")} PAC
               </Button>
               <EditorViewer
-                title={`${t("Edit")} PAC`}
-                mode="text"
-                property={value.pac_content ?? ""}
                 open={editorOpen}
+                title={`${t("Edit")} PAC`}
+                initialData={Promise.resolve(value.pac_content ?? "")}
                 language="javascript"
-                onChange={(_prev, curr) => {
+                onSave={(_prev, curr) => {
                   let pac = DEFAULT_PAC;
                   if (curr && curr.trim().length > 0) {
                     pac = curr;
                   }
                   setValue((v) => ({ ...v, pac_content: pac }));
                 }}
-                onClose={() => {
-                  setEditorOpen(false);
-                }}
+                onClose={() => setEditorOpen(false)}
               />
             </ListItem>
           </>

+ 3 - 4
src/components/setting/mods/theme-viewer.tsx

@@ -124,12 +124,11 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
             {t("Edit")} CSS
           </Button>
           <EditorViewer
-            title={`${t("Edit")} CSS`}
-            mode="text"
-            property={theme.css_injection ?? ""}
             open={editorOpen}
+            title={`${t("Edit")} CSS`}
+            initialData={Promise.resolve(theme.css_injection ?? "")}
             language="css"
-            onChange={(_prev, curr) => {
+            onSave={(_prev, curr) => {
               theme.css_injection = curr;
               handleChange("css_injection");
             }}

+ 3 - 4
src/pages/profiles.tsx

@@ -50,7 +50,6 @@ import { BaseStyledTextField } from "@/components/base/base-styled-text-field";
 import { listen } from "@tauri-apps/api/event";
 import { readTextFile } from "@tauri-apps/api/fs";
 import { readText } from "@tauri-apps/api/clipboard";
-import { EditorViewer } from "@/components/profile/editor-viewer";
 
 const ProfilePage = () => {
   const { t } = useTranslation();
@@ -378,7 +377,7 @@ const ProfilePage = () => {
                       itemData={item}
                       onSelect={(f) => onSelect(item.uid, f)}
                       onEdit={() => viewerRef.current?.edit(item)}
-                      onChange={async (prev, curr) => {
+                      onSave={async (prev, curr) => {
                         if (prev !== curr && profiles.current === item.uid) {
                           await onEnhance();
                         }
@@ -401,7 +400,7 @@ const ProfilePage = () => {
             <Grid item xs={12} sm={6} md={6} lg={6}>
               <ProfileMore
                 id="Merge"
-                onChange={async (prev, curr) => {
+                onSave={async (prev, curr) => {
                   if (prev !== curr) {
                     await onEnhance();
                   }
@@ -412,7 +411,7 @@ const ProfilePage = () => {
               <ProfileMore
                 id="Script"
                 logInfo={chainLogs["Script"]}
-                onChange={async (prev, curr) => {
+                onSave={async (prev, curr) => {
                   if (prev !== curr) {
                     await onEnhance();
                   }