Files
oci-portal-dash/src/components/objectstorage/viewer/TextEditPane.vue
T

180 lines
6.9 KiB
Vue

<script setup lang="ts">
import { NSpin } from 'naive-ui'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { extOf } from '@/components/objectstorage/codeHighlight'
/** 文本编辑器:CodeMirror 6,行号 / 语法高亮 / 搜索;
* 核心与语言包全部动态 import 独立 chunk,不进编辑不下载。
* 须放在定高容器内(内部自滚动)。 */
const props = defineProps<{
modelValue: string
objectName: string
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [string] }>()
type CmExtension = import('@codemirror/state').Extension
type CmEditorView = typeof import('@codemirror/view').EditorView
const host = ref<HTMLElement | null>(null)
const booting = ref(true)
const bootError = ref('')
let view: import('@codemirror/view').EditorView | null = null
let EV: CmEditorView | null = null
let editableComp: import('@codemirror/state').Compartment | null = null
onMounted(() => void boot())
onBeforeUnmount(() => view?.destroy())
async function boot() {
try {
const [{ basicSetup }, { EditorView }, { Compartment }, lang, theme] = await Promise.all([
import('codemirror'),
import('@codemirror/view'),
import('@codemirror/state'),
loadLanguage(extOf(props.objectName)),
themeExtensions(),
])
EV = EditorView
editableComp = new Compartment()
view = new EditorView({
parent: host.value!,
doc: props.modelValue,
extensions: [
basicSetup,
EditorView.lineWrapping,
...(lang ? [lang] : []),
...theme,
editableComp.of(EditorView.editable.of(!props.disabled)),
EditorView.updateListener.of((u) => {
if (u.docChanged) emit('update:modelValue', u.state.doc.toString())
}),
],
})
} catch (e) {
bootError.value = e instanceof Error ? e.message : '编辑器加载失败'
} finally {
booting.value = false
}
}
/** 主题与语法配色引用设计 token(明暗自适应),语义与全局 .code-hl 一致 */
async function themeExtensions(): Promise<CmExtension[]> {
const [{ HighlightStyle, syntaxHighlighting }, { tags: t }, { EditorView }] = await Promise.all([
import('@codemirror/language'),
import('@lezer/highlight'),
import('@codemirror/view'),
])
const hl = HighlightStyle.define([
{ tag: [t.keyword, t.operatorKeyword, t.modifier], color: 'var(--color-warn)' },
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--color-ok)' },
{ tag: [t.number, t.bool, t.null, t.atom], color: 'var(--color-accent)' },
{ tag: [t.comment, t.meta], color: 'var(--color-ink-3)', fontStyle: 'italic' },
{ tag: [t.propertyName, t.attributeName], color: 'var(--color-info)' },
{ tag: [t.typeName, t.className, t.tagName], color: 'var(--color-accent)' },
{ tag: t.heading, color: 'var(--color-ink)', fontWeight: '600' },
{ tag: t.link, color: 'var(--color-accent)' },
])
const theme = EditorView.theme({
'&': { height: '100%', fontSize: '12px', backgroundColor: 'transparent' },
'.cm-scroller': {
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
lineHeight: '1.65',
overflow: 'auto',
},
'.cm-gutters': {
backgroundColor: 'var(--color-wash)',
color: 'var(--color-ink-3)',
border: 'none',
},
'.cm-activeLineGutter': { backgroundColor: 'var(--color-wash)' },
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--color-wash) 55%, transparent)' },
'&.cm-focused': { outline: 'none' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 18%, transparent)',
},
})
// 不带 fallback:作为正式高亮器压过 basicSetup 内置的 defaultHighlightStyle(其为 fallback)
return [theme, syntaxHighlighting(hl)]
}
/** 扩展名 → CodeMirror 语言扩展;不在表内按纯文本编辑 */
const CM_LANG: Record<string, () => Promise<CmExtension>> = {
json: () => import('@codemirror/lang-json').then((m) => m.json()),
yaml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
yml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
py: () => import('@codemirror/lang-python').then((m) => m.python()),
js: () => import('@codemirror/lang-javascript').then((m) => m.javascript()),
ts: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ typescript: true })),
xml: () => import('@codemirror/lang-xml').then((m) => m.xml()),
svg: () => import('@codemirror/lang-xml').then((m) => m.xml()),
html: () => import('@codemirror/lang-html').then((m) => m.html()),
htm: () => import('@codemirror/lang-html').then((m) => m.html()),
css: () => import('@codemirror/lang-css').then((m) => m.css()),
md: () => import('@codemirror/lang-markdown').then((m) => m.markdown()),
sql: () => import('@codemirror/lang-sql').then((m) => m.sql()),
go: () => import('@codemirror/lang-go').then((m) => m.go()),
sh: () => import('@codemirror/legacy-modes/mode/shell').then((m) => streamOf(m.shell)),
ini: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
conf: () =>
import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
cfg: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
env: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
properties: () =>
import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
toml: () => import('@codemirror/legacy-modes/mode/toml').then((m) => streamOf(m.toml)),
}
async function streamOf<T>(def: import('@codemirror/language').StreamParser<T>) {
const { StreamLanguage } = await import('@codemirror/language')
return StreamLanguage.define(def)
}
async function loadLanguage(ext: string): Promise<CmExtension | null> {
const load = CM_LANG[ext]
if (!load) return null
try {
return await load()
} catch {
return null
}
}
watch(
() => props.modelValue,
(v) => {
// 外部改写(如美化格式)同步进编辑器;自身输入产生的 emit 值相等,跳过
if (view && v !== view.state.doc.toString())
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: v } })
},
)
watch(
() => props.disabled,
(d) => {
if (view && editableComp && EV)
view.dispatch({ effects: editableComp.reconfigure(EV.editable.of(!d)) })
},
)
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="booting" class="flex flex-1 items-center justify-center">
<NSpin size="small" />
</div>
<div v-else-if="bootError" class="flex flex-1 items-center justify-center text-xs text-err">
{{ bootError }}
</div>
<div ref="host" class="cm-host min-h-0 flex-1" :class="booting || bootError ? 'hidden' : ''"></div>
</div>
</template>
<style scoped>
.cm-host :deep(.cm-editor) {
height: 100%;
}
</style>