Commit 3c28c342 by pangchong

feat(compare): 新增工卡版本对比功能模块

- 添加CompareModal组件,实现工卡XML与排版渲染结果双栏对比
- 集成了自定义滚动条及差异高亮显示,支持增删改标记
- 支持XML文本区与渲染区的展开收起与同步滚动联动
- CommonXmlEditor新增行号选中事件,并支持光标智能补全
- CommonModal新增showHeader属性控制标题栏显示隐藏
- xmlParser新增XML序列化映射,用于节点行号关联
- 优化XML文本内容解析,统一规范化空白字符处理
- 依赖新增overlayscrollbars及vue绑定样式库,用于滚动条美化
- View层新增比对图例与模式切换控件提升用户体验
- 增加排版渲染对比节点高亮及隐藏空白占位标记样式
- 细化滚动条交互状态及最小滑块尺寸常量定义
parent f4b7cfd2
...@@ -25,6 +25,8 @@ ...@@ -25,6 +25,8 @@
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"naive-ui": "^2.43.2", "naive-ui": "^2.43.2",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-vue": "^0.5.10",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"vfonts": "^0.0.3", "vfonts": "^0.0.3",
...@@ -3792,6 +3794,22 @@ ...@@ -3792,6 +3794,22 @@
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/overlayscrollbars": {
"version": "2.16.0",
"resolved": "https://registry.npmmirror.com/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz",
"integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==",
"license": "MIT"
},
"node_modules/overlayscrollbars-vue": {
"version": "0.5.10",
"resolved": "https://registry.npmmirror.com/overlayscrollbars-vue/-/overlayscrollbars-vue-0.5.10.tgz",
"integrity": "sha512-vu35CJj/kzGBgkxsQyXCCCRRBz/3AvXhkDckuiRHphRE6CwXjbWATY0PVbn2KRy30NbaZhHqueH5yqPo8ItqTA==",
"license": "MIT",
"peerDependencies": {
"overlayscrollbars": "^2.0.0",
"vue": "^3.2.25"
}
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-4.0.0.tgz",
......
...@@ -33,6 +33,8 @@ ...@@ -33,6 +33,8 @@
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"naive-ui": "^2.43.2", "naive-ui": "^2.43.2",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-vue": "^0.5.10",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"vfonts": "^0.0.3", "vfonts": "^0.0.3",
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
header-class="!px-[20px] !py-[15px]" header-class="!px-[20px] !py-[15px]"
content-class="!p-0" content-class="!p-0"
footer-class="!m-0 !p-0" footer-class="!m-0 !p-0"
:header-style="showHeader ? {} : { display: 'none' }"
> >
<template #header-extra> <template #header-extra>
<slot name="header-extra"></slot> <slot name="header-extra"></slot>
...@@ -66,6 +67,7 @@ interface Props { ...@@ -66,6 +67,7 @@ interface Props {
padding?: string | number padding?: string | number
scrollable?: boolean scrollable?: boolean
fullscreen?: boolean fullscreen?: boolean
showHeader?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -80,7 +82,8 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -80,7 +82,8 @@ const props = withDefaults(defineProps<Props>(), {
maxHeight: '75vh', maxHeight: '75vh',
padding: '15px', padding: '15px',
scrollable: true, scrollable: true,
fullscreen: false fullscreen: false,
showHeader: true
}) })
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']) const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
......
...@@ -99,7 +99,7 @@ const customLineNumbers = lineNumbers({ ...@@ -99,7 +99,7 @@ const customLineNumbers = lineNumbers({
} }
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue', 'line-select'])
const editorStyle = computed(() => { const editorStyle = computed(() => {
const styles: Record<string, string> = {} const styles: Record<string, string> = {}
...@@ -627,8 +627,16 @@ const initCodeMirror = () => { ...@@ -627,8 +627,16 @@ const initCodeMirror = () => {
// 如果光标位置改变,或者编辑器聚焦且选区为空(普通闪烁光标) // 如果光标位置改变,或者编辑器聚焦且选区为空(普通闪烁光标)
if (update.selectionSet || update.focusChanged) { if (update.selectionSet || update.focusChanged) {
const mainSelection = update.state.selection.main const mainSelection = update.state.selection.main
if (mainSelection.empty && update.view.hasFocus) { if (mainSelection.empty) {
const pos = mainSelection.head const pos = mainSelection.head
if (update.selectionSet && update.view.hasFocus) {
try {
const line = update.state.doc.lineAt(pos)
emit('line-select', line.number)
} catch (e) {}
}
if (update.view.hasFocus) {
const beforeStr = update.state.sliceDoc(Math.max(0, pos - 50), pos) const beforeStr = update.state.sliceDoc(Math.max(0, pos - 50), pos)
const afterStr = update.state.sliceDoc(pos, Math.min(update.state.doc.length, pos + 50)) const afterStr = update.state.sliceDoc(pos, Math.min(update.state.doc.length, pos + 50))
...@@ -646,6 +654,7 @@ const initCodeMirror = () => { ...@@ -646,6 +654,7 @@ const initCodeMirror = () => {
} }
} }
} }
}
}) })
] ]
}) })
...@@ -753,6 +762,17 @@ const handleGlobalCtrlF = (event: KeyboardEvent) => { ...@@ -753,6 +762,17 @@ const handleGlobalCtrlF = (event: KeyboardEvent) => {
openSearchPanel(editorView) openSearchPanel(editorView)
} }
const highlightAndScrollToLine = (lineNum: number) => {
if (!editorView || !lineNum) return
try {
const line = editorView.state.doc.line(lineNum)
editorView.dispatch({
selection: { anchor: line.from },
scrollIntoView: true
})
} catch (e) {}
}
onMounted(() => { onMounted(() => {
initCodeMirror() initCodeMirror()
window.addEventListener('keydown', handleGlobalCtrlF, true) window.addEventListener('keydown', handleGlobalCtrlF, true)
...@@ -766,6 +786,7 @@ onBeforeUnmount(() => { ...@@ -766,6 +786,7 @@ onBeforeUnmount(() => {
}) })
defineExpose({ defineExpose({
handleFormat handleFormat,
highlightAndScrollToLine
}) })
</script> </script>
...@@ -74,11 +74,16 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode ...@@ -74,11 +74,16 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
let textContentCollector = '' let textContentCollector = ''
for (const child of childNodes) { for (const child of childNodes) {
if (child.nodeType === Node.TEXT_NODE) { if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent || '' const rawText = child.textContent || ''
if (rawText) {
// 规范化空白字符:将所有换行符及其周围的连续空白规范化为一个单空格,并 trim 首尾空白
// trim() 消除格式化缩进导致的尾部空格(如 "text\n " → "text"),避免比对时误判差异
const text = rawText.replace(/\s*[\r\n]\s*/g, ' ').trim()
if (text) { if (text) {
mixedContent.push({ type: 'text', text }) mixedContent.push({ type: 'text', text })
textContentCollector += text textContentCollector += text
} }
}
} else if (child.nodeType === Node.ELEMENT_NODE) { } else if (child.nodeType === Node.ELEMENT_NODE) {
const childNode = domElementToXmlNode(child as Element, id) const childNode = domElementToXmlNode(child as Element, id)
children.push(childNode) children.push(childNode)
...@@ -94,8 +99,9 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode ...@@ -94,8 +99,9 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
} }
} }
} else { } else {
// 纯文本内容 // 纯文本内容,同样对换行与缩进做规范化合并,并 trim 首尾空白避免格式化差异
textContent = element.textContent || '' const rawText = element.textContent || ''
textContent = rawText.replace(/\s*[\r\n]\s*/g, ' ').trim()
} }
return { return {
...@@ -151,6 +157,90 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b ...@@ -151,6 +157,90 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
return `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>` return `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>`
} }
interface LineRange {
startLine: number
endLine: number
}
/**
* 将 XmlNode 树序列化回 XML 字符串,并同时计算记录每一个节点在最终序列化纯净文本中的起始和结束行号范围(1-indexed)
*/
export function serializeTreeToXmlWithMapping(
node: XmlNode,
indent: number = 0,
compact: boolean = false,
state = { currentLine: 1 },
mapping = new Map<string, LineRange>()
): { xml: string; mapping: Map<string, LineRange> } {
const pad = compact ? '' : ' '.repeat(indent)
const newline = compact ? '' : '\n'
const attrs = Object.entries(node.attributes)
.map(([k, v]) => `${k}="${escapeXmlAttr(v)}"`)
.join(' ')
const openTag = attrs ? `${node.tagName} ${attrs}` : node.tagName
const startLine = state.currentLine
let xml = ''
// 空元素(无子节点、无文本、无混合内容)
if (node.children.length === 0 && !node.textContent && node.mixedContent.length === 0) {
xml = `${pad}<${openTag}/>`
mapping.set(node.id, { startLine, endLine: startLine })
return { xml, mapping }
}
// 混合内容节点
if (node.mixedContent.length > 0) {
let content = ''
for (const item of node.mixedContent) {
if (item.type === 'text') {
const escapedText = escapeXmlText(item.text || '')
content += escapedText
const newlines = (escapedText.match(/\n/g) || []).length
state.currentLine += newlines
} else if (item.type === 'element' && item.nodeId) {
const child = node.children.find((c) => c.id === item.nodeId)
if (child) {
const res = serializeTreeToXmlWithMapping(child, 0, true, state, mapping)
content += res.xml
}
}
}
xml = `${pad}<${openTag}>${content}</${node.tagName}>`
mapping.set(node.id, { startLine, endLine: state.currentLine })
return { xml, mapping }
}
// 纯文本节点
if (node.children.length === 0 && node.textContent) {
const escapedText = escapeXmlText(node.textContent)
xml = `${pad}<${openTag}>${escapedText}</${node.tagName}>`
const newlines = (escapedText.match(/\n/g) || []).length
state.currentLine += newlines
mapping.set(node.id, { startLine, endLine: state.currentLine })
return { xml, mapping }
}
// 纯元素子节点
state.currentLine++
const childrenParts: string[] = []
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i]
const res = serializeTreeToXmlWithMapping(child, indent + 1, compact, state, mapping)
childrenParts.push(res.xml)
if (i < node.children.length - 1) {
state.currentLine++
}
}
state.currentLine++
const childrenXml = childrenParts.join(newline)
xml = `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>`
mapping.set(node.id, { startLine, endLine: state.currentLine })
return { xml, mapping }
}
/** /**
* 格式化 XML 字符串(转换为带换行和缩进的排版形式) * 格式化 XML 字符串(转换为带换行和缩进的排版形式)
*/ */
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '', isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '',
!renderInline && isContainer ? 'py-1 px-1' : '' !renderInline && isContainer ? 'py-1 px-1' : ''
]" ]"
:style="!isDiffMode && node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}" :style="node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="editorStore.setSelectedNodeId(node.id)" @click.stop="editorStore.setSelectedNodeId(node.id)"
> >
<!-- 智能翻译加载遮罩 --> <!-- 智能翻译加载遮罩 -->
......
// 比对差异状态类型定义
export type DiffStatusType = 'added' | 'removed' | 'modified' | 'none' | 'added-placeholder' | 'removed-placeholder'
// 滚动条交互所使用的侧边类型
export type ScrollSide = 'left' | 'right'
// 自定义横向/纵向滚动条滑块的最小宽度/高度百分比 (%)
export const MIN_THUMB_SIZE_PCT = 8
// 渲染区域比对差异段样式模型
export interface RenderDiffHunk {
type: 'added' | 'removed' | 'modified'
style: Record<string, string>
}
// XML源码比对差异段样式模型
export interface XmlDiffHunk {
type: 'added' | 'removed'
style: Record<string, string>
}
import { openUploadModal } from '@/utils/render'
import { ref, computed, nextTick, watch, onBeforeUnmount } from 'vue'
import * as Diff from 'diff'
import { parseXmlToTree, serializeTreeToXml, serializeTreeToXmlWithMapping } from '@/utils/xmlParser'
import { useEditorStore } from '@/store/editor'
import type { XmlNode, MixedContentItem } from '@/types/xmlNode'
import type { DiffStatusType, ScrollSide, RenderDiffHunk, XmlDiffHunk } from '../constants'
import { MIN_THUMB_SIZE_PCT } from '../constants'
// 辅助函数:根据 status 克隆节点
function cloneWithDiff(
node: XmlNode,
status: DiffStatusType = 'none'
): XmlNode {
const children = node.children.map((c) => cloneWithDiff(c, status))
// 建立子节点原始 ID 到克隆后新 ID 的映射
const childIdMap = new Map<string, string>()
for (let idx = 0; idx < node.children.length; idx++) {
childIdMap.set(node.children[idx].id, children[idx].id)
}
const mixedContent = node.mixedContent.map((item) => {
if (item.type === 'text') {
return {
...item,
diffStatus: status as any,
diffWords:
status === 'none'
? []
: [
{
value: item.text || '',
added: status === 'added',
removed: status === 'removed'
}
]
}
} else {
return {
...item,
nodeId: childIdMap.get(item.nodeId!) || item.nodeId,
diffStatus: status as any
}
}
})
return {
...node,
id: node.id + '_' + status, // 避免 id 冲突
diffStatus: status as any,
children,
mixedContent
}
}
// 辅助函数:LCS 对齐子节点列表
function alignChildren(oldChildren: XmlNode[], newChildren: XmlNode[]): { oldNode: XmlNode | null; newNode: XmlNode | null }[] {
const m = oldChildren.length
const n = newChildren.length
// 获取匹配得分:tagName 必须一致;如果带有 KEY 属性则也必须一致
const getMatchScore = (o: XmlNode, n: XmlNode): number => {
if (o.tagName !== n.tagName) return 0
const oKey = o.attributes.KEY || o.attributes.ID
const nKey = n.attributes.KEY || n.attributes.ID
if (oKey || nKey) {
return oKey === nKey ? 3 : 0
}
return 1
}
const dp: number[][] = Array(m + 1)
.fill(0)
.map(() => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const score = getMatchScore(oldChildren[i - 1], newChildren[j - 1])
if (score > 0) {
dp[i][j] = dp[i - 1][j - 1] + score
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
const alignment: { oldNode: XmlNode | null; newNode: XmlNode | null }[] = []
let i = m,
j = n
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && getMatchScore(oldChildren[i - 1], newChildren[j - 1]) > 0) {
alignment.unshift({ oldNode: oldChildren[i - 1], newNode: newChildren[j - 1] })
i--
j--
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
alignment.unshift({ oldNode: null, newNode: newChildren[j - 1] })
j--
} else {
alignment.unshift({ oldNode: oldChildren[i - 1], newNode: null })
i--
}
}
return alignment
}
// 递归进行两棵树的比对
function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDiff: XmlNode; newDiff: XmlNode } {
if (oldNode && !newNode) {
// 旧节点被删除了
const oldDiff = cloneWithDiff(oldNode, 'removed')
const newDiff = cloneWithDiff(oldNode, 'removed-placeholder')
return { oldDiff, newDiff }
}
if (!oldNode && newNode) {
// 新节点是新增的
const oldDiff = cloneWithDiff(newNode, 'added-placeholder')
const newDiff = cloneWithDiff(newNode, 'added')
return { oldDiff, newDiff }
}
// 两者都存在。它们 tagName 肯定相同。
const tagName = oldNode!.tagName
// 比较属性
const oldAttrs = { ...oldNode!.attributes }
const newAttrs = { ...newNode!.attributes }
let attrsChanged = false
const allAttrKeys = new Set([...Object.keys(oldAttrs), ...Object.keys(newAttrs)])
for (const key of allAttrKeys) {
if (oldAttrs[key] !== newAttrs[key]) {
attrsChanged = true
}
}
// 递归对齐比对子节点
const oldChildrenDiff: XmlNode[] = []
const newChildrenDiff: XmlNode[] = []
const alignment = alignChildren(oldNode!.children, newNode!.children)
// 建立子节点原始 ID 到比对后新 ID 的映射
const oldIdMap = new Map<string, string>()
const newIdMap = new Map<string, string>()
for (const pair of alignment) {
const { oldDiff, newDiff } = diffXmlTrees(pair.oldNode, pair.newNode)
oldChildrenDiff.push(oldDiff)
newChildrenDiff.push(newDiff)
if (pair.oldNode) {
oldIdMap.set(pair.oldNode.id, oldDiff.id)
}
if (pair.newNode) {
newIdMap.set(pair.newNode.id, newDiff.id)
}
}
// 比较文本内容
let diffStatus: 'none' | 'modified' | 'added' | 'removed' = 'none'
const oldMixedContent: MixedContentItem[] = []
const newMixedContent: MixedContentItem[] = []
const oldMixed = oldNode!.mixedContent || []
const newMixed = newNode!.mixedContent || []
const maxLen = Math.max(oldMixed.length, newMixed.length)
for (let k = 0; k < maxLen; k++) {
const oldItem = oldMixed[k]
const newItem = newMixed[k]
if (oldItem && newItem) {
if (oldItem.type === 'text' && newItem.type === 'text') {
const oldText = oldItem.text || ''
const newText = newItem.text || ''
if (oldText !== newText) {
diffStatus = 'modified'
const words = Diff.diffWords(oldText, newText)
const oldWords = words.filter((w) => !w.added).map((w) => ({ value: w.value, removed: w.removed }))
const newWords = words.filter((w) => !w.removed).map((w) => ({ value: w.value, added: w.added }))
oldMixedContent.push({
type: 'text',
text: oldText,
diffStatus: 'modified' as any,
diffWords: oldWords
})
newMixedContent.push({
type: 'text',
text: newText,
diffStatus: 'modified' as any,
diffWords: newWords
})
} else {
oldMixedContent.push({
type: 'text',
text: oldText,
diffStatus: 'none' as any
})
newMixedContent.push({
type: 'text',
text: newText,
diffStatus: 'none' as any
})
}
} else if (oldItem.type === 'element' && newItem.type === 'element') {
const oldMappedId = oldIdMap.get(oldItem.nodeId!) || oldItem.nodeId
const newMappedId = newIdMap.get(newItem.nodeId!) || newItem.nodeId
oldMixedContent.push({
type: 'element',
nodeId: oldMappedId,
diffStatus: 'none' as any
})
newMixedContent.push({
type: 'element',
nodeId: newMappedId,
diffStatus: 'none' as any
})
} else {
diffStatus = 'modified'
if (oldItem.type === 'text') {
oldMixedContent.push({
type: 'text',
text: oldItem.text || '',
diffStatus: 'removed' as any,
diffWords: [{ value: oldItem.text || '', removed: true }]
})
} else {
const oldMappedId = oldIdMap.get(oldItem.nodeId!) || oldItem.nodeId
oldMixedContent.push({
type: 'element',
nodeId: oldMappedId,
diffStatus: 'removed' as any
})
}
if (newItem.type === 'text') {
newMixedContent.push({
type: 'text',
text: newItem.text || '',
diffStatus: 'added' as any,
diffWords: [{ value: newItem.text || '', added: true }]
})
} else {
const newMappedId = newIdMap.get(newItem.nodeId!) || newItem.nodeId
newMixedContent.push({
type: 'element',
nodeId: newMappedId,
diffStatus: 'added' as any
})
}
}
} else if (oldItem) {
diffStatus = 'modified'
if (oldItem.type === 'text') {
oldMixedContent.push({
type: 'text',
text: oldItem.text || '',
diffStatus: 'removed' as any,
diffWords: [{ value: oldItem.text || '', removed: true }]
})
} else {
const oldMappedId = oldIdMap.get(oldItem.nodeId!) || oldItem.nodeId
oldMixedContent.push({
type: 'element',
nodeId: oldMappedId,
diffStatus: 'removed' as any
})
}
} else if (newItem) {
diffStatus = 'modified'
if (newItem.type === 'text') {
newMixedContent.push({
type: 'text',
text: newItem.text || '',
diffStatus: 'added' as any,
diffWords: [{ value: newItem.text || '', added: true }]
})
} else {
const newMappedId = newIdMap.get(newItem.nodeId!) || newItem.nodeId
newMixedContent.push({
type: 'element',
nodeId: newMappedId,
diffStatus: 'added' as any
})
}
}
}
if (oldMixed.length === 0 && newMixed.length === 0) {
const oldText = oldNode!.textContent || ''
const newText = newNode!.textContent || ''
if (oldText !== newText) {
diffStatus = 'modified'
const words = Diff.diffWords(oldText, newText)
const oldWords = words.filter((w) => !w.added).map((w) => ({ value: w.value, removed: w.removed }))
const newWords = words.filter((w) => !w.removed).map((w) => ({ value: w.value, added: w.added }))
oldMixedContent.push({
type: 'text',
text: oldText,
diffStatus: 'modified' as any,
diffWords: oldWords
})
newMixedContent.push({
type: 'text',
text: newText,
diffStatus: 'modified' as any,
diffWords: newWords
})
}
}
if (attrsChanged && diffStatus === 'none') {
diffStatus = 'modified'
}
const oldDiff: XmlNode = {
...oldNode!,
id: oldNode!.id + '_diff',
diffStatus: diffStatus as any,
children: oldChildrenDiff,
mixedContent: oldMixedContent,
attributes: oldAttrs,
textContent: oldNode!.textContent
}
const newDiff: XmlNode = {
...newNode!,
id: newNode!.id + '_diff',
diffStatus: diffStatus as any,
children: newChildrenDiff,
mixedContent: newMixedContent,
attributes: newAttrs,
textContent: newNode!.textContent
}
return { oldDiff, newDiff }
}
export function useCompareModal() {
const visible = ref(false)
const editorStore = useEditorStore()
// 输入的原始 XML
const leftXml = ref('')
const rightXml = ref('')
// 比对展示用的对齐 XML(在 CommonXmlEditor 渲染)
const leftDisplayXml = ref('')
const rightDisplayXml = ref('')
// XML 的高亮样式类数组
const leftLineClasses = ref<string[]>([])
const rightLineClasses = ref<string[]>([])
// XML 的行号数组
const leftLineNumbers = ref<(string | number)[]>([])
const rightLineNumbers = ref<(string | number)[]>([])
// 比对展示用的样式渲染树(在 DocNodeRenderer 渲染)
const leftRenderTree = ref<XmlNode | null>(null)
const rightRenderTree = ref<XmlNode | null>(null)
// 行号和节点的映射表
const leftNodeRanges = ref<Map<string, { startLine: number; endLine: number }>>(new Map())
const rightNodeRanges = ref<Map<string, { startLine: number; endLine: number }>>(new Map())
// 编辑器对齐行号 到 原始格式化行号 的映射
const leftEditorToOriginal = ref<Map<number, number>>(new Map())
const rightEditorToOriginal = ref<Map<number, number>>(new Map())
// 原始格式化行号 到 编辑器对齐行号 的映射
const leftOriginalToEditor = ref<Map<number, number>>(new Map())
const rightOriginalToEditor = ref<Map<number, number>>(new Map())
// 比对控制
const compared = ref(false)
const comparing = ref(false) // 比对进行中(加载状态)
const showXmlCompare = ref(true)
const showRenderCompare = ref(true)
const handleTextExpandedChange = (val: boolean) => {
if (!val && !showRenderCompare.value) {
window.$message?.warning('请至少保留一个对比区域')
return
}
showXmlCompare.value = val
}
const handleRenderExpandedChange = (val: boolean) => {
if (!val && !showXmlCompare.value) {
window.$message?.warning('请至少保留一个对比区域')
return
}
showRenderCompare.value = val
}
// 左右同步滚动控制(普通变量,无响应式开销)
let leftScrollerEl: HTMLElement | null = null
let rightScrollerEl: HTMLElement | null = null
const leftRenderContainer = ref<HTMLElement | null>(null)
const rightRenderContainer = ref<HTMLElement | null>(null)
const open = () => {
visible.value = true
leftXml.value = ''
rightXml.value = ''
leftDisplayXml.value = ''
rightDisplayXml.value = ''
leftLineClasses.value = []
rightLineClasses.value = []
leftLineNumbers.value = []
rightLineNumbers.value = []
leftRenderTree.value = null
rightRenderTree.value = null
leftNodeRanges.value.clear()
rightNodeRanges.value.clear()
leftEditorToOriginal.value.clear()
rightEditorToOriginal.value.clear()
leftOriginalToEditor.value.clear()
rightOriginalToEditor.value.clear()
compared.value = false
comparing.value = false
}
// 使用当前打开的工卡
const handleUseCurrent = () => {
if (!editorStore.xmlTree) {
window.$message?.warning('当前编辑器没有打开的工卡')
return
}
try {
leftXml.value = serializeTreeToXml(editorStore.xmlTree, 0, false)
window.$message?.success('已成功使用当前工卡 XML')
} catch (err: any) {
window.$message?.error(`获取当前工卡 XML 失败: ${err.message || err}`)
}
}
// 核心比对逻辑(async:先格式化写回编辑器,让 UI 渲染加载动画后再执行重计算)
const handleCompare = async () => {
if (!leftXml.value.trim()) {
window.$message?.warning('请先提供源工卡 XML')
return
}
if (!rightXml.value.trim()) {
window.$message?.warning('请先提供目标工卡 XML')
return
}
comparing.value = true
const rawLeftXml = leftXml.value
const rawRightXml = rightXml.value
// 清空重置映射表
leftNodeRanges.value.clear()
rightNodeRanges.value.clear()
leftEditorToOriginal.value.clear()
rightEditorToOriginal.value.clear()
leftOriginalToEditor.value.clear()
rightOriginalToEditor.value.clear()
try {
// --- 步骤 1:解析为树,以便生成各节点唯一的 id 标识 ---
const cleanLeftXml = rawLeftXml.trim().replace(/>\s+</g, '><')
const cleanRightXml = rawRightXml.trim().replace(/>\s+</g, '><')
const leftTree = parseXmlToTree(cleanLeftXml)
const rightTree = parseXmlToTree(cleanRightXml)
// --- 步骤 2:以纯净、格式化的形式序列化 XML 源码,并获取每个节点在源码中的行范围 ---
const leftRes = serializeTreeToXmlWithMapping(leftTree, 0, false, { currentLine: 1 })
const rightRes = serializeTreeToXmlWithMapping(rightTree, 0, false, { currentLine: 1 })
const formattedLeftXml = leftRes.xml
const formattedRightXml = rightRes.xml
leftNodeRanges.value = leftRes.mapping
rightNodeRanges.value = rightRes.mapping
// 先将格式化后的纯净结果回显到编辑器
leftXml.value = formattedLeftXml
rightXml.value = formattedRightXml
// 让加载遮罩和格式化内容先进行一帧渲染
await new Promise<void>((resolve) => setTimeout(resolve, 50))
// --- 步骤 3:Line Diff 生成,占位对齐与行号计算 ---
const diffResults = Diff.diffLines(formattedLeftXml, formattedRightXml)
const lLines: string[] = []
const rLines: string[] = []
const lClasses: string[] = []
const rClasses: string[] = []
const lNums: (string | number)[] = []
const rNums: (string | number)[] = []
let leftRealLine = 1
let rightRealLine = 1
let displayLineIndex = 1
for (const change of diffResults) {
const lines = change.value.split('\n')
if (lines.length > 1 && lines[lines.length - 1] === '') {
lines.pop()
}
if (change.added) {
for (const line of lines) {
lLines.push('')
lClasses.push('diff-added-placeholder')
lNums.push('')
rLines.push(line)
rClasses.push('diff-added')
rNums.push(rightRealLine)
// 记录右侧编辑器到原始格式化行号的对应关系
rightEditorToOriginal.value.set(displayLineIndex, rightRealLine)
rightOriginalToEditor.value.set(rightRealLine, displayLineIndex)
rightRealLine++
displayLineIndex++
}
} else if (change.removed) {
for (const line of lines) {
lLines.push(line)
lClasses.push('diff-removed')
lNums.push(leftRealLine)
rLines.push('')
rClasses.push('diff-removed-placeholder')
rNums.push('')
// 记录左侧编辑器到原始格式化行号的对应关系
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
leftRealLine++
displayLineIndex++
}
} else {
for (const line of lines) {
lLines.push(line)
lClasses.push('')
lNums.push(leftRealLine)
rLines.push(line)
rClasses.push('')
rNums.push(rightRealLine)
// 记录两侧编辑器到原始格式化行号的对应关系
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
rightEditorToOriginal.value.set(displayLineIndex, rightRealLine)
rightOriginalToEditor.value.set(rightRealLine, displayLineIndex)
leftRealLine++
rightRealLine++
displayLineIndex++
}
}
}
leftDisplayXml.value = lLines.join('\n')
rightDisplayXml.value = rLines.join('\n')
leftLineClasses.value = lClasses
rightLineClasses.value = rClasses
leftLineNumbers.value = lNums
rightLineNumbers.value = rNums
// 将对齐后的纯净内容写回编辑器
leftXml.value = leftDisplayXml.value
rightXml.value = rightDisplayXml.value
// --- 步骤 4:直接对先前创建的树进行样式比对,保证节点 ID 完全一致且解析高效 ---
const { oldDiff, newDiff } = diffXmlTrees(leftTree, rightTree)
leftRenderTree.value = oldDiff
rightRenderTree.value = newDiff
compared.value = true
window.$message?.success('比对成功')
// 比对成功后同步滚动绑定
nextTick(() => {
bindScrollSync()
})
} catch (err: any) {
window.$message?.error(`比对发生错误: ${err.message || err}`)
} finally {
comparing.value = false
}
}
// 同步 XML 编辑器滚动
// 用位置相等判断阻断回路:设置 other.scrollTop 后 other 的 scroll 事件触发时两侧已相等 → 直接返回
// 不使用锁标志,确保每次用户滚动事件都能即时同步对侧,不产生位置滞后
const syncXmlScroll = (e: Event) => {
const target = e.target as HTMLElement
const other = target === leftScrollerEl ? rightScrollerEl : leftScrollerEl
if (!other) return
if (other.scrollTop === target.scrollTop && other.scrollLeft === target.scrollLeft) return
other.scrollTop = target.scrollTop
other.scrollLeft = target.scrollLeft
}
// 同步渲染区域滚动(同理)
const syncRenderScroll = (e: Event) => {
const target = e.target as HTMLElement
const other = target === leftRenderContainer.value ? rightRenderContainer.value : leftRenderContainer.value
if (!other) return
if (other.scrollTop === target.scrollTop && other.scrollLeft === target.scrollLeft) return
other.scrollTop = target.scrollTop
other.scrollLeft = target.scrollLeft
}
// 绑定左右同步滚动
const bindScrollSync = () => {
// XML 滚动同步
const leftEl = document.querySelector('.xml-compare-left .cm-scroller') as HTMLElement
const rightEl = document.querySelector('.xml-compare-right .cm-scroller') as HTMLElement
if (leftEl && rightEl) {
leftScrollerEl = leftEl
rightScrollerEl = rightEl
leftEl.addEventListener('scroll', syncXmlScroll, { passive: true })
rightEl.addEventListener('scroll', syncXmlScroll, { passive: true })
}
// 渲染区滚动同步
const leftRC = leftRenderContainer.value
const rightRC = rightRenderContainer.value
if (leftRC && rightRC) {
leftRC.addEventListener('scroll', syncRenderScroll, { passive: true })
rightRC.addEventListener('scroll', syncRenderScroll, { passive: true })
}
}
// 解绑同步滚动
const unbindScrollSync = () => {
if (leftScrollerEl) {
leftScrollerEl.removeEventListener('scroll', syncXmlScroll)
}
if (rightScrollerEl) {
rightScrollerEl.removeEventListener('scroll', syncXmlScroll)
}
if (leftRenderContainer.value) {
leftRenderContainer.value.removeEventListener('scroll', syncRenderScroll)
}
if (rightRenderContainer.value) {
rightRenderContainer.value.removeEventListener('scroll', syncRenderScroll)
}
}
// ── 源码自定义横向同步滚动条实现 ──
const leftScrollLeft = ref(0)
const leftScrollWidth = ref(0)
const leftClientWidth = ref(0)
const rightScrollLeft = ref(0)
const rightScrollWidth = ref(0)
const rightClientWidth = ref(0)
const isLeftHorizontalDragging = ref(false)
const isRightHorizontalDragging = ref(false)
// 是否支持横向滚动(内容超宽)
const isLeftHorizontalScrollable = computed(() => {
return leftScrollWidth.value > leftClientWidth.value + 1
})
const isRightHorizontalScrollable = computed(() => {
return rightScrollWidth.value > rightClientWidth.value + 1
})
// 计算滑块位置及宽度样式
const leftHorizontalViewportStyle = computed(() => {
if (!leftScrollWidth.value) return { width: '0px', left: '0px' }
const ratio = leftClientWidth.value / leftScrollWidth.value
const widthPct = Math.max(MIN_THUMB_SIZE_PCT, ratio * 100) // 设定滑块最小宽度为常量百分比
const maxLeftPct = 100 - widthPct
const scrollRatio = leftScrollLeft.value / (leftScrollWidth.value - leftClientWidth.value || 1)
const leftPct = scrollRatio * maxLeftPct
return {
width: `${widthPct}%`,
left: `${leftPct}%`
}
})
const rightHorizontalViewportStyle = computed(() => {
if (!rightScrollWidth.value) return { width: '0px', left: '0px' }
const ratio = rightClientWidth.value / rightScrollWidth.value
const widthPct = Math.max(MIN_THUMB_SIZE_PCT, ratio * 100)
const maxLeftPct = 100 - widthPct
const scrollRatio = rightScrollLeft.value / (rightScrollWidth.value - rightClientWidth.value || 1)
const leftPct = scrollRatio * maxLeftPct
return {
width: `${widthPct}%`,
left: `${leftPct}%`
}
})
// 读取并更新编辑器的横向滚动数据
const updateHorizontalScrollInfo = () => {
const leftEl = document.querySelector('.xml-compare-left .cm-scroller') as HTMLElement
const rightEl = document.querySelector('.xml-compare-right .cm-scroller') as HTMLElement
if (leftEl) {
leftScrollLeft.value = leftEl.scrollLeft
leftScrollWidth.value = leftEl.scrollWidth
leftClientWidth.value = leftEl.clientWidth
}
if (rightEl) {
rightScrollLeft.value = rightEl.scrollLeft
rightScrollWidth.value = rightEl.scrollWidth
rightClientWidth.value = rightEl.clientWidth
}
}
// 滚动监听事件
const handleXmlScroll = () => {
updateHorizontalScrollInfo()
}
// 指针拖拽自定义横向滑块
const handleHorizontalThumbPointerdown = (e: PointerEvent, side: ScrollSide) => {
const isLeft = side === 'left'
if (isLeft) isLeftHorizontalDragging.value = true
else isRightHorizontalDragging.value = true
const scroller = document.querySelector(`.xml-compare-${side} .cm-scroller`) as HTMLElement
if (!scroller) return
const scrollbarTrack = e.currentTarget ? (e.currentTarget as HTMLElement).parentElement : null
if (!scrollbarTrack) return
const trackWidth = scrollbarTrack.clientWidth
const thumbWidth = (e.currentTarget as HTMLElement).clientWidth
const startX = e.clientX
const startScrollLeft = scroller.scrollLeft
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX
const moveRatio = deltaX / (trackWidth - thumbWidth || 1)
const newScrollLeft = Math.max(0, Math.min(maxScrollLeft, startScrollLeft + moveRatio * maxScrollLeft))
scroller.scrollLeft = newScrollLeft
// 双侧横向滚动同步
const otherSide = side === 'left' ? 'right' : 'left'
const otherScroller = document.querySelector(`.xml-compare-${otherSide} .cm-scroller`) as HTMLElement
if (otherScroller) {
otherScroller.scrollLeft = newScrollLeft
}
updateHorizontalScrollInfo()
}
const handlePointerUp = () => {
if (isLeft) isLeftHorizontalDragging.value = false
else isRightHorizontalDragging.value = false
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
}
// 点击滚动条轨道跳转
const handleHorizontalScrollbarClick = (e: MouseEvent, side: ScrollSide) => {
const scroller = document.querySelector(`.xml-compare-${side} .cm-scroller`) as HTMLElement
const scrollbarTrack = e.currentTarget as HTMLElement
if (!scroller || !scrollbarTrack) return
const rect = scrollbarTrack.getBoundingClientRect()
const clickX = e.clientX - rect.left
const trackWidth = scrollbarTrack.clientWidth
const ratio = clickX / trackWidth
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth
const newScrollLeft = ratio * maxScrollLeft
scroller.scrollLeft = newScrollLeft
const otherSide = side === 'left' ? 'right' : 'left'
const otherScroller = document.querySelector(`.xml-compare-${otherSide} .cm-scroller`) as HTMLElement
if (otherScroller) {
otherScroller.scrollLeft = newScrollLeft
}
updateHorizontalScrollInfo()
}
// 绑定滚动和窗口 Resize 监听
watch(compared, (isCompared) => {
if (isCompared) {
nextTick(() => {
const leftEl = document.querySelector('.xml-compare-left .cm-scroller') as HTMLElement
const rightEl = document.querySelector('.xml-compare-right .cm-scroller') as HTMLElement
if (leftEl) {
leftEl.addEventListener('scroll', handleXmlScroll, { passive: true })
}
if (rightEl) {
rightEl.addEventListener('scroll', handleXmlScroll, { passive: true })
}
if (leftEl && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => {
updateHorizontalScrollInfo()
})
ro.observe(leftEl)
if (rightEl) ro.observe(rightEl)
}
updateHorizontalScrollInfo()
})
}
})
// 对比文本变化时重新读取滚动信息
watch([leftXml, rightXml], () => {
nextTick(() => {
updateHorizontalScrollInfo()
})
})
// ── 源码自定义纵向滚动条实现 ──
const leftScrollerBound = ref(false)
const isScrollable = ref(false)
const gutterScrollTop = ref(0)
const scrollTotalHeight = ref(1)
const gutterClientHeight = ref(240)
const isDragging = ref(false)
// 滚动条指示器视口样式
const viewportStyle = computed(() => {
const totalPx = scrollTotalHeight.value || 1
const top = Math.min((gutterScrollTop.value / totalPx) * 100, 95)
const height = Math.min((gutterClientHeight.value / totalPx) * 100, 100 - top)
return {
top: `${top}%`,
height: `${Math.max(height, 3)}%`,
minHeight: '30px'
}
})
// 将左侧 lineClasses 拆解为纵向标记段
const diffHunks = computed<XmlDiffHunk[]>(() => {
const cls = leftLineClasses.value
const n = cls.length
if (n === 0) return []
const result: XmlDiffHunk[] = []
let i = 0
while (i < n) {
if (cls[i] === 'diff-removed') {
const start = i
while (i < n && cls[i] === 'diff-removed') i++
const count = i - start
result.push({
type: 'removed',
style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' }
})
} else if (cls[i] === 'diff-added-placeholder') {
const start = i
while (i < n && cls[i] === 'diff-added-placeholder') i++
const count = i - start
result.push({
type: 'added',
style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' }
})
} else {
i++
}
}
return result
})
const updateGutterScroll = () => {
if (leftScrollerEl) {
gutterScrollTop.value = leftScrollerEl.scrollTop
scrollTotalHeight.value = leftScrollerEl.scrollHeight || 1
gutterClientHeight.value = leftScrollerEl.clientHeight || 240
isScrollable.value = leftScrollerEl.scrollHeight > leftScrollerEl.clientHeight
}
}
const bindScroller = () => {
const lEl = document.querySelector('.xml-compare-left .cm-scroller') as HTMLElement
const rEl = document.querySelector('.xml-compare-right .cm-scroller') as HTMLElement
if (lEl) {
if (leftScrollerEl) leftScrollerEl.removeEventListener('scroll', updateGutterScroll)
leftScrollerEl = lEl
rightScrollerEl = rEl
scrollTotalHeight.value = lEl.scrollHeight || 1
gutterClientHeight.value = lEl.clientHeight || 240
gutterScrollTop.value = lEl.scrollTop
isScrollable.value = lEl.scrollHeight > lEl.clientHeight
lEl.addEventListener('scroll', updateGutterScroll, { passive: true })
leftScrollerBound.value = true
}
}
const handleThumbPointerdown = (e: PointerEvent) => {
const thumb = e.currentTarget as HTMLElement
const track = thumb.parentElement!
thumb.setPointerCapture(e.pointerId)
isDragging.value = true
const dragStartY = e.clientY
const dragStartScrollTop = gutterScrollTop.value
const trackHeight = track.offsetHeight
const totalPx = scrollTotalHeight.value
const onMove = (mv: PointerEvent) => {
const delta = ((mv.clientY - dragStartY) / trackHeight) * totalPx
const target = Math.max(0, Math.min(dragStartScrollTop + delta, totalPx))
if (leftScrollerEl) leftScrollerEl.scrollTop = target
if (rightScrollerEl) rightScrollerEl.scrollTop = target
gutterScrollTop.value = target
}
const onUp = () => {
isDragging.value = false
thumb.removeEventListener('pointermove', onMove)
thumb.removeEventListener('pointerup', onUp)
}
thumb.addEventListener('pointermove', onMove)
thumb.addEventListener('pointerup', onUp)
}
const handleScrollbarClick = (e: MouseEvent, side?: ScrollSide) => {
const el = e.currentTarget as HTMLElement
const ratio = (e.clientY - el.getBoundingClientRect().top) / el.offsetHeight
const targetTop = ratio * scrollTotalHeight.value
if (leftScrollerEl) leftScrollerEl.scrollTop = targetTop
if (rightScrollerEl) rightScrollerEl.scrollTop = targetTop
}
// ─── 下方排版样式渲染对比区 滚动条 / 小地图逻辑 ───────────────────────────────────────────────────
const renderScrollTop = ref(0)
const renderClientHeight = ref(240)
const renderTotalHeight = ref(1)
const renderScrollerBound = ref(false)
const isRenderScrollable = ref(false)
const isRenderDragging = ref(false)
const renderDiffHunks = ref<RenderDiffHunk[]>([])
let renderMutationObserver: MutationObserver | null = null
const updateRenderScroll = () => {
const el = leftRenderContainer.value
if (el) {
renderScrollTop.value = el.scrollTop
renderTotalHeight.value = el.scrollHeight || 1
renderClientHeight.value = el.clientHeight || 240
isRenderScrollable.value = el.scrollHeight > el.clientHeight
if (compared.value && renderDiffHunks.value.length === 0) {
calculateRenderDiffHunks()
}
}
}
const calculateRenderDiffHunks = () => {
const el = leftRenderContainer.value
if (!el) return
const totalHeight = el.scrollHeight || 1
const hunks: RenderDiffHunk[] = []
const diffNodes = el.querySelectorAll('[data-diff-status]')
diffNodes.forEach((node) => {
const htmlNode = node as HTMLElement
const status = htmlNode.getAttribute('data-diff-status')
if (
status === 'added' ||
status === 'removed' ||
status === 'modified' ||
status === 'added-placeholder' ||
status === 'removed-placeholder'
) {
let offsetTop = htmlNode.offsetTop
let parent = htmlNode.offsetParent as HTMLElement
while (parent && parent !== el && el.contains(parent)) {
offsetTop += parent.offsetTop
parent = parent.offsetParent as HTMLElement
}
const offsetHeight = htmlNode.offsetHeight
const topPct = (offsetTop / totalHeight) * 100
const heightPct = (offsetHeight / totalHeight) * 100
let type: 'added' | 'removed' | 'modified' = 'modified'
if (status === 'added' || status === 'added-placeholder') {
type = 'added'
} else if (status === 'removed' || status === 'removed-placeholder') {
type = 'removed'
}
hunks.push({
type,
style: {
top: `${topPct}%`,
height: `${Math.max(0.5, heightPct)}%`,
minHeight: '3px'
}
})
}
})
renderDiffHunks.value = hunks
}
const bindRenderScroller = () => {
const lEl = leftRenderContainer.value
if (lEl) {
lEl.removeEventListener('scroll', updateRenderScroll)
renderTotalHeight.value = lEl.scrollHeight || 1
renderClientHeight.value = lEl.clientHeight || 240
renderScrollTop.value = lEl.scrollTop
isRenderScrollable.value = lEl.scrollHeight > lEl.clientHeight
lEl.addEventListener('scroll', updateRenderScroll, { passive: true })
renderScrollerBound.value = true
if (renderMutationObserver) {
renderMutationObserver.disconnect()
}
renderMutationObserver = new MutationObserver(() => {
calculateRenderDiffHunks()
if (leftRenderContainer.value) {
renderTotalHeight.value = leftRenderContainer.value.scrollHeight || 1
renderClientHeight.value = leftRenderContainer.value.clientHeight || 240
isRenderScrollable.value = leftRenderContainer.value.scrollHeight > leftRenderContainer.value.clientHeight
}
})
renderMutationObserver.observe(lEl, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-diff-status']
})
calculateRenderDiffHunks()
}
}
const renderViewportStyle = computed(() => {
const totalPx = renderTotalHeight.value || 1
const top = Math.min((renderScrollTop.value / totalPx) * 100, 95)
const height = Math.min((renderClientHeight.value / totalPx) * 100, 100 - top)
return {
top: `${top}%`,
height: `${Math.max(height, 3)}%`,
minHeight: '30px'
}
})
const handleRenderThumbPointerdown = (e: PointerEvent) => {
const thumb = e.currentTarget as HTMLElement
const track = thumb.parentElement!
thumb.setPointerCapture(e.pointerId)
isRenderDragging.value = true
const dragStartY = e.clientY
const dragStartScrollTop = renderScrollTop.value
const trackHeight = track.offsetHeight
const totalPx = renderTotalHeight.value
const onMove = (mv: PointerEvent) => {
const delta = ((mv.clientY - dragStartY) / trackHeight) * totalPx
const target = Math.max(0, Math.min(dragStartScrollTop + delta, totalPx))
if (leftRenderContainer.value) leftRenderContainer.value.scrollTop = target
if (rightRenderContainer.value) rightRenderContainer.value.scrollTop = target
renderScrollTop.value = target
}
const onUp = () => {
isRenderDragging.value = false
thumb.removeEventListener('pointermove', onMove)
thumb.removeEventListener('pointerup', onUp)
}
thumb.addEventListener('pointermove', onMove)
thumb.addEventListener('pointerup', onUp)
}
const handleRenderScrollbarClick = (e: MouseEvent) => {
const el = e.currentTarget as HTMLElement
const ratio = (e.clientY - el.getBoundingClientRect().top) / el.offsetHeight
const targetTop = ratio * renderTotalHeight.value
if (leftRenderContainer.value) leftRenderContainer.value.scrollTop = targetTop
if (rightRenderContainer.value) rightRenderContainer.value.scrollTop = targetTop
}
const triggerFileImport = () => {
openUploadModal({
title: '选择 XML 文件导入',
accept: '.xml',
onSuccess: (res?: any, file?: File | null) => {
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
let xmlContent = (event.target?.result as string) || ''
// 去除 <?xml ...?> 声明头
xmlContent = xmlContent.replace(/<\?xml.*?\?>/gi, '').trim()
try {
const tree = parseXmlToTree(xmlContent)
rightXml.value = serializeTreeToXml(tree, 0, false)
window.$message?.success('导入 XML 并格式化成功')
} catch (err: any) {
rightXml.value = xmlContent
window.$message?.warning('导入成功,但自动格式化失败,请检查 XML 语法')
}
}
reader.onerror = () => {
window.$message?.error('导入 XML 失败')
}
reader.readAsText(file)
}
})
}
const leftEditorRef = ref<any>(null)
const rightEditorRef = ref<any>(null)
const activeNodeId = ref('')
const leftActiveLine = ref<number | null>(null)
const rightActiveLine = ref<number | null>(null)
const computedLeftLineClasses = computed(() => {
if (!leftActiveLine.value) return leftLineClasses.value
return leftLineClasses.value.map((cls, idx) => {
if (idx === leftActiveLine.value! - 1) {
return cls ? `${cls} xml-active-line` : 'xml-active-line'
}
return cls
})
})
const computedRightLineClasses = computed(() => {
if (!rightActiveLine.value) return rightLineClasses.value
return rightLineClasses.value.map((cls, idx) => {
if (idx === rightActiveLine.value! - 1) {
return cls ? `${cls} xml-active-line` : 'xml-active-line'
}
return cls
})
})
const handleXmlLineSelect = (lineNum: number, side: 'left' | 'right') => {
if (!lineNum) return
// 设置触发侧的激活高亮行
if (side === 'left') {
leftActiveLine.value = lineNum
rightActiveLine.value = null
} else {
rightActiveLine.value = lineNum
leftActiveLine.value = null
}
const editorToOriginal = side === 'left' ? leftEditorToOriginal.value : rightEditorToOriginal.value
const origLine = editorToOriginal.get(lineNum)
if (!origLine) return
const nodeRanges = side === 'left' ? leftNodeRanges.value : rightNodeRanges.value
let targetNodeId = ''
let minSpan = Infinity
for (const [nodeId, range] of nodeRanges.entries()) {
if (origLine >= range.startLine && origLine <= range.endLine) {
const span = range.endLine - range.startLine
if (span < minSpan) {
minSpan = span
targetNodeId = nodeId
}
}
}
if (!targetNodeId) return
activeNodeId.value = targetNodeId
// 移除所有旧的高亮
document.querySelectorAll('.render-active-highlight').forEach((el) => {
el.classList.remove('render-active-highlight')
})
// 查找并高亮左侧与右侧排版区域中匹配的元素
const targetLeft = leftRenderContainer.value?.querySelector(`[data-node-id^="${targetNodeId}"]`) as HTMLElement
if (targetLeft) {
targetLeft.classList.add('render-active-highlight')
}
const targetRight = rightRenderContainer.value?.querySelector(`[data-node-id^="${targetNodeId}"]`) as HTMLElement
if (targetRight) {
targetRight.classList.add('render-active-highlight')
}
// 根据触发源,让对应的渲染区域容器进行滚动定位
if (side === 'left' && targetLeft) {
targetLeft.scrollIntoView({ behavior: 'smooth', block: 'center' })
} else if (side === 'right' && targetRight) {
targetRight.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
const handleRenderClick = (e: MouseEvent, side: 'left' | 'right') => {
e.stopPropagation() // 在捕获阶段阻断事件,避免触发子节点的全局选中逻辑
const target = e.target as HTMLElement
const nodeEl = target.closest('[data-node-id]') as HTMLElement
if (nodeEl) {
const fullNodeId = nodeEl.getAttribute('data-node-id')
if (fullNodeId) {
const nodeId = fullNodeId.replace(/_(diff|added|removed|unchanged|modified)/g, '').replace(/-txt-\d+/g, '')
activeNodeId.value = nodeId
// 高亮排版区 DOM
document.querySelectorAll('.render-active-highlight').forEach((el) => {
el.classList.remove('render-active-highlight')
})
document.querySelectorAll(`[data-node-id^="${nodeId}"]`).forEach((el) => {
el.classList.add('render-active-highlight')
})
// 清空旧的 XML 激活高亮行
leftActiveLine.value = null
rightActiveLine.value = null
// 高亮定位 XML 编辑器
console.log('[CompareModal] Clicked side:', side, 'nodeId:', nodeId)
const leftRange = leftNodeRanges.value.get(nodeId)
console.log('[CompareModal] leftRange:', leftRange)
if (leftRange) {
const leftDisplayLine = leftOriginalToEditor.value.get(leftRange.startLine)
console.log('[CompareModal] leftDisplayLine:', leftDisplayLine)
if (leftDisplayLine) {
leftActiveLine.value = leftDisplayLine
leftEditorRef.value?.highlightAndScrollToLine(leftDisplayLine)
}
}
const rightRange = rightNodeRanges.value.get(nodeId)
console.log('[CompareModal] rightRange:', rightRange)
if (rightRange) {
const rightDisplayLine = rightOriginalToEditor.value.get(rightRange.startLine)
console.log('[CompareModal] rightDisplayLine:', rightDisplayLine)
if (rightDisplayLine) {
rightActiveLine.value = rightDisplayLine
rightEditorRef.value?.highlightAndScrollToLine(rightDisplayLine)
}
}
}
}
}
// 模态框打开时就绑定滚动监听,不等待对比
watch(visible, (val) => {
if (val) {
nextTick(() => {
bindScroller()
bindRenderScroller()
})
} else {
leftActiveLine.value = null
rightActiveLine.value = null
activeNodeId.value = ''
leftScrollerEl?.removeEventListener('scroll', updateGutterScroll)
leftScrollerEl = null
rightScrollerEl = null
gutterScrollTop.value = 0
scrollTotalHeight.value = 1
leftScrollerBound.value = false
leftRenderContainer.value?.removeEventListener('scroll', updateRenderScroll)
renderScrollTop.value = 0
renderTotalHeight.value = 1
renderScrollerBound.value = false
if (renderMutationObserver) {
renderMutationObserver.disconnect()
renderMutationObserver = null
}
}
})
// 对比完成后重新绑定(编辑器可能重渲染过)
watch(compared, (val) => {
if (val) {
nextTick(() => {
bindScroller()
bindRenderScroller()
})
}
})
// 排版渲染对比区切换展示时重新计算绑定
watch(showRenderCompare, (val) => {
if (val) {
nextTick(() => {
bindRenderScroller()
})
}
})
onBeforeUnmount(() => {
const leftEl = document.querySelector('.xml-compare-left .cm-scroller') as HTMLElement
const rightEl = document.querySelector('.xml-compare-right .cm-scroller') as HTMLElement
if (leftEl) {
leftEl.removeEventListener('scroll', handleXmlScroll)
}
if (rightEl) {
rightEl.removeEventListener('scroll', handleXmlScroll)
}
leftScrollerEl?.removeEventListener('scroll', updateGutterScroll)
leftRenderContainer.value?.removeEventListener('scroll', updateRenderScroll)
if (renderMutationObserver) {
renderMutationObserver.disconnect()
renderMutationObserver = null
}
})
return {
visible,
leftXml,
rightXml,
leftDisplayXml,
rightDisplayXml,
leftLineClasses,
rightLineClasses,
leftLineNumbers,
rightLineNumbers,
leftRenderTree,
rightRenderTree,
leftNodeRanges,
rightNodeRanges,
leftEditorToOriginal,
rightEditorToOriginal,
leftOriginalToEditor,
rightOriginalToEditor,
compared,
comparing,
showXmlCompare,
showRenderCompare,
leftRenderContainer,
rightRenderContainer,
open,
handleUseCurrent,
handleCompare,
unbindScrollSync,
// 自定义横向滚动
isLeftHorizontalScrollable,
isRightHorizontalScrollable,
isLeftHorizontalDragging,
isRightHorizontalDragging,
leftHorizontalViewportStyle,
rightHorizontalViewportStyle,
handleHorizontalThumbPointerdown,
handleHorizontalScrollbarClick,
// 新增的自定义纵向与渲染纵向滚动暴露
leftScrollerBound,
isScrollable,
isDragging,
viewportStyle,
diffHunks,
handleThumbPointerdown,
handleScrollbarClick,
renderScrollerBound,
isRenderScrollable,
isRenderDragging,
renderDiffHunks,
renderViewportStyle,
handleRenderThumbPointerdown,
handleRenderScrollbarClick,
// 彻底移入的激活高亮及行为联动方法
triggerFileImport,
leftEditorRef,
rightEditorRef,
activeNodeId,
leftActiveLine,
rightActiveLine,
computedLeftLineClasses,
computedRightLineClasses,
handleXmlLineSelect,
handleRenderClick,
handleTextExpandedChange,
handleRenderExpandedChange
}
}
<template> <template>
<div></div> <CommonModal
v-model="visible"
title="工卡版本对比"
fullscreen
:scrollable="false"
:show-confirm="false"
:show-header="false"
cancel-text="关闭"
@after-leave="unbindScrollSync"
>
<div class="flex flex-col flex-1 h-full -mx-4 -my-4 divide-y divide-divider">
<!-- 顶栏工具条:图例与比对开关控制 -->
<div class="flex items-center justify-between px-2 py-1 bg-fill-1 select-none">
<!-- 比对图例 (Legend) -->
<div class="flex items-center gap-4 text-xs font-semibold">
<span class="text-color3">比对图例:</span>
<div class="flex items-center gap-1.5">
<span
class="w-3.5 h-3.5 rounded bg-success/15 border border-success/30 flex items-center justify-center text-success font-bold text-[10px]"
>
+
</span>
<span class="text-success font-medium">新增内容</span>
</div>
<div class="flex items-center gap-1.5">
<span
class="w-3.5 h-3.5 rounded bg-error/15 border border-error/30 flex items-center justify-center text-error font-bold text-[10px]"
>
-
</span>
<span class="text-error font-medium line-through">删除内容</span>
</div>
<div class="flex items-center gap-1.5">
<span
class="w-3.5 h-3.5 rounded bg-warning/15 border border-warning/30 flex items-center justify-center text-warning font-bold text-[10px]"
>
~
</span>
<span class="text-warning font-medium">属性/文本变更</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-3.5 rounded bg-fill-3 border border-divider/60 repeating-stripes"></span>
<span class="text-color3 font-medium">空白对齐占位</span>
</div>
</div>
<!-- 比对模式开关与动作 -->
<div class="flex items-center gap-4">
<span class="text-xs text-color3">上方为 XML 对比,下方为工卡排版样式对比</span>
<div class="flex items-center gap-1.5">
<span class="text-xs text-color2">XML 对比</span>
<n-switch :value="showXmlCompare" size="small" @update:value="handleTextExpandedChange" />
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs text-color2">渲染对比</span>
<n-switch :value="showRenderCompare" size="small" @update:value="handleRenderExpandedChange" />
</div>
<div class="w-px h-4 bg-divider"></div>
<CommonButton type="primary" size="small" :loading="comparing" :disabled="comparing" @click="handleCompare">
<template #icon>
<n-icon><git-compare-outline /></n-icon>
</template>
{{ comparing ? '正在对比中...' : '点击查看对比' }}
</CommonButton>
</div>
</div>
<!-- 主内容区域:双栏展示 -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-fill-2/30 p-4 gap-4 relative">
<!-- 加载遮罩(比对进行中) -->
<transition name="fade">
<div v-if="comparing" class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-card/80 backdrop-blur-sm">
<n-spin size="large" />
<span class="mt-3 text-sm text-color2 font-medium">正在分析对比,请稍候...</span>
</div>
</transition>
<!-- 上方:XML 源代码对比区域 -->
<div
class="flex flex-col compare-text-zone shrink-0 gap-1.5"
:class="{
'is-collapsed': !showXmlCompare,
'expanded-full': !showRenderCompare
}"
>
<div class="flex items-center justify-between text-xs font-bold text-color2 px-1">
<div class="flex items-center gap-2">
<span>源工卡 XML (旧版本)</span>
<CommonButton size="tiny" quaternary type="info" @click="handleUseCurrent">使用当前打开的工卡</CommonButton>
</div>
<div class="flex items-center gap-2">
<span>目标工卡 XML (新版本)</span>
<CommonButton size="tiny" quaternary type="primary" @click="triggerFileImport">选择 XML 文件导入</CommonButton>
</div>
</div>
<div class="flex-1 flex gap-2 min-h-0">
<!-- 左:源工卡 XML(将屏蔽原生竖向滚动条,用自定义滚动条替代) -->
<div class="flex-1 min-w-0 h-full xml-compare-left flex relative">
<div class="flex-1 min-w-0 h-full">
<CommonXmlEditor
ref="leftEditorRef"
v-model="leftXml"
height="100%"
min-height="100%"
:line-classes="computedLeftLineClasses"
:line-numbers="leftLineNumbers"
@line-select="handleXmlLineSelect($event, 'left')"
/>
</div>
<!-- 自定义滚动条(左) -->
<div
class="flex-shrink-0 w-3 self-stretch relative cursor-pointer select-none custom-scrollbar-track-v"
@click="handleScrollbarClick($event, 'left')"
>
<!-- diff 差异标记(仅对比后显示) -->
<template v-if="compared">
<div
v-for="(hunk, i) in diffHunks"
:key="i"
class="absolute left-0.5 right-0.5 rounded-sm"
:style="[
hunk.style,
{ background: hunk.type === 'removed' ? 'rgba(239,83,80,0.8)' : 'rgba(102,187,106,0.8)' }
]"
/>
</template>
<!-- 可拖拽视口 thumb(有内容可滚动时才显示) -->
<div
v-if="compared && leftScrollerBound && isScrollable"
class="absolute left-0 right-0 rounded-sm custom-scrollbar-thumb-v"
:class="isDragging ? 'cursor-grabbing grabbing' : 'cursor-grab transition-[top,height] duration-75'"
:style="[viewportStyle]"
@pointerdown.stop.prevent="handleThumbPointerdown"
@click.stop
/>
</div>
<!-- 自定义横向滚动条(左) -->
<div
v-if="isLeftHorizontalScrollable"
class="absolute bottom-0 left-0 right-3 h-2 z-10 cursor-pointer select-none border-t border-divider custom-scrollbar-track-h"
@click="handleHorizontalScrollbarClick($event, 'left')"
>
<div
class="absolute top-0.5 bottom-0.5 rounded-sm custom-scrollbar-thumb-h"
:class="isLeftHorizontalDragging ? 'cursor-grabbing grabbing' : 'cursor-grab transition-[left,width] duration-75'"
:style="[leftHorizontalViewportStyle]"
@pointerdown.stop.prevent="handleHorizontalThumbPointerdown($event, 'left')"
@click.stop
/>
</div>
</div>
<!-- 右:目标工卡 XML(同上) -->
<div class="flex-1 min-w-0 h-full xml-compare-right flex relative">
<div class="flex-1 min-w-0 h-full">
<CommonXmlEditor
ref="rightEditorRef"
v-model="rightXml"
height="100%"
min-height="100%"
:line-classes="computedRightLineClasses"
:line-numbers="rightLineNumbers"
@line-select="handleXmlLineSelect($event, 'right')"
/>
</div>
<!-- 自定义滚动条(右) -->
<div
class="flex-shrink-0 w-3 self-stretch relative cursor-pointer select-none custom-scrollbar-track-v"
@click="handleScrollbarClick($event, 'right')"
>
<!-- diff 差异标记(仅对比后显示) -->
<template v-if="compared">
<div
v-for="(hunk, i) in diffHunks"
:key="i"
class="absolute left-0.5 right-0.5 rounded-sm"
:style="[
hunk.style,
{ background: hunk.type === 'removed' ? 'rgba(239,83,80,0.8)' : 'rgba(102,187,106,0.8)' }
]"
/>
</template>
<!-- 可拖拽视口 thumb(有内容可滚动时才显示) -->
<div
v-if="compared && leftScrollerBound && isScrollable"
class="absolute left-0 right-0 rounded-sm custom-scrollbar-thumb-v"
:class="isDragging ? 'cursor-grabbing grabbing' : 'cursor-grab transition-[top,height] duration-75'"
:style="[viewportStyle]"
@pointerdown.stop.prevent="handleThumbPointerdown"
@click.stop
/>
</div>
<!-- 自定义横向滚动条(右) -->
<div
v-if="isRightHorizontalScrollable"
class="absolute bottom-0 left-0 right-3 h-2 z-10 cursor-pointer select-none border-t border-divider custom-scrollbar-track-h"
@click="handleHorizontalScrollbarClick($event, 'right')"
>
<div
class="absolute top-0.5 bottom-0.5 rounded-sm custom-scrollbar-thumb-h"
:class="
isRightHorizontalDragging ? 'cursor-grabbing grabbing' : 'cursor-grab transition-[left,width] duration-75'
"
:style="[rightHorizontalViewportStyle]"
@pointerdown.stop.prevent="handleHorizontalThumbPointerdown($event, 'right')"
@click.stop
/>
</div>
</div>
</div>
</div>
<!-- 下方:排版样式渲染对比区域 -->
<div
class="flex-grow flex flex-col compare-render-zone min-h-0 gap-1.5"
:class="{
'is-collapsed': !showRenderCompare
}"
>
<div class="flex items-center justify-between text-xs font-bold text-color2 px-1 shrink-0">
<span>源工卡 渲染 (旧版本)</span>
<span>目标工卡 渲染 (新版本)</span>
</div>
<div class="flex-grow flex gap-4 min-h-0">
<!-- 左:源工卡渲染 -->
<div class="flex-1 min-w-0 h-full flex border border-divider rounded-xl bg-card overflow-hidden">
<div
ref="leftRenderContainer"
class="flex-1 min-w-0 h-full overflow-y-auto p-4 hide-scrollbar relative"
@click.capture="handleRenderClick($event, 'left')"
>
<template v-if="compared && leftRenderTree">
<DocNodeRenderer :node="leftRenderTree" :parent="null" />
</template>
<template v-else>
<div class="absolute inset-0 flex flex-col items-center justify-center text-color3 text-xs select-none">
<n-icon size="36" class="opacity-20 mb-2"><git-compare-outline /></n-icon>
<span>暂无原工卡数据,请在上方框中输入或导入</span>
</div>
</template>
</div>
<!-- 自定义滚动条(左) -->
<div
class="flex-shrink-0 w-3 self-stretch relative cursor-pointer select-none custom-scrollbar-track-v"
@click="handleRenderScrollbarClick($event)"
>
<!-- diff 差异标记 -->
<template v-if="compared">
<div
v-for="(hunk, i) in renderDiffHunks"
:key="i"
class="absolute left-0.5 right-0.5 rounded-sm"
:style="[
hunk.style,
{
background:
hunk.type === 'removed'
? 'rgba(239,83,80,0.8)'
: hunk.type === 'added'
? 'rgba(102,187,106,0.8)'
: 'rgba(251,191,36,0.8)'
}
]"
/>
</template>
<!-- 可拖拽视口 thumb -->
<div
v-if="compared && renderScrollerBound && isRenderScrollable"
class="absolute left-0 right-0 rounded-sm custom-scrollbar-thumb-v"
:class="isRenderDragging ? 'cursor-grabbing grabbing' : 'cursor-grab transition-[top,height] duration-75'"
:style="[renderViewportStyle]"
@pointerdown.stop.prevent="handleRenderThumbPointerdown"
@click.stop
/>
</div>
</div>
<!-- 右:目标工卡渲染 -->
<div class="flex-1 min-w-0 h-full flex border border-divider rounded-xl bg-card overflow-hidden">
<div
ref="rightRenderContainer"
class="flex-1 min-w-0 h-full overflow-y-auto p-4 hide-scrollbar relative"
@click.capture="handleRenderClick($event, 'right')"
>
<template v-if="compared && rightRenderTree">
<DocNodeRenderer :node="rightRenderTree" :parent="null" />
</template>
<template v-else>
<div class="absolute inset-0 flex flex-col items-center justify-center text-color3 text-xs select-none">
<n-icon size="36" class="opacity-20 mb-2"><git-compare-outline /></n-icon>
<span>暂无目标工卡数据,请在上方框中输入或导入</span>
</div>
</template>
</div>
<!-- 自定义滚动条(右) -->
<div
class="flex-shrink-0 w-3 self-stretch relative cursor-pointer select-none custom-scrollbar-track-v"
@click="handleRenderScrollbarClick($event)"
>
<!-- diff 差异标记 -->
<template v-if="compared">
<div
v-for="(hunk, i) in renderDiffHunks"
:key="i"
class="absolute left-0.5 right-0.5 rounded-sm"
:style="[
hunk.style,
{
background:
hunk.type === 'removed'
? 'rgba(239,83,80,0.8)'
: hunk.type === 'added'
? 'rgba(102,187,106,0.8)'
: 'rgba(251,191,36,0.8)'
}
]"
/>
</template>
<!-- 可拖拽视口 thumb -->
<div
v-if="compared && renderScrollerBound && isRenderScrollable"
class="absolute left-0 right-0 rounded-sm custom-scrollbar-thumb-v"
:class="isRenderDragging ? 'cursor-grabbing grabbing' : 'cursor-grab transition-[top,height] duration-75'"
:style="[renderViewportStyle]"
@pointerdown.stop.prevent="handleRenderThumbPointerdown"
@click.stop
/>
</div>
</div>
</div>
</div>
</div>
</div>
</CommonModal>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts">
<style lang="less" scoped></style> import { GitCompareOutline } from '@vicons/ionicons5'
import DocNodeRenderer from '../../../DocNodeRenderer/index.vue'
import { useCompareModal } from './functionals'
const {
visible,
leftXml,
rightXml,
leftDisplayXml,
rightDisplayXml,
leftLineClasses,
rightLineClasses,
leftLineNumbers,
rightLineNumbers,
leftRenderTree,
rightRenderTree,
leftNodeRanges,
rightNodeRanges,
leftEditorToOriginal,
rightEditorToOriginal,
leftOriginalToEditor,
rightOriginalToEditor,
compared,
comparing,
showXmlCompare,
showRenderCompare,
leftRenderContainer,
rightRenderContainer,
open,
handleUseCurrent,
handleCompare,
unbindScrollSync,
// 自定义横向滚动
isLeftHorizontalScrollable,
isRightHorizontalScrollable,
isLeftHorizontalDragging,
isRightHorizontalDragging,
leftHorizontalViewportStyle,
rightHorizontalViewportStyle,
handleHorizontalThumbPointerdown,
handleHorizontalScrollbarClick,
// 自定义纵向与渲染纵向滚动
leftScrollerBound,
isScrollable,
isDragging,
viewportStyle,
diffHunks,
handleThumbPointerdown,
handleScrollbarClick,
renderScrollerBound,
isRenderScrollable,
isRenderDragging,
renderDiffHunks,
renderViewportStyle,
handleRenderThumbPointerdown,
handleRenderScrollbarClick,
// 激活高亮行、定位联动和导入等操作
triggerFileImport,
leftEditorRef,
rightEditorRef,
computedLeftLineClasses,
computedRightLineClasses,
handleXmlLineSelect,
handleRenderClick,
handleTextExpandedChange,
handleRenderExpandedChange
} = useCompareModal()
// 注入只读对比模式状态
provide('diffMode', true)
defineExpose({
open
})
</script>
<style lang="less" scoped>
// 加载遮罩淡入淡出
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// 物理对齐的空白占位条纹背景
.repeating-stripes {
background-image: repeating-linear-gradient(-45deg, transparent, transparent 3px, rgba(0, 0, 0, 0.08) 3px, rgba(0, 0, 0, 0.08) 6px);
}
// 滚动条样式定义
.custom-scrollbar {
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--n-scrollbar-color, rgba(0, 0, 0, 0.15));
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--n-scrollbar-color-hover, rgba(0, 0, 0, 0.3));
}
}
.hide-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
// 源码比对自定义滚动轴(纵向/横向)样式实现,使用系统内置的主题色变量自适应,拒绝硬编码
.custom-scrollbar-track-v {
background: rgba(128, 128, 128, 0.05);
border-left: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.15));
}
.custom-scrollbar-track-h {
background: rgba(128, 128, 128, 0.03);
}
.custom-scrollbar-thumb-v,
.custom-scrollbar-thumb-h {
background: var(--n-scrollbar-color, rgba(128, 128, 128, 0.25)) !important;
border: 1px solid var(--n-scrollbar-color, rgba(128, 128, 128, 0.3)) !important;
border-radius: 3px;
transition:
background 0.1s ease,
border-color 0.1s ease;
}
.custom-scrollbar-thumb-v:hover,
.custom-scrollbar-thumb-v.grabbing,
.custom-scrollbar-thumb-h:hover,
.custom-scrollbar-thumb-h.grabbing {
background: var(--n-scrollbar-color-hover, rgba(128, 128, 128, 0.45)) !important;
border-color: var(--n-scrollbar-color-hover, rgba(128, 128, 128, 0.5)) !important;
}
// 隐藏 CodeMirror 原生竖向滚动条(用自定义滚动条替代)
.xml-compare-left,
.xml-compare-right {
:deep(.cm-scroller) {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
// 深度修改 CodeMirror 和渲染的比对样式
:deep(.cm-editor) {
border-radius: 8px;
overflow: hidden;
}
// 差异行高亮
:deep(.diff-removed) {
background-color: rgba(239, 83, 80, 0.12) !important;
}
:deep(.diff-added) {
background-color: rgba(102, 187, 106, 0.12) !important;
}
// 差异空白对齐高亮
:deep(.diff-removed-placeholder) {
background-color: rgba(239, 83, 80, 0.04) !important;
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(239, 83, 80, 0.08) 4px,
rgba(239, 83, 80, 0.08) 8px
) !important;
}
:deep(.diff-added-placeholder) {
background-color: rgba(102, 187, 106, 0.04) !important;
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(102, 187, 106, 0.08) 4px,
rgba(102, 187, 106, 0.08) 8px
) !important;
}
// 排版渲染结果比对样式
:deep([data-diff-status='added']) {
background-color: rgba(102, 187, 106, 0.08);
outline: 1.5px dashed rgba(102, 187, 106, 0.45) !important;
outline-offset: 1px;
position: relative;
border-radius: 4px;
&::before {
content: '+';
position: absolute;
left: 2px;
top: 2px;
color: #2e7d32;
font-weight: bold;
font-size: 11px;
line-height: 1;
z-index: 10;
background-color: rgba(102, 187, 106, 0.2);
padding: 1px 3px;
border-radius: 2px;
}
}
:deep([data-diff-status='removed']) {
background-color: rgba(239, 83, 80, 0.08);
outline: 1.5px dashed rgba(239, 83, 80, 0.45) !important;
outline-offset: 1px;
text-decoration: line-through;
opacity: 0.72;
position: relative;
border-radius: 4px;
&::before {
content: '-';
position: absolute;
left: 2px;
top: 2px;
color: #c62828;
font-weight: bold;
font-size: 11px;
line-height: 1;
z-index: 10;
background-color: rgba(239, 83, 80, 0.2);
padding: 1px 4px;
border-radius: 2px;
}
}
:deep([data-diff-status='modified']) {
background-color: rgba(255, 193, 7, 0.06);
outline: 1.5px dashed rgba(255, 193, 7, 0.4) !important;
outline-offset: 1px;
border-radius: 4px;
}
// 隐藏空白对齐占位节点
:deep([data-diff-status='added-placeholder']),
:deep([data-diff-status='removed-placeholder']) {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
* {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
// 单词级高亮显示
:deep(.text-success) {
color: #2e7d32 !important;
background-color: rgba(102, 187, 106, 0.15) !important;
font-weight: 600;
padding: 0 2px;
border-radius: 2px;
}
:deep(.text-error) {
color: #c62828 !important;
background-color: rgba(239, 83, 80, 0.15) !important;
text-decoration: line-through;
font-weight: 600;
padding: 0 2px;
border-radius: 2px;
}
// 选中排版节点双向联动高亮样式
:deep(.render-active-highlight) {
outline: 2px dashed var(--primary-color, #165dff) !important;
outline-offset: 1px;
background-color: color-mix(in srgb, var(--primary-color, #165dff) 8%, transparent) !important;
transition:
outline-color 0.2s ease,
background-color 0.2s ease;
border-radius: 4px;
}
// 选中 XML 行高亮样式
:deep(.xml-active-line) {
background-color: color-mix(in srgb, var(--primary-color, #165dff) 12%, transparent) !important;
position: relative;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: var(--primary-color, #165dff);
z-index: 5;
}
}
/* XML 文本区默认样式与展开/收起过渡 */
.compare-text-zone {
flex: 1 1 0% !important;
min-height: 180px !important;
max-height: 320px !important;
margin-bottom: 0px !important;
opacity: 1 !important;
overflow: hidden;
/* 展开过渡:弹性分配比和高度立即变化,不透明度延迟 0.15s 淡入 */
transition:
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
min-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
opacity 0.2s linear 0.15s !important;
}
/* 当渲染区收起时,文本区独占全部剩余高度 */
.compare-text-zone.expanded-full {
flex: 1 1 0% !important;
min-height: 0 !important;
max-height: 100% !important;
}
/* 当文本区收起时:不透明度立即淡出,尺寸延迟 0.08s 收折 */
.compare-text-zone.is-collapsed {
flex: 0 0 0% !important;
max-height: 0 !important;
min-height: 0 !important;
opacity: 0 !important;
pointer-events: none;
transition:
opacity 0.15s linear 0s,
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s,
min-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s !important;
}
/* 渲染区默认样式与展开/收起过渡 */
.compare-render-zone {
flex: 2 1 0% !important;
min-height: 0 !important;
max-height: 2000px !important;
opacity: 1 !important;
overflow: hidden;
/* 展开过渡:弹性大小和高度立即变化,不透明度延迟 0.15s 淡入 */
transition:
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
opacity 0.2s linear 0.15s !important;
}
/* 当渲染区收起时:不透明度立即淡出,弹性大小延迟 0.08s 收折 */
.compare-render-zone.is-collapsed {
flex: 0 0 0% !important;
max-height: 0 !important;
opacity: 0 !important;
pointer-events: none;
transition:
opacity 0.15s linear 0s,
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s !important;
}
</style>
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