Ver código fonte

pref: json editor (#821)

Shuyoou 4 meses atrás
pai
commit
29ae19f7e7

+ 2 - 1
client/package.json

@@ -11,6 +11,7 @@
     "@codemirror/lang-javascript": "^6.2.2",
     "@codemirror/lang-json": "^6.0.1",
     "@codemirror/language": "^6.10.1",
+    "@codemirror/legacy-modes": "^6.5.0",
     "@codemirror/lint": "^6.8.0",
     "@codemirror/state": "^6.4.1",
     "@codemirror/view": "^6.28.6",
@@ -18,9 +19,9 @@
     "@ddietr/codemirror-themes": "^1.4.2",
     "@emotion/react": "^11.13.0",
     "@emotion/styled": "^11.13.0",
+    "@json2csv/plainjs": "^7.0.6",
     "@lezer/common": "^1.2.3",
     "@lezer/highlight": "^1.2.1",
-    "@json2csv/plainjs": "^7.0.6",
     "@mui/icons-material": "^5.17.1",
     "@mui/material": "^5.17.1",
     "@mui/styles": "^5.17.1",

+ 20 - 88
client/src/pages/dialogs/EditJSONDialog.tsx

@@ -1,21 +1,15 @@
-import { FC, useEffect, useRef, useState } from 'react';
-import { Theme, useTheme } from '@mui/material';
-import { EditorState, Compartment } from '@codemirror/state';
-import { EditorView, keymap, ViewUpdate } from '@codemirror/view';
-import { insertTab } from '@codemirror/commands';
-import { indentUnit } from '@codemirror/language';
-import { basicSetup } from 'codemirror';
-import { json, jsonParseLinter } from '@codemirror/lang-json';
-import { linter } from '@codemirror/lint';
+import { FC, useState } from 'react';
+import { Theme } from '@mui/material';
 import { useTranslation } from 'react-i18next';
 import DialogTemplate from '@/components/customDialog/DialogTemplate';
 import { makeStyles } from '@mui/styles';
-import { githubLight } from '@ddietr/codemirror-themes/github-light';
-import { githubDark } from '@ddietr/codemirror-themes/github-dark';
+
+import { JSONEditor } from '../play/JSONEditor';
 
 const useStyles = makeStyles((theme: Theme) => ({
   code: {
     border: `1px solid ${theme.palette.divider}`,
+    borderRadius: 4,
     overflow: 'auto',
   },
   tip: {
@@ -35,93 +29,35 @@ type EditJSONDialogProps = {
   cb?: () => void;
 };
 
-// json linter for cm
-const linterExtension = linter(jsonParseLinter());
-
 const EditJSONDialog: FC<EditJSONDialogProps> = props => {
-  // hooks
-  const theme = useTheme();
-  const themeCompartment = new Compartment();
-
   // props
   const { data, handleCloseDialog, handleConfirm } = props;
   // UI states
   const [disabled, setDisabled] = useState(true);
   // translations
   const { t: btnTrans } = useTranslation('btn');
-  // refs
-  const editorEl = useRef<HTMLDivElement>(null);
-  const editor = useRef<EditorView>();
   // styles
   const classes = useStyles();
 
   const originalData = JSON.stringify(data, null, 2) + '\n';
-
-  // create editor
-  useEffect(() => {
-    if (!editor.current) {
-      const startState = EditorState.create({
-        doc: originalData,
-        extensions: [
-          basicSetup,
-          json(),
-          linterExtension,
-          keymap.of([{ key: 'Tab', run: insertTab }]), // fix tab behaviour
-          indentUnit.of('    '), // fix tab indentation
-          EditorView.theme({
-            '&.cm-editor': {
-              '&.cm-focused': {
-                outline: 'none',
-              },
-            },
-            '.cm-content': {
-              fontSize: '12px',
-            },
-            '.cm-tooltip-lint': {
-              width: '80%',
-            },
-          }),
-          themeCompartment.of(
-            theme.palette.mode === 'light' ? githubLight : githubDark
-          ),
-          EditorView.lineWrapping,
-          EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
-            if (viewUpdate.docChanged) {
-              const d = jsonParseLinter()(view);
-              if (d.length !== 0) {
-                setDisabled(true);
-                return;
-              }
-
-              const doc = viewUpdate.state.doc;
-              const value = doc.toString();
-
-              setDisabled(value === originalData);
-            }
-          }),
-        ],
-      });
-
-      const view = new EditorView({
-        state: startState,
-        parent: editorEl.current!,
-      });
-
-      editor.current = view;
-
-      return () => {
-        view.destroy();
-        editor.current = undefined;
-      };
-    }
-  }, [JSON.stringify(data)]);
+  const [value, setValue] = useState(originalData);
 
   // handle confirm
   const _handleConfirm = async () => {
-    handleConfirm(JSON.parse(editor.current!.state.doc.toString()));
+    handleConfirm(JSON.parse(value));
     handleCloseDialog();
   };
 
+  const handleChange = (docValue: string) => {
+    try {
+      setValue(docValue);
+      const jsonValue = JSON.parse(docValue);
+      setDisabled(docValue === originalData);
+    } catch (err) {
+      setDisabled(true);
+    }
+  };
+
   return (
     <DialogTemplate
       title={props.dialogTitle}
@@ -134,13 +70,9 @@ const EditJSONDialog: FC<EditJSONDialogProps> = props => {
               __html: props.dialogTip,
             }}
           ></div>
-          <div
-            className={`${classes.code} cm-editor`}
-            ref={editorEl}
-            onClick={() => {
-              if (editor.current) editor.current.focus();
-            }}
-          ></div>
+          <div className={classes.code}>
+            <JSONEditor value={originalData} onChange={handleChange} />
+          </div>
         </>
       }
       confirmDisabled={disabled}

+ 64 - 0
client/src/pages/play/JSONEditor.tsx

@@ -0,0 +1,64 @@
+import { EditorView, ViewUpdate } from '@codemirror/view';
+import { FC, useRef, useMemo } from 'react';
+import { useTheme } from '@mui/material';
+import { json, jsonParseLinter } from '@codemirror/lang-json';
+import { linter } from '@codemirror/lint';
+
+import { useCodeMirror } from './hooks/use-codemirror';
+import { jsonFoldGutter } from './language/extensions/fold';
+
+import { getCMStyle, getStyles } from './style';
+import {
+  HighlightStyle,
+  StreamLanguage,
+  syntaxHighlighting,
+} from '@codemirror/language';
+import { json as jsonMode } from '@codemirror/legacy-modes/mode/javascript';
+import { tags } from '@lezer/highlight';
+
+const getJsonHighlightStyle = (isDarkMode: boolean) =>
+  HighlightStyle.define([
+    { tag: tags.string, color: isDarkMode ? '#9CDCFE' : '#085bd7' },
+    { tag: tags.number, color: isDarkMode ? '#50fa7b' : '#0c7e5e' },
+    { tag: tags.bool, color: '#a00' },
+    { tag: tags.null, color: '#a00' },
+    { tag: tags.propertyName, color: '#a0a' },
+    { tag: tags.punctuation, color: '#555' },
+  ]);
+
+type Props = {
+  value: string;
+  editable?: boolean;
+  onChange?: (value: string) => void;
+};
+
+export const JSONEditor: FC<Props> = props => {
+  const { value, editable = true, onChange } = props;
+  const theme = useTheme();
+  const container = useRef<HTMLDivElement>(null);
+  const classes = getStyles();
+
+  const isDarkMode = theme.palette.mode === 'dark';
+
+  const extensions = useMemo(() => {
+    return [
+      EditorView.lineWrapping,
+      EditorView.editable.of(editable),
+      EditorView.theme(getCMStyle(theme)),
+      StreamLanguage.define(jsonMode as any),
+      syntaxHighlighting(getJsonHighlightStyle(isDarkMode)),
+      json(),
+      jsonFoldGutter(),
+      linter(jsonParseLinter()),
+    ];
+  }, [theme.palette.mode, isDarkMode, editable]);
+
+  useCodeMirror({
+    container: container.current,
+    value,
+    extensions,
+    onChange,
+  });
+
+  return <div ref={container} className={classes.editor}></div>;
+};

+ 6 - 27
client/src/pages/play/Play.tsx

@@ -10,11 +10,11 @@ import {
   useState,
 } from 'react';
 
-import CodeBlock from '@/components/code/CodeBlock';
 import { ATTU_PLAY_CODE } from '@/consts';
 import { authContext, dataContext } from '@/context';
 import { useNavigationHook } from '@/hooks';
 import { ALL_ROUTER_TYPES } from '@/router/consts';
+import { DEFAULT_CODE_VALUE } from '@/pages/play/Constants';
 
 import { useCodeMirror } from './hooks/use-codemirror';
 import { Autocomplete } from './language/extensions/autocomplete';
@@ -23,7 +23,8 @@ import { MilvusHTTP } from './language/milvus.http';
 import { getCMStyle, getStyles } from './style';
 import { CustomEventNameEnum, PlaygroundCustomEventDetail } from './Types';
 import { DocumentEventManager } from './utils/event';
-import { DEFAULT_CODE_VALUE } from '@/pages/play/Constants';
+import { JSONEditor } from './JSONEditor';
+import { customFoldGutter, persistFoldState } from './language/extensions/fold';
 
 const Play: FC = () => {
   // hooks
@@ -76,6 +77,8 @@ const Play: FC = () => {
         isDarkMode: theme.palette.mode === 'dark',
       }),
       Autocomplete({ databases, collections }),
+      customFoldGutter(),
+      persistFoldState(),
     ];
   }, [databases, collections, authReq, theme.palette.mode]);
 
@@ -115,30 +118,6 @@ const Play: FC = () => {
     };
   }, []);
 
-  const renderResponse = () => {
-    const style =
-      theme.palette.mode !== 'dark'
-        ? {
-            hljs: {
-              display: 'block',
-              overflowX: 'auto' as const,
-              padding: '12px 0',
-              color: '#333',
-              margin: 0,
-              background: theme.palette.background.paper,
-            },
-          }
-        : undefined;
-    return (
-      <CodeBlock
-        wrapperClass={classes.response}
-        language="json"
-        code={content || '{}'}
-        style={style}
-      />
-    );
-  };
-
   return (
     <Box className={classes.root}>
       <Paper elevation={0} className={classes.leftPane}>
@@ -150,7 +129,7 @@ const Play: FC = () => {
       </Paper>
 
       <Paper elevation={0} className={classes.rightPane}>
-        {renderResponse()}
+        <JSONEditor value={content || `{}`} editable={false} />
       </Paper>
     </Box>
   );

+ 13 - 12
client/src/pages/play/hooks/use-codemirror.ts

@@ -5,7 +5,7 @@ import {
   StateEffect,
 } from '@codemirror/state';
 import { EditorView } from '@codemirror/view';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import {
   highlightSpecialChars,
   drawSelection,
@@ -43,9 +43,7 @@ import {
   highlightActiveLineGutter,
 } from '../language/extensions/gutter';
 import {
-  customFoldGutter,
   foldByLineRanges,
-  persistFoldState,
   loadFoldState,
   recoveryFoldState,
 } from '../language/extensions/fold';
@@ -77,8 +75,6 @@ const basicSetup = () => [
     ...completionKeymap,
     ...lintKeymap,
   ]),
-  customFoldGutter(),
-  persistFoldState(),
 ];
 
 const External = Annotation.define<boolean>();
@@ -94,10 +90,10 @@ export const useCodeMirror = (props: UseCodeMirrorProps) => {
   const { value, extensions, onChange } = props;
 
   const [container, setContainer] = useState<HTMLDivElement | null>();
-  const [view, setView] = useState<EditorView>();
+  const viewRef = useRef<EditorView>();
 
   const updateListener = EditorView.updateListener.of(update => {
-    if (update.changes) {
+    if (update.docChanged) {
       onChange?.(update.state.doc.toString());
     }
   });
@@ -108,7 +104,7 @@ export const useCodeMirror = (props: UseCodeMirrorProps) => {
 
   // init editor
   useEffect(() => {
-    if (container && !view) {
+    if (container && !viewRef.current) {
       const startState = EditorState.create({
         doc: value,
         extensions: getExtensions(),
@@ -125,10 +121,13 @@ export const useCodeMirror = (props: UseCodeMirrorProps) => {
       } else if (value === DEFAULT_CODE_VALUE) {
         foldByLineRanges(editorView, DEFAULT_FOLD_LINE_RANGES);
       }
-      setView(editorView);
+      viewRef.current = editorView;
     }
 
-    return () => {};
+    return () => {
+      viewRef.current?.destroy();
+      viewRef.current = undefined;
+    };
   }, [container]);
 
   // update value
@@ -137,6 +136,7 @@ export const useCodeMirror = (props: UseCodeMirrorProps) => {
       return;
     }
 
+    const view = viewRef.current;
     const currentValue = view?.state.doc.toString() ?? '';
     if (view && value !== currentValue) {
       view.dispatch({
@@ -152,12 +152,13 @@ export const useCodeMirror = (props: UseCodeMirrorProps) => {
 
   // update extensions
   useEffect(() => {
+    const view = viewRef.current;
     if (view) {
       view.dispatch({
         effects: [StateEffect.reconfigure.of(getExtensions())],
       });
     }
-  }, [extensions, view]);
+  }, [extensions]);
 
   useEffect(() => setContainer(props.container), [props.container]);
 
@@ -188,5 +189,5 @@ export const useCodeMirror = (props: UseCodeMirrorProps) => {
     };
   }, [container]);
 
-  return { view };
+  return { view: viewRef.current };
 };

+ 49 - 0
client/src/pages/play/language/extensions/fold.ts

@@ -105,3 +105,52 @@ export const recoveryFoldState = (view: EditorView) => {
     }
   }
 };
+
+export const jsonFoldGutter = () => {
+  return foldService.of(
+    (state: EditorState, lineStart: number, lineEnd: number) => {
+      try {
+        const text = state.doc.sliceString(lineStart, lineEnd);
+        const lines = state.doc.lines;
+        if (text.endsWith('{') || text.endsWith('[')) {
+          const letter = text.endsWith('{') ? '{' : '[';
+          const line = state.doc.lineAt(lineStart);
+          let to = line.to;
+          const matches = [letter];
+          for (let i = line.number + 1; i <= lines; i++) {
+            const nextLine = state.doc.line(i);
+            const nextText = nextLine.text.trim();
+            for (const char of nextText) {
+              if (char === '{' || char === '[') {
+                matches.push(char);
+              } else if (char === '}') {
+                if (matches[matches.length - 1] === '{') {
+                  matches.pop();
+                }
+              } else if (char === ']') {
+                if (matches[matches.length - 1] === '[') {
+                  matches.pop();
+                }
+              }
+            }
+            if (matches.length === 0) {
+              if (nextText.endsWith('}') || nextText.endsWith(']')) {
+                to = nextLine.to - 1;
+              } else {
+                to = nextLine.to - 2;
+              }
+              break;
+            }
+          }
+          return {
+            from: lineEnd,
+            to: to,
+          };
+        }
+      } catch (error) {
+        return null;
+      }
+      return null;
+    }
+  );
+};

+ 3 - 10
client/src/pages/play/style.tsx

@@ -19,20 +19,10 @@ export const getStyles = makeStyles((theme: Theme) => ({
   },
   rightPane: {
     flex: 1,
-    padding: `0 ${theme.spacing(2)}`,
     marginLeft: theme.spacing(2),
     display: 'flex',
     overflow: 'hidden',
   },
-  response: {
-    width: '100%',
-    overflow: 'auto',
-    lineHeight: '18px',
-
-    '& pre': {
-      borderRadius: '4px',
-    },
-  },
   editor: {
     width: '100%',
     height: '100%',
@@ -218,5 +208,8 @@ export const getCMStyle = (theme: Theme) => {
     '.cm-gutter .cm-codelens-marker': {
       paddingTop: '17px',
     },
+    '.cm-content[data-language="json"]': {
+      paddingLeft: '18px',
+    },
   };
 };

+ 7 - 0
client/yarn.lock

@@ -442,6 +442,13 @@
     "@lezer/lr" "^1.0.0"
     style-mod "^4.0.0"
 
+"@codemirror/legacy-modes@^6.5.0":
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.0.tgz#21c8cf818f9ea4d6eba9f22afdfef010d1d9f754"
+  integrity sha512-dNw5pwTqtR1giYjaJyEajunLqxGavZqV0XRtVZyMJnNOD2HmK9DMUmuCAr6RMFGRJ4l8OeQDjpI/us+R09mQsw==
+  dependencies:
+    "@codemirror/language" "^6.0.0"
+
 "@codemirror/lint@^6.0.0", "@codemirror/lint@^6.8.0":
   version "6.8.1"
   resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.1.tgz#6427848815baaf68c08e98c7673b804d3d8c0e7f"