Commit 868627e6 by pangchong

feat(editor): 集成 CodeMirror 6 实现高级 XML 编辑器组件

- 新增 CommonXmlEditor 组件,基于 CodeMirror 6 构建
- 支持自定义行号与差异行高亮功能
- 集成 XML 语法高亮、代码折叠、括号匹配、历史记录和搜索
- 实现基于 DTD 的自动补全,支持标签名、属性和枚举值提示
- 自适应亮暗主题,样式完全基于 CSS 变量
- 支持格式化粘贴的 XML 内容,提升编辑体验
- 拦截组件内 Ctrl+F 快捷键优先显示查找面板,避免冲突
- 添加依赖 codemirror 相关库,更新 package.json 和 package-lock.json
parent 05264f0b
......@@ -17,9 +17,15 @@
},
"dependencies": {
"@alova/scene-vue": "^1.6.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@lezer/highlight": "^1.2.3",
"@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "^14.3.0",
"alova": "^3.5.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.19",
"diff": "^7.0.0",
"less": "^4.5.1",
......
......@@ -27,8 +27,10 @@ export function useKeyboardShortcuts(options: ShortcutOptions = {}, enableGlobal
}
const target = e.target as HTMLElement | null
const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
if (isInput) return
const isInputOrEditor =
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || target.closest('.cm-editor'))
if (isInputOrEditor) return
if (ctrl) {
const key = e.key.toLowerCase()
......@@ -65,6 +67,8 @@ export function useKeyboardShortcuts(options: ShortcutOptions = {}, enableGlobal
if (options.onFind) {
onKeyStroke(['f', 'F'], (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
// 若页面上存在已挂载的 CommonXmlEditor,则让它自己处理 Ctrl+F,跳过主编辑器的查找
if (document.querySelector('.xml-editor-container')) return
e.preventDefault()
options.onFind!()
}
......
......@@ -156,12 +156,53 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
*/
export function formatXmlText(xmlStr: string): string {
if (!xmlStr) return ''
try {
const tree = parseXmlToTree(xmlStr)
return serializeTreeToXml(tree, 0, false)
} catch (e) {
return xmlStr
// 规范化空白字符,先去除标签之间的所有换行和缩进,以重新对其进行对齐排版
const cleanXml = xmlStr.trim().replace(/>\s+</g, '><')
// 正则分割标签与文本内容
const tokens = cleanXml.split(/(<[^>]+>)/g).filter(t => t.trim() !== '')
let indentLevel = 0
const lines: string[] = []
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i].trim()
if (!token) continue
if (token.startsWith('</')) {
// 闭合标签,减少缩进
indentLevel = Math.max(0, indentLevel - 1)
lines.push(' '.repeat(indentLevel) + token)
} else if (token.startsWith('<') && token.endsWith('/>')) {
// 自闭合标签
lines.push(' '.repeat(indentLevel) + token)
} else if (token.startsWith('<?') || token.startsWith('<!')) {
// XML 声明、注释、或者 DTD 声明
lines.push(' '.repeat(indentLevel) + token)
} else if (token.startsWith('<')) {
// 开始标签
const matchTag = token.match(/^<([a-zA-Z0-9_:-]+)/)
const tagName = matchTag ? matchTag[1] : null
const nextToken = tokens[i + 1]?.trim()
const afterNextToken = tokens[i + 2]?.trim()
// 检查这是否是一个单行标签(即:开始标签 + 纯文本内容 + 结束标签)
if (tagName && nextToken && !nextToken.startsWith('<') && afterNextToken === `</${tagName}>`) {
lines.push(' '.repeat(indentLevel) + token + nextToken + afterNextToken)
i += 2 // 跳过这两个 token
} else {
lines.push(' '.repeat(indentLevel) + token)
indentLevel++
}
} else {
// 纯文本内容
lines.push(' '.repeat(indentLevel) + token)
}
}
return lines.join('\n')
}
/**
......
......@@ -6,6 +6,7 @@ export function useInsertFragmentModal() {
const visible = ref(false)
const xmlContent = ref('')
const isSaving = ref(false)
const hasClipboardContent = ref(false)
const insertModeSetting = ref<'above' | 'below' | 'inside'>('below')
const targetNodeIdSetting = ref<string | undefined>(undefined)
......@@ -17,11 +18,15 @@ export function useInsertFragmentModal() {
}
targetNodeIdSetting.value = targetId
xmlContent.value = ''
hasClipboardContent.value = false
try {
if (navigator.clipboard && navigator.clipboard.readText) {
const text = await navigator.clipboard.readText()
if (text && text.trim()) {
xmlContent.value = text.trim()
// 如果剪贴板内容含有 XML 标签,自动格式化后再填入
const raw = text.trim()
xmlContent.value = (raw.includes('<') || raw.includes('>')) ? formatXmlText(raw) : raw
hasClipboardContent.value = true
}
}
} catch (err) {
......@@ -53,6 +58,7 @@ export function useInsertFragmentModal() {
xmlContent,
isSaving,
open,
handleConfirm
handleConfirm,
hasClipboardContent
}
}
<template>
<CommonModal v-model="visible" title="插入 XML 片段" :width="600" :loading="isSaving" confirm-text="插入" @confirm="handleConfirm">
<CommonModal
:scrollable="false"
v-model="visible"
title="插入 XML 片段"
:width="900"
:loading="isSaving"
confirm-text="插入"
@confirm="handleConfirm"
>
<div class="flex flex-col space-y-3 p-1">
<!-- 头部说明性描述 -->
<div class="text-xs text-color3">
请输入从其他地方复制的 XML 节点片段,系统将自动解析节点结构,并根据当前选中节点及 DTD 架构规则进行兼容性校验:
</div>
<n-input v-model:value="xmlContent" type="textarea" rows="10" placeholder="例如:<PARAC>测试记录行</PARAC>" />
<!-- 编辑器组件 -->
<CommonXmlEditor ref="xmlEditorRef" v-model="xmlContent" :max-height="hasClipboardContent ? '70vh' : undefined" />
<!-- 底部快捷键或帮助提示 -->
<div class="text-[10px] text-color3 flex items-center justify-between">
<span>提示: 输入 &lt; 可唤起 XML 节点建议;在标签内输入空格可唤起属性建议。</span>
<span>支持高亮与折叠</span>
</div>
</div>
<template #footer-extra>
<CommonButton secondary @click="handleFormat" :disabled="!xmlContent.trim()">格式化</CommonButton>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { useInsertFragmentModal } from './functionals'
const { visible, xmlContent, isSaving, open, handleConfirm } = useInsertFragmentModal()
const { visible, xmlContent, isSaving, open, handleConfirm, hasClipboardContent } = useInsertFragmentModal()
const xmlEditorRef = ref<any>(null)
const handleFormat = () => {
xmlEditorRef.value?.handleFormat()
}
defineExpose({
open,
......
import { useStashStore, type StashItem } from '@/store/stash'
import { useEditorStore } from '@/store/editor'
import { serializeTreeToXml, parseXmlToTreeAsync } from '@/utils/xmlParser'
import { openDownloadModal } from '@/utils/render'
export function useStashModal() {
const visible = ref(false)
......
......@@ -137,17 +137,19 @@
</div>
<!-- 代码预览区 -->
<div
class="flex-1 overflow-auto border border-divider rounded-lg bg-card p-3 relative font-mono text-[11px] leading-5 select-text custom-scrollbar"
>
<div v-if="isXmlTextLoading" class="flex flex-col gap-2 animate-pulse py-1">
<div v-if="isXmlTextLoading" class="flex-grow flex flex-col gap-2 animate-pulse p-4 border border-divider rounded-lg bg-card">
<div class="h-3 bg-fill-3 rounded w-4/5"></div>
<div class="h-3 bg-fill-3 rounded w-full"></div>
<div class="h-3 bg-fill-3 rounded w-3/4"></div>
<div class="h-3 bg-fill-3 rounded w-5/6"></div>
<div class="h-3 bg-fill-3 rounded w-2/3"></div>
</div>
<pre v-else class="m-0 text-color2 whitespace-pre-wrap break-all">{{ activeItemXmlText }}</pre>
<div v-else class="flex-grow min-h-0 flex flex-col">
<CommonXmlEditor
v-model="activeItemXmlText"
disabled
height="100%"
/>
</div>
<!-- 操作面板 -->
......
<template>
<CommonModal v-model="viewXmlVisible" :title="viewXmlTitle" :width="900" :show-confirm="false" cancel-text="关闭">
<CommonModal :scrollable="false" v-model="viewXmlVisible" :title="viewXmlTitle" :width="900" :show-confirm="false" cancel-text="关闭">
<div class="flex flex-col space-y-4">
<!-- XML 渲染面板,带优雅网格背景与代码字体 -->
<div
class="rounded-xl overflow-hidden border border-divider shadow-lg relative bg-fill-4"
style="background-image: radial-gradient(circle, rgba(0, 0, 0, 0.02) 1px, transparent 1px); background-size: 16px 16px"
>
<div class="p-6 font-mono text-sm leading-relaxed overflow-x-auto max-h-[500px] scrollbar-thin select-all">
<pre class="text-color1" @copy.prevent="handleCopyXml"><code class="xml-content-pre">{{ formattedXmlContent }}</code></pre>
</div>
</div>
<!-- 使用全局 CommonXmlEditor 组件展示只读的 XML 内容 -->
<CommonXmlEditor :model-value="formattedXmlContent" disabled max-height="70vh" />
</div>
<template #footer-extra>
......@@ -32,10 +25,4 @@ const { viewXmlVisible, viewXmlTitle, formattedXmlContent, handleCopyXml, open }
defineExpose({ open })
</script>
<style scoped>
.xml-content-pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
white-space: pre-wrap;
word-break: break-all;
}
</style>
<style scoped></style>
......@@ -68,7 +68,7 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: false,
headers: {
Cookie: '_udid=264b2935-6cad-4d00-8a65-7b54d1ecf2db; JSESSIONID=DE507139FF27D7D5BA18A673DCD3EC71; _amro_sk=d2bf7778-f4b7-497a-a8c5-b6fa9cfe3369'
Cookie: '_udid=5bf34532-a66b-477d-81dc-8c0f4079f6bc; JSESSIONID=48C6C0153A0F552336259B30A2D4D8DC; _amro_sk=b51dbda2-701b-4a17-983d-3770fad9966f'
}
},
'/mnt': {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment