refactor: editor-viewer using react-monaco-editor

This commit is contained in:
dongchengjie 2024-07-02 21:02:29 +08:00
parent 0021fc24bb
commit f69e1d2a0c
8 changed files with 195 additions and 182 deletions

View File

@ -9,176 +9,184 @@ import {
DialogTitle, DialogTitle,
} from "@mui/material"; } from "@mui/material";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { Notice } from "@/components/base"; import { Notice } from "@/components/base";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import getSystem from "@/utils/get-system"; import getSystem from "@/utils/get-system";
import * as monaco from "monaco-editor"; 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 { configureMonacoYaml } from "monaco-yaml";
import { type JSONSchema7 } from "json-schema"; import { type JSONSchema7 } from "json-schema";
import metaSchema from "meta-json-schema/schemas/meta-json-schema.json"; 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 mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json";
import pac from "types-pac/pac.d.ts?raw"; import pac from "types-pac/pac.d.ts?raw";
interface Props { type Language = "yaml" | "javascript" | "css";
title?: string | ReactNode; type Schema<T extends Language> = LanguageSchemaMap[T];
mode: "profile" | "text"; interface LanguageSchemaMap {
property: string; yaml: "clash" | "merge";
open: boolean; javascript: never;
readOnly?: boolean; css: never;
language: "yaml" | "javascript" | "css";
schema?: "clash" | "merge";
onClose: () => void;
onChange?: (prev?: string, curr?: string) => void;
} }
// yaml worker interface Props<T extends Language> {
configureMonacoYaml(monaco, { open: boolean;
validate: true, title?: string | ReactNode;
enableSchemaRequest: true, initialData: Promise<string>;
schemas: [ readOnly?: boolean;
{ language: T;
uri: "http://example.com/meta-json-schema.json", schema?: Schema<T>;
fileMatch: ["**/*.clash.yaml"], onChange?: (prev?: string, curr?: string) => void;
//@ts-ignore onSave?: (prev?: string, curr?: string) => void;
schema: metaSchema as JSONSchema7, onClose: () => void;
}, }
{
uri: "http://example.com/clash-verge-merge-json-schema.json", let initialized = false;
fileMatch: ["**/*.merge.yaml"], const monacoInitialization = () => {
//@ts-ignore if (initialized) return;
schema: mergeSchema as JSONSchema7,
}, // configure yaml worker
], configureMonacoYaml(monaco, {
}); validate: true,
// PAC definition enableSchemaRequest: true,
monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts"); schemas: [
monaco.languages.registerCompletionItemProvider("javascript", {
provideCompletionItems: (model, position) => ({
suggestions: [
{ {
label: "%mixed-port%", uri: "http://example.com/meta-json-schema.json",
kind: monaco.languages.CompletionItemKind.Text, fileMatch: ["**/*.clash.yaml"],
insertText: "%mixed-port%", // @ts-ignore
range: { schema: metaSchema as JSONSchema7,
startLineNumber: position.lineNumber, },
endLineNumber: position.lineNumber, {
startColumn: model.getWordUntilPosition(position).startColumn - 1, uri: "http://example.com/clash-verge-merge-json-schema.json",
endColumn: model.getWordUntilPosition(position).endColumn - 1, fileMatch: ["**/*.merge.yaml"],
}, // @ts-ignore
schema: mergeSchema as JSONSchema7,
}, },
], ],
}), });
}); // configure PAC definition
monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
export const EditorViewer = (props: Props) => { initialized = true;
const { };
title,
mode, export const EditorViewer = <T extends Language>(props: Props<T>) => {
property,
open,
readOnly,
language,
schema,
onClose,
onChange,
} = props;
const { t } = useTranslation(); const { t } = useTranslation();
const editorRef = useRef<any>();
const instanceRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const themeMode = useThemeMode(); const themeMode = useThemeMode();
const prevData = useRef<string>();
useEffect(() => { const {
if (!open) return; open = false,
title = t("Edit File"),
initialData = Promise.resolve(""),
readOnly = false,
language = "yaml",
schema,
onChange,
onSave,
onClose,
} = props;
let fetchContent; const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
switch (mode) { const prevData = useRef<string | undefined>("");
case "profile": // profile文件 const currData = useRef<string | undefined>("");
fetchContent = readProfileFile(property);
break;
case "text": // 文本内容
fetchContent = Promise.resolve(property);
break;
}
fetchContent.then((data) => {
const dom = editorRef.current;
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 uri = monaco.Uri.parse(`${nanoid()}.${schema}.${language}`);
const model = monaco.editor.createModel(data, language, uri); const model = monaco.editor.createModel(data, language, uri);
instanceRef.current = editor.create(editorRef.current, { editorRef.current?.setModel(model);
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;
}); });
};
return () => { const handleChange = useLockFn(async (value: string | undefined) => {
if (instanceRef.current) {
instanceRef.current.dispose();
instanceRef.current = null;
}
};
}, [open]);
const onSave = useLockFn(async () => {
const currData = instanceRef.current?.getValue();
if (currData == null) return;
try { try {
if (mode === "profile") { currData.current = value;
await saveProfileFile(property, currData); onChange?.(prevData.current, currData.current);
} } catch (err: any) {
onChange?.(prevData.current, currData); Notice.error(err.message || err.toString());
}
});
const handleSave = useLockFn(async () => {
try {
!readOnly && onSave?.(prevData.current, currData.current);
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
Notice.error(err.message || err.toString()); Notice.error(err.message || err.toString());
} }
}); });
const handleClose = useLockFn(async () => {
try {
onClose();
} catch (err: any) {
Notice.error(err.message || err.toString());
}
});
useEffect(() => {
return () => {
editorRef.current?.dispose();
editorRef.current = undefined;
};
}, []);
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
<DialogTitle>{title ?? t("Edit File")}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ width: "auto", height: "100vh" }}> <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> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} variant="outlined"> <Button onClick={handleClose} variant="outlined">
{t(readOnly ? "Close" : "Cancel")} {t(readOnly ? "Close" : "Cancel")}
</Button> </Button>
{!readOnly && ( {!readOnly && (
<Button onClick={onSave} variant="contained"> <Button onClick={handleSave} variant="contained">
{t("Save")} {t("Save")}
</Button> </Button>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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