Browse Source

feat(json): swap raw textareas for a CodeMirror 6 JsonEditor

A new JsonEditor.vue component wraps CodeMirror 6 + lang-json with
line numbers, JSON syntax highlighting, bracket matching, code
folding, search (Ctrl+F), undo/redo, lint (red squiggle and gutter
icon on invalid JSON), tab indent, and line wrapping. It is wired
into the four raw-JSON spots that previously used <a-textarea
class="json-editor">: the Xray Advanced Template tab, the Outbound
JSON tab, the Balancer Observatory pane, and the Inbound Advanced
tab (settings / streamSettings / sniffing).

Chrome colors are driven by EditorView.theme so they win the
specificity fight cleanly against CodeMirror's own injected styles.
A single buildDarkTheme() factory yields a Dark+ palette (#1e1e1e
background, #252526 active line, #2d2d30 panels) for the regular
dark mode and a near-black variant (#0a0a0a / #141414 / #1f1f1f
border) for ultra-dark — both pair with oneDarkHighlightStyle for
the syntax colors. Light mode stays on basicSetup's default.

CodeMirror lazy-loads as a ~17 kB gzipped chunk that only appears
on the Xray/Inbounds bundles.
MHSanaei 1 day ago
parent
commit
ce4c42e09c

File diff suppressed because it is too large
+ 321 - 28
frontend/package-lock.json


+ 3 - 0
frontend/package.json

@@ -16,8 +16,11 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@ant-design/icons-vue": "^7.0.1",
     "@ant-design/icons-vue": "^7.0.1",
+    "@codemirror/lang-json": "^6.0.2",
+    "@codemirror/theme-one-dark": "^6.1.3",
     "ant-design-vue": "^4.2.6",
     "ant-design-vue": "^4.2.6",
     "axios": "^1.7.9",
     "axios": "^1.7.9",
+    "codemirror": "^6.0.2",
     "dayjs": "^1.11.20",
     "dayjs": "^1.11.20",
     "otpauth": "^9.5.1",
     "otpauth": "^9.5.1",
     "qs": "^6.13.1",
     "qs": "^6.13.1",

+ 185 - 0
frontend/src/components/JsonEditor.vue

@@ -0,0 +1,185 @@
+<script setup>
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { EditorView, basicSetup } from 'codemirror';
+import { EditorState, Compartment } from '@codemirror/state';
+import { json, jsonParseLinter } from '@codemirror/lang-json';
+import { lintGutter, linter } from '@codemirror/lint';
+import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
+import { syntaxHighlighting } from '@codemirror/language';
+import { keymap } from '@codemirror/view';
+import { indentWithTab } from '@codemirror/commands';
+
+import { theme as themeState } from '@/composables/useTheme.js';
+
+const props = defineProps({
+  value: { type: String, default: '' },
+  minHeight: { type: String, default: '320px' },
+  maxHeight: { type: String, default: '600px' },
+  readonly: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:value', 'change']);
+
+const host = ref(null);
+let view = null;
+const themeCompartment = new Compartment();
+const readonlyCompartment = new Compartment();
+
+function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
+  return EditorView.theme(
+    {
+      '&': { color: '#dcdcdc', backgroundColor: bg },
+      '.cm-content': { caretColor: '#dcdcdc' },
+      '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
+      '.cm-gutters': {
+        backgroundColor: bg,
+        borderRight: `1px solid ${border}`,
+        color: '#6a6a6a',
+      },
+      '.cm-activeLine': { backgroundColor: activeBg },
+      '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
+      '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
+        { backgroundColor: selection },
+      '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
+      '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
+      '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
+      '.cm-tooltip': {
+        backgroundColor: panelBg,
+        border: `1px solid ${border}`,
+        color: '#dcdcdc',
+      },
+    },
+    { dark: true },
+  );
+}
+
+const darkTheme = buildDarkTheme({
+  bg: '#1e1e1e',
+  panelBg: '#2d2d30',
+  activeBg: '#252526',
+  border: '#3a3a3c',
+  selection: '#3a3a3c',
+});
+
+const ultraDarkTheme = buildDarkTheme({
+  bg: '#0a0a0a',
+  panelBg: '#141414',
+  activeBg: '#141414',
+  border: '#1f1f1f',
+  selection: '#2a2a2a',
+});
+
+function themeExtension() {
+  if (!themeState.isDark) return [];
+  const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
+  return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
+}
+
+function readonlyExtension() {
+  return EditorState.readOnly.of(props.readonly);
+}
+
+onMounted(() => {
+  const updateListener = EditorView.updateListener.of((u) => {
+    if (!u.docChanged) return;
+    const next = u.state.doc.toString();
+    if (next === props.value) return;
+    emit('update:value', next);
+    emit('change', next);
+  });
+
+  view = new EditorView({
+    parent: host.value,
+    state: EditorState.create({
+      doc: props.value || '',
+      extensions: [
+        basicSetup,
+        keymap.of([indentWithTab]),
+        json(),
+        linter(jsonParseLinter()),
+        lintGutter(),
+        EditorView.lineWrapping,
+        updateListener,
+        themeCompartment.of(themeExtension()),
+        readonlyCompartment.of(readonlyExtension()),
+        EditorView.theme({
+          '&': { height: '100%' },
+          '.cm-scroller': {
+            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+            fontSize: '12px',
+            minHeight: props.minHeight,
+            maxHeight: props.maxHeight,
+          },
+        }),
+      ],
+    }),
+  });
+});
+
+watch(() => props.value, (next) => {
+  if (!view) return;
+  const current = view.state.doc.toString();
+  if (next === current) return;
+  view.dispatch({
+    changes: { from: 0, to: current.length, insert: next || '' },
+  });
+});
+
+watch(
+  [() => themeState.isDark, () => themeState.isUltra],
+  () => {
+    if (!view) return;
+    view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
+  },
+);
+
+watch(
+  () => props.readonly,
+  () => {
+    if (!view) return;
+    view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
+  },
+);
+
+onBeforeUnmount(() => {
+  view?.destroy();
+  view = null;
+});
+
+defineExpose({
+  focus: () => view?.focus(),
+});
+</script>
+
+<template>
+  <div ref="host" class="json-editor-host" />
+</template>
+
+<style scoped>
+.json-editor-host {
+  border: 1px solid var(--ant-color-border, #d9d9d9);
+  border-radius: 6px;
+  overflow: hidden;
+  background: var(--ant-color-bg-container, #fff);
+}
+
+.json-editor-host :deep(.cm-editor),
+.json-editor-host :deep(.cm-editor.cm-focused) {
+  outline: none;
+}
+
+.json-editor-host:focus-within {
+  border-color: var(--ant-color-primary, #1677ff);
+  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
+}
+
+:global(body.dark) .json-editor-host {
+  border-color: #3a3a3c;
+  background: #1e1e1e;
+}
+
+:global(html[data-theme="ultra-dark"]) .json-editor-host {
+  border-color: #1f1f1f;
+  background: #0a0a0a;
+}
+</style>

+ 4 - 11
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -32,6 +32,7 @@ import {
 import { DBInbound } from '@/models/dbinbound.js';
 import { DBInbound } from '@/models/dbinbound.js';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
 import DateTimePicker from '@/components/DateTimePicker.vue';
 import DateTimePicker from '@/components/DateTimePicker.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 import { useNodeList } from '@/composables/useNodeList.js';
 import { useNodeList } from '@/composables/useNodeList.js';
 
 
 const { t } = useI18n();
 const { t } = useI18n();
@@ -1956,16 +1957,13 @@ watch(
           class="mb-12" />
           class="mb-12" />
         <a-form layout="vertical">
         <a-form layout="vertical">
           <a-form-item label="settings (clients, encryption, fallbacks, …)">
           <a-form-item label="settings (clients, encryption, fallbacks, …)">
-            <a-textarea v-model:value="advancedJson.settings" :auto-size="{ minRows: 10, maxRows: 24 }"
-              spellcheck="false" class="json-editor" />
+            <JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
           </a-form-item>
           </a-form-item>
           <a-form-item label="streamSettings">
           <a-form-item label="streamSettings">
-            <a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
-              class="json-editor" />
+            <JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
           </a-form-item>
           </a-form-item>
           <a-form-item label="sniffing (overrides the Sniffing tab when set)">
           <a-form-item label="sniffing (overrides the Sniffing tab when set)">
-            <a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
-              spellcheck="false" class="json-editor" />
+            <JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
           </a-form-item>
           </a-form-item>
         </a-form>
         </a-form>
       </a-tab-pane>
       </a-tab-pane>
@@ -2015,11 +2013,6 @@ watch(
   margin-top: 6px;
   margin-top: 6px;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-}
-
 .client-summary {
 .client-summary {
   width: 100%;
   width: 100%;
   border-collapse: collapse;
   border-collapse: collapse;

+ 2 - 7
frontend/src/pages/xray/BalancersTab.vue

@@ -10,6 +10,7 @@ import {
 import { Modal } from 'ant-design-vue';
 import { Modal } from 'ant-design-vue';
 
 
 import BalancerFormModal from './BalancerFormModal.vue';
 import BalancerFormModal from './BalancerFormModal.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 
 
 const { t } = useI18n();
 const { t } = useI18n();
 
 
@@ -305,8 +306,7 @@ const obsText = computed({
           <a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
           <a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
           <a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
           <a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
         </a-radio-group>
         </a-radio-group>
-        <a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
-          class="json-editor" />
+        <JsonEditor v-model:value="obsText" min-height="220px" max-height="480px" />
       </template>
       </template>
     </template>
     </template>
 
 
@@ -330,9 +330,4 @@ const obsText = computed({
   color: #ff4d4f;
   color: #ff4d4f;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-  margin-top: 8px;
-}
 </style>
 </style>

+ 2 - 7
frontend/src/pages/xray/OutboundFormModal.vue

@@ -21,6 +21,7 @@ import {
   DNSRuleActions,
   DNSRuleActions,
 } from '@/models/outbound.js';
 } from '@/models/outbound.js';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 
 
 const { t } = useI18n();
 const { t } = useI18n();
 
 
@@ -988,8 +989,7 @@ function regenerateWgKeys() {
               <a-button>Convert</a-button>
               <a-button>Convert</a-button>
             </template>
             </template>
           </a-input-search>
           </a-input-search>
-          <a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
-            class="json-editor" />
+          <JsonEditor v-model:value="advancedJson" min-height="360px" max-height="600px" />
         </a-space>
         </a-space>
       </a-tab-pane>
       </a-tab-pane>
     </a-tabs>
     </a-tabs>
@@ -1032,11 +1032,6 @@ function regenerateWgKeys() {
   opacity: 0.85;
   opacity: 0.85;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-}
-
 /* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
 /* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
  * inline-block, but inside a narrow form wrapper they can wrap
  * inline-block, but inside a narrow form wrapper they can wrap
  * inconsistently. Force a clean horizontal row with even gaps. */
  * inconsistently. Force a clean horizontal row with even gaps. */

+ 2 - 7
frontend/src/pages/xray/XrayPage.vue

@@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue';
 import DnsTab from './DnsTab.vue';
 import DnsTab from './DnsTab.vue';
 import WarpModal from './WarpModal.vue';
 import WarpModal from './WarpModal.vue';
 import NordModal from './NordModal.vue';
 import NordModal from './NordModal.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 import { useXraySetting } from './useXraySetting.js';
 import { useXraySetting } from './useXraySetting.js';
 import { useWebSocket } from '@/composables/useWebSocket.js';
 import { useWebSocket } from '@/composables/useWebSocket.js';
 
 
@@ -376,8 +377,7 @@ onBeforeUnmount(() => {
                         <a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
                         <a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
                         <a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
                         <a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
                       </a-radio-group>
                       </a-radio-group>
-                      <a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
-                        spellcheck="false" class="json-editor" />
+                      <JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
                     </a-tab-pane>
                     </a-tab-pane>
                   </a-tabs>
                   </a-tabs>
                 </a-col>
                 </a-col>
@@ -464,11 +464,6 @@ onBeforeUnmount(() => {
   margin: 0;
   margin: 0;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-}
-
 .icons-only :deep(.ant-tabs-nav) {
 .icons-only :deep(.ant-tabs-nav) {
   margin-bottom: 8px;
   margin-bottom: 8px;
 }
 }

Some files were not shown because too many files changed in this diff