tansci-boot/antdv-next-admin/src/components/Pro/ProCodeEditor/index.vue

515 lines
12 KiB
Vue

<template>
<div
class="pro-code-editor"
:class="{
'is-disabled': disabled,
'is-readonly': readonly,
'is-dark': computedTheme === 'dark',
}"
>
<div v-if="$slots.toolbar || showDefaultToolbar" class="editor-toolbar">
<slot name="toolbar">
<a-space v-if="showDefaultToolbar" size="small">
<a-select
v-if="showLanguageSelect"
:value="language"
size="small"
style="width: 120px"
:options="languageOptions"
@change="handleLanguageChange"
/>
<a-button
v-if="language === 'json' && !readonly"
size="small"
@click="formatJson"
>
{{ $t("codeEditor.format") }}
</a-button>
<a-button
v-if="language === 'json' && !readonly"
size="small"
@click="minifyJson"
>
{{ $t("codeEditor.minify") }}
</a-button>
<a-button size="small" @click="copyToClipboard">
{{ $t("codeEditor.copy") }}
</a-button>
</a-space>
</slot>
</div>
<div class="editor-container" :style="containerStyle">
<codemirror
:model-value="modelValue"
:style="editorStyle"
:placeholder="placeholder"
:disabled="disabled"
:extensions="extensions"
@update="handleUpdate"
@focus="handleFocus"
@blur="handleBlur"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { Codemirror } from "vue-codemirror";
import type { Extension } from "@codemirror/state";
import {
keymap,
lineNumbers,
highlightActiveLineGutter,
highlightSpecialChars,
drawSelection,
dropCursor,
rectangularSelection,
crosshairCursor,
highlightActiveLine,
} from "@codemirror/view";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import {
indentOnInput,
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
foldGutter,
} from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import { oneDark } from "@codemirror/theme-one-dark";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { dracula } from "@uiw/codemirror-theme-dracula";
import { material, materialDark } from "@uiw/codemirror-theme-material";
import { monokai } from "@uiw/codemirror-theme-monokai";
import { nord } from "@uiw/codemirror-theme-nord";
import { tokyoNight } from "@uiw/codemirror-theme-tokyo-night";
import { solarizedLight, solarizedDark } from "@uiw/codemirror-theme-solarized";
import { json, jsonParseLinter } from "@codemirror/lang-json";
import { javascript } from "@codemirror/lang-javascript";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { markdown } from "@codemirror/lang-markdown";
import { sql } from "@codemirror/lang-sql";
import { yaml } from "@codemirror/lang-yaml";
import { xml } from "@codemirror/lang-xml";
import { python } from "@codemirror/lang-python";
import { java } from "@codemirror/lang-java";
import { php } from "@codemirror/lang-php";
import { rust } from "@codemirror/lang-rust";
import { go } from "@codemirror/lang-go";
import { linter } from "@codemirror/lint";
import { message } from "antdv-next";
import { computed, ref, watch, type CSSProperties, type PropType } from "vue";
import { $t } from "@/locales";
import { useThemeStore } from "@/stores/theme";
defineOptions({
name: "ProCodeEditor",
});
export type SupportedLanguage =
| "json"
| "javascript"
| "typescript"
| "html"
| "css"
| "markdown"
| "sql"
| "yaml"
| "xml"
| "python"
| "java"
| "php"
| "rust"
| "go";
export type EditorTheme =
| "auto"
| "light"
| "dark"
| "github"
| "githubDark"
| "dracula"
| "material"
| "materialDark"
| "monokai"
| "nord"
| "tokyoNight"
| "solarized"
| "solarizedDark";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
language: {
type: String as PropType<SupportedLanguage>,
default: "json",
},
theme: {
type: String as PropType<EditorTheme>,
default: "auto",
},
readonly: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
height: {
type: [Number, String] as PropType<number | "auto">,
default: 300,
},
placeholder: {
type: String,
default: "",
},
lineNumbers: {
type: Boolean,
default: true,
},
foldGutter: {
type: Boolean,
default: true,
},
showToolbar: {
type: Boolean,
default: false,
},
showLanguageSelect: {
type: Boolean,
default: false,
},
formatOnBlur: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
(e: "change", value: string): void;
(e: "focus"): void;
(e: "blur"): void;
(e: "languageChange", language: SupportedLanguage): void;
}>();
const themeStore = useThemeStore();
const emitLanguage = ref<SupportedLanguage>(props.language);
const languageOptions: Array<{ label: string; value: SupportedLanguage }> = [
{ label: "JSON", value: "json" },
{ label: "JavaScript", value: "javascript" },
{ label: "TypeScript", value: "typescript" },
{ label: "HTML", value: "html" },
{ label: "CSS", value: "css" },
{ label: "Markdown", value: "markdown" },
{ label: "SQL", value: "sql" },
{ label: "YAML", value: "yaml" },
{ label: "XML", value: "xml" },
{ label: "Python", value: "python" },
{ label: "Java", value: "java" },
{ label: "PHP", value: "php" },
{ label: "Rust", value: "rust" },
{ label: "Go", value: "go" },
];
const computedTheme = computed<"light" | "dark">(() => {
if (props.theme === "auto") {
return themeStore.isDark ? "dark" : "light";
}
const lightThemes = ["light", "github", "material", "solarized"];
return lightThemes.includes(props.theme) ? "light" : "dark";
});
const showDefaultToolbar = computed(() => {
return (
props.showToolbar || props.showLanguageSelect || props.language === "json"
);
});
const containerStyle = computed<CSSProperties>(() => {
if (props.height === "auto") {
return {
height: "auto",
minHeight: "100px",
};
}
return {
height:
typeof props.height === "number" ? `${props.height}px` : props.height,
};
});
const editorStyle = computed<CSSProperties>(() => ({
height: "100%",
fontSize: "13px",
}));
function getLanguageExtension(lang: SupportedLanguage) {
switch (lang) {
case "json":
return json();
case "javascript":
return javascript();
case "typescript":
return javascript({ typescript: true });
case "html":
return html();
case "css":
return css();
case "markdown":
return markdown();
case "sql":
return sql();
case "yaml":
return yaml();
case "xml":
return xml();
case "python":
return python();
case "java":
return java();
case "php":
return php();
case "rust":
return rust();
case "go":
return go();
default:
return json();
}
}
function getThemeExtension(theme: EditorTheme): Extension[] {
const themeMap: Record<string, Extension | undefined> = {
github: githubLight,
githubDark,
dracula,
material,
materialDark,
monokai,
nord,
tokyoNight,
solarized: solarizedLight,
solarizedDark,
};
if (theme === "light") return [];
if (theme === "dark") return [oneDark];
const ext = themeMap[theme];
return ext ? [ext] : [];
}
const baseExtensions = computed<Extension[]>(() => {
const ext: Extension[] = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
];
if (props.lineNumbers) {
ext.push(lineNumbers());
ext.push(highlightActiveLineGutter());
}
if (props.foldGutter) {
ext.push(foldGutter());
}
if (props.theme === "auto") {
if (themeStore.isDark) ext.push(oneDark);
} else if (props.theme === "dark") {
ext.push(oneDark);
} else {
ext.push(...getThemeExtension(props.theme));
}
ext.push(getLanguageExtension(emitLanguage.value));
if (emitLanguage.value === "json") {
ext.push(linter(jsonParseLinter()));
}
return ext;
});
const extensions = computed(() => baseExtensions.value);
function handleUpdate(viewUpdate: {
state: { doc: { toString: () => string } };
}) {
if (props.readonly || props.disabled) return;
const value = viewUpdate.state.doc.toString();
emit("update:modelValue", value);
emit("change", value);
}
function handleFocus() {
emit("focus");
}
function handleBlur() {
if (props.formatOnBlur && emitLanguage.value === "json" && props.modelValue) {
try {
const parsed = JSON.parse(props.modelValue);
const formatted = JSON.stringify(parsed, null, 2);
emit("update:modelValue", formatted);
emit("change", formatted);
} catch (error) {
console.warn("JSON format on blur failed:", error);
}
}
emit("blur");
}
function handleLanguageChange(lang: SupportedLanguage) {
emitLanguage.value = lang;
emit("languageChange", lang);
}
function formatJson() {
if (emitLanguage.value !== "json" || !props.modelValue) return;
try {
const parsed = JSON.parse(props.modelValue);
const formatted = JSON.stringify(parsed, null, 2);
emit("update:modelValue", formatted);
emit("change", formatted);
message.success($t("codeEditor.formatSuccess"));
} catch (error) {
console.warn("JSON format failed:", error);
message.error($t("codeEditor.jsonError"));
}
}
function minifyJson() {
if (emitLanguage.value !== "json" || !props.modelValue) return;
try {
const parsed = JSON.parse(props.modelValue);
const minified = JSON.stringify(parsed);
emit("update:modelValue", minified);
emit("change", minified);
message.success($t("codeEditor.minifySuccess"));
} catch (error) {
console.warn("JSON minify failed:", error);
message.error($t("codeEditor.jsonError"));
}
}
async function copyToClipboard() {
if (!props.modelValue) return;
try {
await navigator.clipboard.writeText(props.modelValue);
message.success($t("codeEditor.copySuccess"));
} catch (error) {
console.warn("Copy to clipboard failed:", error);
message.error($t("codeEditor.copyFailed"));
}
}
watch(
() => props.language,
(newLang) => {
emitLanguage.value = newLang;
},
);
</script>
<style scoped lang="scss">
.pro-code-editor {
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
background: var(--color-bg-container);
&.is-dark {
background: #1e1e1e;
border-color: #3c3c3c;
}
&.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-secondary);
background: var(--color-bg-layout);
.is-dark & {
background: #252526;
border-bottom-color: #3c3c3c;
}
}
.editor-container {
overflow: auto;
:deep(.cm-editor) {
height: 100%;
outline: none;
}
:deep(.cm-scroller) {
font-family: "Monaco", "Menlo", "Consolas", monospace;
}
:deep(.cm-content) {
padding: 8px 0;
}
:deep(.cm-line) {
padding: 0 8px;
}
:deep(.cm-placeholder) {
color: var(--color-text-quaternary);
font-style: italic;
}
}
.is-readonly {
.editor-container {
:deep(.cm-content) {
cursor: default;
}
:deep(.cm-cursor) {
display: none;
}
}
}
</style>