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.
This commit is contained in:
MHSanaei
2026-05-14 00:02:59 +02:00
parent 18614bd6ea
commit ce4c42e09c
7 changed files with 701 additions and 82 deletions

View File

@@ -32,6 +32,7 @@ import {
import { DBInbound } from '@/models/dbinbound.js';
import FinalMaskForm from '@/components/FinalMaskForm.vue';
import DateTimePicker from '@/components/DateTimePicker.vue';
import JsonEditor from '@/components/JsonEditor.vue';
import { useNodeList } from '@/composables/useNodeList.js';
const { t } = useI18n();
@@ -1956,16 +1957,13 @@ watch(
class="mb-12" />
<a-form layout="vertical">
<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 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 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>
</a-tab-pane>
@@ -2015,11 +2013,6 @@ watch(
margin-top: 6px;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.client-summary {
width: 100%;
border-collapse: collapse;

View File

@@ -10,6 +10,7 @@ import {
import { Modal } from 'ant-design-vue';
import BalancerFormModal from './BalancerFormModal.vue';
import JsonEditor from '@/components/JsonEditor.vue';
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="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
</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>
@@ -330,9 +330,4 @@ const obsText = computed({
color: #ff4d4f;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
margin-top: 8px;
}
</style>

View File

@@ -21,6 +21,7 @@ import {
DNSRuleActions,
} from '@/models/outbound.js';
import FinalMaskForm from '@/components/FinalMaskForm.vue';
import JsonEditor from '@/components/JsonEditor.vue';
const { t } = useI18n();
@@ -988,8 +989,7 @@ function regenerateWgKeys() {
<a-button>Convert</a-button>
</template>
</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-tab-pane>
</a-tabs>
@@ -1032,11 +1032,6 @@ function regenerateWgKeys() {
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
* inline-block, but inside a narrow form wrapper they can wrap
* inconsistently. Force a clean horizontal row with even gaps. */

View File

@@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue';
import DnsTab from './DnsTab.vue';
import WarpModal from './WarpModal.vue';
import NordModal from './NordModal.vue';
import JsonEditor from '@/components/JsonEditor.vue';
import { useXraySetting } from './useXraySetting.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="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
</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-tabs>
</a-col>
@@ -464,11 +464,6 @@ onBeforeUnmount(() => {
margin: 0;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.icons-only :deep(.ant-tabs-nav) {
margin-bottom: 8px;
}