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:
Generated
+503
-50
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"axios": "^1.7.9",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"otpauth": "^9.5.1",
|
||||
"qs": "^6.13.1",
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user