editor-viewer.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { ReactNode, useEffect, useRef, useState } from "react";
  2. import { useLockFn } from "ahooks";
  3. import { useTranslation } from "react-i18next";
  4. import {
  5. Button,
  6. ButtonGroup,
  7. Dialog,
  8. DialogActions,
  9. DialogContent,
  10. DialogTitle,
  11. IconButton,
  12. } from "@mui/material";
  13. import {
  14. FormatPaintRounded,
  15. OpenInFullRounded,
  16. CloseFullscreenRounded,
  17. } from "@mui/icons-material";
  18. import { useThemeMode } from "@/services/states";
  19. import { Notice } from "@/components/base";
  20. import { nanoid } from "nanoid";
  21. import { appWindow } from "@tauri-apps/api/window";
  22. import getSystem from "@/utils/get-system";
  23. import debounce from "@/utils/debounce";
  24. import * as monaco from "monaco-editor";
  25. import MonacoEditor from "react-monaco-editor";
  26. import { configureMonacoYaml } from "monaco-yaml";
  27. import { type JSONSchema7 } from "json-schema";
  28. import metaSchema from "meta-json-schema/schemas/meta-json-schema.json";
  29. import mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json";
  30. import pac from "types-pac/pac.d.ts?raw";
  31. type Language = "yaml" | "javascript" | "css";
  32. type Schema<T extends Language> = LanguageSchemaMap[T];
  33. interface LanguageSchemaMap {
  34. yaml: "clash" | "merge";
  35. javascript: never;
  36. css: never;
  37. }
  38. interface Props<T extends Language> {
  39. open: boolean;
  40. title?: string | ReactNode;
  41. initialData: Promise<string>;
  42. readOnly?: boolean;
  43. language: T;
  44. schema?: Schema<T>;
  45. onChange?: (prev?: string, curr?: string) => void;
  46. onSave?: (prev?: string, curr?: string) => void;
  47. onClose: () => void;
  48. }
  49. let initialized = false;
  50. const monacoInitialization = () => {
  51. if (initialized) return;
  52. // configure yaml worker
  53. configureMonacoYaml(monaco, {
  54. validate: true,
  55. enableSchemaRequest: true,
  56. schemas: [
  57. {
  58. uri: "http://example.com/meta-json-schema.json",
  59. fileMatch: ["**/*.clash.yaml"],
  60. // @ts-ignore
  61. schema: metaSchema as JSONSchema7,
  62. },
  63. {
  64. uri: "http://example.com/clash-verge-merge-json-schema.json",
  65. fileMatch: ["**/*.merge.yaml"],
  66. // @ts-ignore
  67. schema: mergeSchema as JSONSchema7,
  68. },
  69. ],
  70. });
  71. // configure PAC definition
  72. monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
  73. initialized = true;
  74. };
  75. export const EditorViewer = <T extends Language>(props: Props<T>) => {
  76. const { t } = useTranslation();
  77. const themeMode = useThemeMode();
  78. const [isMaximized, setIsMaximized] = useState(false);
  79. const {
  80. open = false,
  81. title = t("Edit File"),
  82. initialData = Promise.resolve(""),
  83. readOnly = false,
  84. language = "yaml",
  85. schema,
  86. onChange,
  87. onSave,
  88. onClose,
  89. } = props;
  90. const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
  91. const prevData = useRef<string | undefined>("");
  92. const currData = useRef<string | undefined>("");
  93. const editorWillMount = () => {
  94. monacoInitialization(); // initialize monaco
  95. };
  96. const editorDidMount = async (
  97. editor: monaco.editor.IStandaloneCodeEditor
  98. ) => {
  99. editorRef.current = editor;
  100. // retrieve initial data
  101. await initialData.then((data) => {
  102. prevData.current = data;
  103. currData.current = data;
  104. // create and set model
  105. const uri = monaco.Uri.parse(`${nanoid()}.${schema}.${language}`);
  106. const model = monaco.editor.createModel(data, language, uri);
  107. editorRef.current?.setModel(model);
  108. });
  109. };
  110. const handleChange = useLockFn(async (value: string | undefined) => {
  111. try {
  112. currData.current = value;
  113. onChange?.(prevData.current, currData.current);
  114. } catch (err: any) {
  115. Notice.error(err.message || err.toString());
  116. }
  117. });
  118. const handleSave = useLockFn(async () => {
  119. try {
  120. !readOnly && onSave?.(prevData.current, currData.current);
  121. onClose();
  122. } catch (err: any) {
  123. Notice.error(err.message || err.toString());
  124. }
  125. });
  126. const handleClose = useLockFn(async () => {
  127. try {
  128. onClose();
  129. } catch (err: any) {
  130. Notice.error(err.message || err.toString());
  131. }
  132. });
  133. const editorResize = debounce(() => {
  134. editorRef.current?.layout();
  135. setTimeout(() => editorRef.current?.layout(), 500);
  136. }, 100);
  137. useEffect(() => {
  138. const onResized = debounce(() => {
  139. editorResize();
  140. appWindow.isMaximized().then((maximized) => {
  141. setIsMaximized(() => maximized);
  142. });
  143. }, 100);
  144. const unlistenResized = appWindow.onResized(onResized);
  145. return () => {
  146. unlistenResized.then((fn) => fn());
  147. editorRef.current?.dispose();
  148. editorRef.current = undefined;
  149. };
  150. }, []);
  151. return (
  152. <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
  153. <DialogTitle>{title}</DialogTitle>
  154. <DialogContent
  155. sx={{
  156. width: "auto",
  157. height: "calc(100vh - 185px)",
  158. overflow: "hidden",
  159. }}
  160. >
  161. <MonacoEditor
  162. language={language}
  163. theme={themeMode === "light" ? "vs" : "vs-dark"}
  164. options={{
  165. tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
  166. minimap: {
  167. enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
  168. },
  169. mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
  170. readOnly: readOnly, // 只读模式
  171. readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息
  172. renderValidationDecorations: "on", // 只读模式下显示校验信息
  173. quickSuggestions: {
  174. strings: true, // 字符串类型的建议
  175. comments: true, // 注释类型的建议
  176. other: true, // 其他类型的建议
  177. },
  178. padding: {
  179. top: 33, // 顶部padding防止遮挡snippets
  180. },
  181. fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
  182. getSystem() === "windows" ? ", twemoji mozilla" : ""
  183. }`,
  184. fontLigatures: true, // 连字符
  185. smoothScrolling: true, // 平滑滚动
  186. }}
  187. editorWillMount={editorWillMount}
  188. editorDidMount={editorDidMount}
  189. onChange={handleChange}
  190. />
  191. <ButtonGroup
  192. variant="contained"
  193. sx={{ position: "absolute", left: "14px", bottom: "8px" }}
  194. >
  195. <IconButton
  196. size="medium"
  197. color="inherit"
  198. sx={{ display: readOnly ? "none" : "" }}
  199. title={t("Format document")}
  200. onClick={() =>
  201. editorRef.current
  202. ?.getAction("editor.action.formatDocument")
  203. ?.run()
  204. }
  205. >
  206. <FormatPaintRounded fontSize="inherit" />
  207. </IconButton>
  208. <IconButton
  209. size="medium"
  210. color="inherit"
  211. title={t(isMaximized ? "Minimize" : "Maximize")}
  212. onClick={() => appWindow.toggleMaximize().then(editorResize)}
  213. >
  214. {isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />}
  215. </IconButton>
  216. </ButtonGroup>
  217. </DialogContent>
  218. <DialogActions>
  219. <Button onClick={handleClose} variant="outlined">
  220. {t(readOnly ? "Close" : "Cancel")}
  221. </Button>
  222. {!readOnly && (
  223. <Button onClick={handleSave} variant="contained">
  224. {t("Save")}
  225. </Button>
  226. )}
  227. </DialogActions>
  228. </Dialog>
  229. );
  230. };