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,
} 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;
open: boolean;
readOnly?: boolean;
language: "yaml" | "javascript" | "css";
schema?: "clash" | "merge";
onClose: () => void;
onChange?: (prev?: string, curr?: string) => void;
type Language = "yaml" | "javascript" | "css";
type Schema<T extends Language> = LanguageSchemaMap[T];
interface LanguageSchemaMap {
yaml: "clash" | "merge";
javascript: never;
css: never;
}
// 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: [
interface Props<T extends Language> {
open: boolean;
title?: string | ReactNode;
initialData: Promise<string>;
readOnly?: boolean;
language: T;
schema?: Schema<T>;
onChange?: (prev?: string, curr?: string) => void;
onSave?: (prev?: string, curr?: string) => void;
onClose: () => void;
}
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");
export const EditorViewer = (props: Props) => {
const {
title,
mode,
property,
open,
readOnly,
language,
schema,
onClose,
onChange,
} = props;
initialized = true;
};
export const EditorViewer = <T extends Language>(props: Props<T>) => {
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;
const {
open = false,
title = t("Edit File"),
initialData = Promise.resolve(""),
readOnly = false,
language = "yaml",
schema,
onChange,
onSave,
onClose,
} = props;
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();
if (currData == null) return;
const handleChange = useLockFn(async (value: string | undefined) => {
try {
if (mode === "profile") {
await saveProfileFile(property, currData);
}
onChange?.(prevData.current, currData);
currData.current = value;
onChange?.(prevData.current, currData.current);
} catch (err: any) {
Notice.error(err.message || err.toString());
}
});
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 {
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>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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