Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
I
Ifar-Xml-Edtor
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
pangchong
Ifar-Xml-Edtor
Commits
f4b7cfd2
Commit
f4b7cfd2
authored
Jul 17, 2026
by
pangchong
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 重新写工卡对比
parent
a65ea933
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
3 additions
and
1939 deletions
+3
-1939
src/views/editor/components/EditorToolbar/components/CompareModal/constants/index.ts
+0
-24
src/views/editor/components/EditorToolbar/components/CompareModal/functionals/index.ts
+0
-1213
src/views/editor/components/EditorToolbar/components/CompareModal/index.vue
+3
-702
No files found.
src/views/editor/components/EditorToolbar/components/CompareModal/constants/index.ts
View file @
f4b7cfd2
export
interface
DiffMixedContentItem
{
type
:
'text'
|
'element'
text
?:
string
nodeId
?:
string
diffStatus
?:
'added'
|
'removed'
|
'modified'
|
'none'
diffWords
?:
{
value
:
string
;
added
?:
boolean
;
removed
?:
boolean
}[]
}
export
interface
DiffXmlNode
{
id
:
string
tagName
:
string
attributes
:
Record
<
string
,
string
>
children
:
DiffXmlNode
[]
textContent
:
string
mixedContent
:
DiffMixedContentItem
[]
parentId
:
string
|
null
diffStatus
:
'added'
|
'removed'
|
'modified'
|
'none'
}
export
interface
DiffLine
{
type
:
'added'
|
'removed'
|
'normal'
|
'empty'
num
?:
number
content
:
string
}
src/views/editor/components/EditorToolbar/components/CompareModal/functionals/index.ts
View file @
f4b7cfd2
import
{
type
DiffXmlNode
,
type
DiffMixedContentItem
,
type
DiffLine
}
from
'../constants'
import
type
{
XmlNode
}
from
'@/types/xmlNode'
import
*
as
Diff
from
'diff'
import
{
useEditorStore
}
from
'@/store/editor'
import
{
serializeTreeToXml
,
formatXmlText
}
from
'@/utils/xmlParser'
import
{
openUploadModal
}
from
'@/utils/render'
import
{
EFFECT_METADATA_TAGS
}
from
'@/configs/xmlTags'
export
function
useCompareModal
()
{
const
editorStore
=
useEditorStore
()
const
showModal
=
ref
(
false
)
const
loading
=
ref
(
false
)
const
oldXmlText
=
ref
(
''
)
const
newXmlText
=
ref
(
''
)
const
oldDiffTree
=
ref
<
DiffXmlNode
|
null
>
(
null
)
const
newDiffTree
=
ref
<
DiffXmlNode
|
null
>
(
null
)
// 支持 XML 文本差异和编辑状态控制
const
isEditingOld
=
ref
(
true
)
const
isEditingNew
=
ref
(
true
)
const
oldDiffLines
=
ref
<
DiffLine
[]
>
([])
const
newDiffLines
=
ref
<
DiffLine
[]
>
([])
const
editingOldRange
=
ref
<
{
start
:
number
;
end
:
number
}
|
null
>
(
null
)
const
editingNewRange
=
ref
<
{
start
:
number
;
end
:
number
}
|
null
>
(
null
)
const
editingOldRangeText
=
ref
(
''
)
const
editingNewRangeText
=
ref
(
''
)
const
updateTextDiff
=
()
=>
{
const
{
leftLines
,
rightLines
}
=
generateDoubleFolderDiff
(
oldXmlText
.
value
,
newXmlText
.
value
)
oldDiffLines
.
value
=
leftLines
newDiffLines
.
value
=
rightLines
}
// 滚动条同步逻辑(XML文本 + 工卡渲染,四向强联同步)
const
leftScrollContainer
=
ref
<
HTMLElement
|
null
>
(
null
)
const
rightScrollContainer
=
ref
<
HTMLElement
|
null
>
(
null
)
const
leftTextScrollContainer
=
ref
<
HTMLElement
|
null
>
(
null
)
const
rightTextScrollContainer
=
ref
<
HTMLElement
|
null
>
(
null
)
let
scrollSource
:
HTMLElement
|
null
=
null
let
clearSourceTimer
:
any
=
null
const
handleScroll
=
(
source
:
HTMLElement
,
targets
:
(
HTMLElement
|
null
)[])
=>
{
if
(
scrollSource
&&
scrollSource
!==
source
)
return
scrollSource
=
source
if
(
clearSourceTimer
)
clearTimeout
(
clearSourceTimer
)
const
ratio
=
source
.
scrollHeight
-
source
.
clientHeight
>
0
?
source
.
scrollTop
/
(
source
.
scrollHeight
-
source
.
clientHeight
)
:
0
for
(
const
target
of
targets
)
{
if
(
target
&&
target
!==
source
)
{
const
targetMax
=
target
.
scrollHeight
-
target
.
clientHeight
target
.
scrollTop
=
Math
.
round
(
ratio
*
targetMax
)
}
}
clearSourceTimer
=
setTimeout
(()
=>
{
scrollSource
=
null
},
80
)
}
const
handleLeftScroll
=
()
=>
{
if
(
!
leftScrollContainer
.
value
)
return
handleScroll
(
leftScrollContainer
.
value
,
[
rightScrollContainer
.
value
])
}
const
handleRightScroll
=
()
=>
{
if
(
!
rightScrollContainer
.
value
)
return
handleScroll
(
rightScrollContainer
.
value
,
[
leftScrollContainer
.
value
])
}
const
handleLeftTextScroll
=
()
=>
{
if
(
!
leftTextScrollContainer
.
value
)
return
handleScroll
(
leftTextScrollContainer
.
value
,
[
rightTextScrollContainer
.
value
])
}
const
handleRightTextScroll
=
()
=>
{
if
(
!
rightTextScrollContainer
.
value
)
return
handleScroll
(
rightTextScrollContainer
.
value
,
[
leftTextScrollContainer
.
value
])
}
// 载入当前编辑器 XML
const
loadCurrentXml
=
()
=>
{
if
(
!
editorStore
.
xmlTree
)
{
window
.
$message
.
warning
(
'当前编辑器内无正在编辑的工卡'
)
return
}
oldXmlText
.
value
=
serializeTreeToXml
(
editorStore
.
xmlTree
,
0
,
false
)
window
.
$message
.
success
(
'已自动载入当前正在编辑的工卡 XML'
)
updateTextDiff
()
}
// 重新开始对比
const
resetDiff
=
()
=>
{
oldDiffTree
.
value
=
null
newDiffTree
.
value
=
null
editingOldRange
.
value
=
null
editingNewRange
.
value
=
null
}
const
open
=
(
currentXml
?:
string
)
=>
{
showModal
.
value
=
true
loading
.
value
=
false
oldXmlText
.
value
=
currentXml
||
''
newXmlText
.
value
=
''
oldDiffTree
.
value
=
null
newDiffTree
.
value
=
null
isEditingOld
.
value
=
true
isEditingNew
.
value
=
true
oldDiffLines
.
value
=
[]
newDiffLines
.
value
=
[]
editingOldRange
.
value
=
null
editingNewRange
.
value
=
null
// 自动拉取当前编辑器内容填充为旧版本
if
(
editorStore
.
xmlTree
)
{
oldXmlText
.
value
=
serializeTreeToXml
(
editorStore
.
xmlTree
,
0
,
false
)
}
updateTextDiff
()
}
const
handleCompare
=
async
(
showWarning
=
false
)
=>
{
if
(
!
oldXmlText
.
value
.
trim
()
||
!
newXmlText
.
value
.
trim
())
{
if
(
showWarning
)
{
window
.
$message
.
warning
(
'请确保两份工卡的 XML 内容都不为空'
)
}
return
}
loading
.
value
=
true
try
{
// 解析 XML 树
const
oldTree
=
parseXmlToTree
(
oldXmlText
.
value
)
const
newTree
=
parseXmlToTree
(
newXmlText
.
value
)
// 比对并生成左右两侧差异树
oldDiffTree
.
value
=
buildOldDiffTree
(
oldTree
,
newTree
)
newDiffTree
.
value
=
buildNewDiffTree
(
oldTree
,
newTree
)
window
.
$message
.
success
(
'对比成功,已生成差异视图'
)
}
catch
(
err
:
any
)
{
window
.
$message
.
error
(
'XML 解析或对比失败: '
+
err
.
message
)
oldDiffTree
.
value
=
null
newDiffTree
.
value
=
null
}
finally
{
loading
.
value
=
false
}
}
const
handleXmlImportClick
=
()
=>
{
openUploadModal
({
title
:
'导入目标工卡 XML'
,
accept
:
'.xml'
,
uploadFunc
:
(
file
:
File
)
=>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
(
e
)
=>
{
resolve
(
e
.
target
?.
result
as
string
)
}
reader
.
onerror
=
(
err
)
=>
reject
(
err
)
reader
.
readAsText
(
file
)
})
},
onSuccess
:
(
res
:
string
)
=>
{
newXmlText
.
value
=
formatXmlText
(
res
)
updateTextDiff
()
}
})
}
const
leftParentContainer
=
ref
<
HTMLElement
|
null
>
(
null
)
const
rightParentContainer
=
ref
<
HTMLElement
|
null
>
(
null
)
const
editingOldLineNum
=
ref
<
number
|
null
>
(
null
)
const
editingNewLineNum
=
ref
<
number
|
null
>
(
null
)
const
editingOldText
=
ref
(
''
)
const
editingNewText
=
ref
(
''
)
// 监听切换编辑模式时的滚动条定位还原,防止滚回顶部
watch
(
isEditingOld
,
(
newVal
)
=>
{
if
(
newVal
)
{
const
scrollVal
=
leftTextScrollContainer
.
value
?
leftTextScrollContainer
.
value
.
scrollTop
:
0
nextTick
(()
=>
{
const
textarea
=
leftParentContainer
.
value
?.
querySelector
(
'textarea'
)
if
(
textarea
)
textarea
.
scrollTop
=
scrollVal
})
}
else
{
const
textarea
=
leftParentContainer
.
value
?.
querySelector
(
'textarea'
)
const
scrollVal
=
textarea
?
textarea
.
scrollTop
:
0
nextTick
(()
=>
{
if
(
leftTextScrollContainer
.
value
)
{
leftTextScrollContainer
.
value
.
scrollTop
=
scrollVal
}
})
}
})
watch
(
isEditingNew
,
(
newVal
)
=>
{
if
(
newVal
)
{
const
scrollVal
=
rightTextScrollContainer
.
value
?
rightTextScrollContainer
.
value
.
scrollTop
:
0
nextTick
(()
=>
{
const
textarea
=
rightParentContainer
.
value
?.
querySelector
(
'textarea'
)
if
(
textarea
)
textarea
.
scrollTop
=
scrollVal
})
}
else
{
const
textarea
=
rightParentContainer
.
value
?.
querySelector
(
'textarea'
)
const
scrollVal
=
textarea
?
textarea
.
scrollTop
:
0
nextTick
(()
=>
{
if
(
rightTextScrollContainer
.
value
)
{
rightTextScrollContainer
.
value
.
scrollTop
=
scrollVal
}
})
}
})
const
startEditLine
=
(
line
:
any
,
isNewSide
:
boolean
)
=>
{
if
(
line
.
type
===
'empty'
||
!
line
.
num
)
return
if
(
isNewSide
)
{
editingNewLineNum
.
value
=
line
.
num
editingNewText
.
value
=
line
.
content
nextTick
(()
=>
{
const
input
=
document
.
getElementById
(
`new-line-input-
${
line
.
num
}
`
)
as
HTMLInputElement
if
(
input
)
input
.
focus
()
})
}
else
{
editingOldLineNum
.
value
=
line
.
num
editingOldText
.
value
=
line
.
content
nextTick
(()
=>
{
const
input
=
document
.
getElementById
(
`old-line-input-
${
line
.
num
}
`
)
as
HTMLInputElement
if
(
input
)
input
.
focus
()
})
}
}
const
saveEditLine
=
(
isNewSide
:
boolean
)
=>
{
if
(
isNewSide
)
{
if
(
editingNewLineNum
.
value
===
null
)
return
const
lines
=
newXmlText
.
value
.
split
(
'
\
n'
)
lines
[
editingNewLineNum
.
value
-
1
]
=
editingNewText
.
value
newXmlText
.
value
=
lines
.
join
(
'
\
n'
)
// 立即执行一次同步比对,防止防抖延迟导致页面文字旧值闪烁
updateTextDiff
()
handleCompare
()
editingNewLineNum
.
value
=
null
}
else
{
if
(
editingOldLineNum
.
value
===
null
)
return
const
lines
=
oldXmlText
.
value
.
split
(
'
\
n'
)
lines
[
editingOldLineNum
.
value
-
1
]
=
editingOldText
.
value
oldXmlText
.
value
=
lines
.
join
(
'
\
n'
)
// 立即执行一次同步比对,防止防抖延迟导致页面文字旧值闪烁
updateTextDiff
()
handleCompare
()
editingOldLineNum
.
value
=
null
}
}
const
cancelEditLine
=
(
isNewSide
:
boolean
)
=>
{
if
(
isNewSide
)
{
editingNewLineNum
.
value
=
null
}
else
{
editingOldLineNum
.
value
=
null
}
}
const
handleOldXmlBlur
=
()
=>
{
oldXmlText
.
value
=
formatXmlText
(
oldXmlText
.
value
)
updateTextDiff
()
}
const
handleNewXmlBlur
=
()
=>
{
newXmlText
.
value
=
formatXmlText
(
newXmlText
.
value
)
updateTextDiff
()
}
const
getSelectedLineRange
=
(
isNewSide
:
boolean
):
{
start
:
number
;
end
:
number
}
|
null
=>
{
const
selection
=
window
.
getSelection
()
if
(
!
selection
||
selection
.
isCollapsed
||
selection
.
rangeCount
===
0
)
return
null
const
range
=
selection
.
getRangeAt
(
0
)
const
getLineNumFromNode
=
(
node
:
Node
|
null
):
number
|
null
=>
{
let
el
=
node
as
HTMLElement
|
null
if
(
node
&&
node
.
nodeType
===
Node
.
TEXT_NODE
)
{
el
=
node
.
parentElement
}
while
(
el
)
{
if
(
el
.
classList
&&
el
.
classList
.
contains
(
'diff-line-row'
))
{
const
numAttr
=
el
.
getAttribute
(
'data-line-num'
)
if
(
numAttr
)
{
const
num
=
parseInt
(
numAttr
,
10
)
if
(
!
isNaN
(
num
))
return
num
}
}
el
=
el
.
parentElement
}
return
null
}
const
startNum
=
getLineNumFromNode
(
range
.
startContainer
)
const
endNum
=
getLineNumFromNode
(
range
.
endContainer
)
if
(
startNum
!==
null
&&
endNum
!==
null
)
{
return
{
start
:
Math
.
min
(
startNum
,
endNum
),
end
:
Math
.
max
(
startNum
,
endNum
)
}
}
return
null
}
const
toggleEditOld
=
()
=>
{
if
(
isEditingOld
.
value
)
{
isEditingOld
.
value
=
false
return
}
if
(
editingOldRange
.
value
)
{
saveEditRange
(
false
)
}
else
{
const
range
=
getSelectedLineRange
(
false
)
if
(
range
)
{
editingOldRange
.
value
=
range
const
lines
=
oldXmlText
.
value
.
split
(
'
\
n'
)
editingOldRangeText
.
value
=
lines
.
slice
(
range
.
start
-
1
,
range
.
end
).
join
(
'
\
n'
)
nextTick
(()
=>
{
const
el
=
document
.
getElementById
(
`old-range-input-
${
range
.
start
}
`
)
el
?.
focus
()
})
}
else
{
window
.
$message
.
warning
(
'请先在上方 XML 文本对比区用鼠标划词选中需要编辑的区域'
)
}
}
}
const
toggleEditNew
=
()
=>
{
if
(
isEditingNew
.
value
)
{
isEditingNew
.
value
=
false
return
}
if
(
editingNewRange
.
value
)
{
saveEditRange
(
true
)
}
else
{
const
range
=
getSelectedLineRange
(
true
)
if
(
range
)
{
editingNewRange
.
value
=
range
const
lines
=
newXmlText
.
value
.
split
(
'
\
n'
)
editingNewRangeText
.
value
=
lines
.
slice
(
range
.
start
-
1
,
range
.
end
).
join
(
'
\
n'
)
nextTick
(()
=>
{
const
el
=
document
.
getElementById
(
`new-range-input-
${
range
.
start
}
`
)
el
?.
focus
()
})
}
else
{
window
.
$message
.
warning
(
'请先在上方 XML 文本对比区用鼠标划词选中需要编辑的区域'
)
}
}
}
const
saveEditRange
=
(
isNewSide
:
boolean
)
=>
{
if
(
isNewSide
)
{
if
(
!
editingNewRange
.
value
)
return
const
lines
=
newXmlText
.
value
.
split
(
'
\
n'
)
const
startIdx
=
editingNewRange
.
value
.
start
-
1
const
endIdx
=
editingNewRange
.
value
.
end
-
1
const
newSubLines
=
editingNewRangeText
.
value
.
split
(
'
\
n'
)
lines
.
splice
(
startIdx
,
endIdx
-
startIdx
+
1
,
...
newSubLines
)
newXmlText
.
value
=
lines
.
join
(
'
\
n'
)
editingNewRange
.
value
=
null
updateTextDiff
()
handleCompare
()
}
else
{
if
(
!
editingOldRange
.
value
)
return
const
lines
=
oldXmlText
.
value
.
split
(
'
\
n'
)
const
startIdx
=
editingOldRange
.
value
.
start
-
1
const
endIdx
=
editingOldRange
.
value
.
end
-
1
const
newSubLines
=
editingOldRangeText
.
value
.
split
(
'
\
n'
)
lines
.
splice
(
startIdx
,
endIdx
-
startIdx
+
1
,
...
newSubLines
)
oldXmlText
.
value
=
lines
.
join
(
'
\
n'
)
editingOldRange
.
value
=
null
updateTextDiff
()
handleCompare
()
}
}
const
cancelEditRange
=
(
isNewSide
:
boolean
)
=>
{
if
(
isNewSide
)
{
editingNewRange
.
value
=
null
}
else
{
editingOldRange
.
value
=
null
}
}
// 实时防抖比对与状态切换侦听
const
debounce
=
<
T
extends
(...
args
:
any
[])
=>
any
>
(
fn
:
T
,
delay
:
number
)
=>
{
let
timer
:
any
=
null
return
function
(
this
:
any
,
...
args
:
Parameters
<
T
>
)
{
if
(
timer
)
clearTimeout
(
timer
)
timer
=
setTimeout
(()
=>
{
fn
.
apply
(
this
,
args
)
},
delay
)
}
}
watch
(
[
oldXmlText
,
newXmlText
],
debounce
(()
=>
{
updateTextDiff
()
handleCompare
()
// 核心交互:如果两边都有值,自动将显示状态切为 Diff 高亮
if
(
oldXmlText
.
value
.
trim
()
&&
newXmlText
.
value
.
trim
())
{
isEditingOld
.
value
=
false
isEditingNew
.
value
=
false
}
else
{
if
(
!
oldXmlText
.
value
.
trim
())
isEditingOld
.
value
=
true
if
(
!
newXmlText
.
value
.
trim
())
isEditingNew
.
value
=
true
}
},
300
),
{
immediate
:
true
}
)
// 寻找下方对应标签和文本最相似的 DOM 节点并高亮(空间相对比例对齐法)
const
highlightTargetNode
=
(
line
:
DiffLine
,
isNewSide
:
boolean
)
=>
{
const
lineContent
=
line
?.
content
if
(
!
lineContent
||
lineContent
.
trim
().
startsWith
(
'<!--'
))
return
// 1. 提取出当前行的标签名 (支持开始与自闭合)
const
tagMatch
=
lineContent
.
match
(
/<
\/?([
a-zA-Z0-9
\-]
+
)
/
)
if
(
!
tagMatch
)
return
const
lineTagName
=
tagMatch
[
1
].
toUpperCase
()
// 2. 确定我们要搜索的容器
const
container
=
isNewSide
?
rightScrollContainer
.
value
:
leftScrollContainer
.
value
if
(
!
container
)
return
// 3. 计算当前点击行在文本中的相对高度比例
const
diffLines
=
isNewSide
?
newDiffLines
.
value
:
oldDiffLines
.
value
const
clickedIndex
=
diffLines
.
indexOf
(
line
)
if
(
clickedIndex
===
-
1
)
return
const
lineRatio
=
clickedIndex
/
diffLines
.
length
// 4. 获取当前侧容器下的所有 [data-node-id] 元素
const
elements
=
Array
.
from
(
container
.
querySelectorAll
(
'[data-node-id]'
))
as
HTMLElement
[]
if
(
elements
.
length
===
0
)
return
let
bestScore
=
-
1
let
bestCandidates
:
{
el
:
HTMLElement
;
domRatio
:
number
}[]
=
[]
for
(
let
i
=
0
;
i
<
elements
.
length
;
i
++
)
{
const
el
=
elements
[
i
]
const
elTagName
=
(
el
.
getAttribute
(
'data-tag-name'
)
||
''
).
toUpperCase
()
// 标签名必须完全一致
if
(
elTagName
!==
lineTagName
)
continue
let
score
=
10
// 基础分
// 匹配属性值
const
attrValues
=
lineContent
.
match
(
/="
([^
"
]
+
)
"/g
)
if
(
attrValues
)
{
for
(
const
val
of
attrValues
)
{
const
cleanVal
=
val
.
replace
(
/="|"$/g
,
''
)
if
(
el
.
outerHTML
.
includes
(
cleanVal
))
{
score
+=
20
}
}
}
// 匹配去标签后的文本内容
const
cleanLineText
=
lineContent
.
replace
(
/<
[^
>
]
+>/g
,
''
).
trim
()
if
(
cleanLineText
)
{
const
elText
=
el
.
textContent
||
''
if
(
elText
.
includes
(
cleanLineText
))
{
score
+=
cleanLineText
.
length
*
2
}
}
// 维护得分最高候选者
const
domRatio
=
i
/
elements
.
length
if
(
score
>
bestScore
)
{
bestScore
=
score
bestCandidates
=
[{
el
,
domRatio
}]
}
else
if
(
score
===
bestScore
)
{
bestCandidates
.
push
({
el
,
domRatio
})
}
}
// 5. 在所有最高分的候选节点中,寻找其在 DOM 树中的相对百分比位置最接近当前点击行的节点
let
bestMatch
:
HTMLElement
|
null
=
null
let
minDistance
=
Infinity
for
(
const
cand
of
bestCandidates
)
{
const
distance
=
Math
.
abs
(
cand
.
domRatio
-
lineRatio
)
if
(
distance
<
minDistance
)
{
minDistance
=
distance
bestMatch
=
cand
.
el
}
}
// 6. 执行闪烁高亮与居中滚动
if
(
bestMatch
)
{
// 清理当前侧容器中的所有已高亮节点
container
.
querySelectorAll
(
'.highlight-focused-node'
).
forEach
((
item
)
=>
{
item
.
classList
.
remove
(
'highlight-focused-node'
)
})
bestMatch
.
classList
.
add
(
'highlight-focused-node'
)
bestMatch
.
scrollIntoView
({
behavior
:
'smooth'
,
block
:
'center'
})
}
}
// ─── 差异指示条(minimap)高精度物理对齐 ───
// 核心逻辑:为了实现“滚轴中间压着指示条”,我们需要在考虑滑块最小高度限制(例如 8%)的情况下,
// 使得滑块中点对应的百分比和元素在视口中点对应的百分比进行线性匹配。
//
// 设轨道总百分比高度为 H_track = 100,滑块百分比高度为 hSlider。
// 当 scrollTop = 0 时,滑块顶部在 0,中点在 hSlider / 2。
// 当 scrollTop = maxScrollTop 时,滑块底部在 100,顶部在 100 - hSlider,中点在 100 - hSlider / 2。
//
// 因此,当 scrollTop 在 [0, maxScrollTop] 变化时,滑块顶部的映射公式为:
// sliderTop% = (scrollTop / maxScrollTop) * (100 - hSlider)
// 某一行在 scrollTop = offsetTop - clientHeight / 2 时刚好处于视口垂直正中。
// 我们让这一行对应的 marker 刚好等于此时滑块中点的位置:
// markerTop% = (targetScrollTopClamped / maxScrollTop) * (100 - hSlider) + hSlider / 2
// 辅助映射:将元素的物理 offsetTop 转换为与滑块中心严格对齐的百分比
const
mapOffsetToPercent
=
(
y
:
number
,
sh
:
number
,
ch
:
number
,
hSlider
:
number
):
number
=>
{
const
maxScrollTop
=
sh
-
ch
if
(
maxScrollTop
<=
0
)
{
return
(
y
/
sh
)
*
100
}
// 目标滚动位置:使该元素刚好处于可视区域的正中间
const
targetScrollTop
=
y
-
ch
/
2
const
targetScrollTopClamped
=
Math
.
max
(
0
,
Math
.
min
(
targetScrollTop
,
maxScrollTop
))
return
(
targetScrollTopClamped
/
maxScrollTop
)
*
(
100
-
hSlider
)
+
hSlider
/
2
}
// 辅助逆映射:点击指示条某处,反推目标 scrollTop 以使该位置的内容处于视口正中
const
getScrollTopFromRatio
=
(
ratio
:
number
,
sh
:
number
,
ch
:
number
,
hSlider
:
number
):
number
=>
{
const
maxScrollTop
=
sh
-
ch
if
(
maxScrollTop
<=
0
)
return
0
const
clickPercent
=
ratio
*
100
// 反推:clickPercent = (targetScrollTop / maxScrollTop) * (100 - hSlider) + hSlider / 2
const
targetScrollTop
=
((
clickPercent
-
hSlider
/
2
)
/
(
100
-
hSlider
))
*
maxScrollTop
return
Math
.
max
(
0
,
Math
.
min
(
targetScrollTop
,
maxScrollTop
))
}
// 递归累加 offsetTop,获取元素相对于指定相对定位容器的物理偏移
const
getOffsetTop
=
(
el
:
HTMLElement
,
container
:
HTMLElement
):
number
=>
{
let
top
=
0
let
cur
:
HTMLElement
|
null
=
el
while
(
cur
&&
cur
!==
container
)
{
top
+=
cur
.
offsetTop
cur
=
cur
.
offsetParent
as
HTMLElement
|
null
}
return
top
}
// 文本差异指示条 markers(高精度中点匹配)
const
leftTextDiffMarkers
=
ref
<
{
top
:
number
}[]
>
([])
const
rightTextDiffMarkers
=
ref
<
{
top
:
number
}[]
>
([])
const
updateTextDiffMarkers
=
()
=>
{
const
lc
=
leftTextScrollContainer
.
value
if
(
lc
&&
lc
.
scrollHeight
>
0
)
{
const
sh
=
lc
.
scrollHeight
const
ch
=
lc
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
leftTextDiffMarkers
.
value
=
(
Array
.
from
(
lc
.
querySelectorAll
(
'.line-row-removed'
))
as
HTMLElement
[]).
map
((
row
)
=>
({
top
:
mapOffsetToPercent
(
getOffsetTop
(
row
,
lc
),
sh
,
ch
,
hSlider
)
}))
}
else
{
leftTextDiffMarkers
.
value
=
[]
}
const
rc
=
rightTextScrollContainer
.
value
if
(
rc
&&
rc
.
scrollHeight
>
0
)
{
const
sh
=
rc
.
scrollHeight
const
ch
=
rc
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
rightTextDiffMarkers
.
value
=
(
Array
.
from
(
rc
.
querySelectorAll
(
'.line-row-added'
))
as
HTMLElement
[]).
map
((
row
)
=>
({
top
:
mapOffsetToPercent
(
getOffsetTop
(
row
,
rc
),
sh
,
ch
,
hSlider
)
}))
}
else
{
rightTextDiffMarkers
.
value
=
[]
}
}
// 渲染差异指示条 markers(高精度中点匹配)
const
leftRenderDiffMarkers
=
ref
<
{
type
:
'removed'
|
'modified'
;
top
:
number
}[]
>
([])
const
rightRenderDiffMarkers
=
ref
<
{
type
:
'added'
|
'modified'
;
top
:
number
}[]
>
([])
const
updateRenderDiffMarkers
=
()
=>
{
const
lc
=
leftScrollContainer
.
value
if
(
lc
&&
lc
.
scrollHeight
>
0
)
{
const
sh
=
lc
.
scrollHeight
const
ch
=
lc
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
leftRenderDiffMarkers
.
value
=
(
Array
.
from
(
lc
.
querySelectorAll
(
'[data-diff-status="removed"],[data-diff-status="modified"]'
))
as
HTMLElement
[]
).
flatMap
((
el
)
=>
{
const
s
=
el
.
getAttribute
(
'data-diff-status'
)
if
(
!
s
||
s
===
'none'
||
s
===
'added'
)
return
[]
return
[{
type
:
s
as
'removed'
|
'modified'
,
top
:
mapOffsetToPercent
(
getOffsetTop
(
el
,
lc
),
sh
,
ch
,
hSlider
)
}]
})
}
else
{
leftRenderDiffMarkers
.
value
=
[]
}
const
rc
=
rightScrollContainer
.
value
if
(
rc
&&
rc
.
scrollHeight
>
0
)
{
const
sh
=
rc
.
scrollHeight
const
ch
=
rc
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
rightRenderDiffMarkers
.
value
=
(
Array
.
from
(
rc
.
querySelectorAll
(
'[data-diff-status="added"],[data-diff-status="modified"]'
))
as
HTMLElement
[]
).
flatMap
((
el
)
=>
{
const
s
=
el
.
getAttribute
(
'data-diff-status'
)
if
(
!
s
||
s
===
'none'
||
s
===
'removed'
)
return
[]
return
[{
type
:
s
as
'added'
|
'modified'
,
top
:
mapOffsetToPercent
(
getOffsetTop
(
el
,
rc
),
sh
,
ch
,
hSlider
)
}]
})
}
else
{
rightRenderDiffMarkers
.
value
=
[]
}
}
// 指示条点击跳转事件处理器(使目标差异完美居中于视口,滚轴中点压住指示条)
const
handleLeftTextMinimapClick
=
(
e
:
MouseEvent
)
=>
{
const
rect
=
(
e
.
currentTarget
as
HTMLElement
).
getBoundingClientRect
()
const
clickY
=
e
.
clientY
-
rect
.
top
const
ratio
=
clickY
/
rect
.
height
if
(
leftTextScrollContainer
.
value
)
{
const
sh
=
leftTextScrollContainer
.
value
.
scrollHeight
const
ch
=
leftTextScrollContainer
.
value
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
leftTextScrollContainer
.
value
.
scrollTop
=
getScrollTopFromRatio
(
ratio
,
sh
,
ch
,
hSlider
)
}
}
const
handleRightTextMinimapClick
=
(
e
:
MouseEvent
)
=>
{
const
rect
=
(
e
.
currentTarget
as
HTMLElement
).
getBoundingClientRect
()
const
clickY
=
e
.
clientY
-
rect
.
top
const
ratio
=
clickY
/
rect
.
height
if
(
rightTextScrollContainer
.
value
)
{
const
sh
=
rightTextScrollContainer
.
value
.
scrollHeight
const
ch
=
rightTextScrollContainer
.
value
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
rightTextScrollContainer
.
value
.
scrollTop
=
getScrollTopFromRatio
(
ratio
,
sh
,
ch
,
hSlider
)
}
}
const
handleLeftRenderMinimapClick
=
(
e
:
MouseEvent
)
=>
{
const
rect
=
(
e
.
currentTarget
as
HTMLElement
).
getBoundingClientRect
()
const
clickY
=
e
.
clientY
-
rect
.
top
const
ratio
=
clickY
/
rect
.
height
if
(
leftScrollContainer
.
value
)
{
const
sh
=
leftScrollContainer
.
value
.
scrollHeight
const
ch
=
leftScrollContainer
.
value
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
leftScrollContainer
.
value
.
scrollTop
=
getScrollTopFromRatio
(
ratio
,
sh
,
ch
,
hSlider
)
}
}
const
handleRightRenderMinimapClick
=
(
e
:
MouseEvent
)
=>
{
const
rect
=
(
e
.
currentTarget
as
HTMLElement
).
getBoundingClientRect
()
const
clickY
=
e
.
clientY
-
rect
.
top
const
ratio
=
clickY
/
rect
.
height
if
(
rightScrollContainer
.
value
)
{
const
sh
=
rightScrollContainer
.
value
.
scrollHeight
const
ch
=
rightScrollContainer
.
value
.
clientHeight
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
rightScrollContainer
.
value
.
scrollTop
=
getScrollTopFromRatio
(
ratio
,
sh
,
ch
,
hSlider
)
}
}
// ─── 模拟滚动滑块与拖拽同步 ───
const
leftTextScrollTopPercent
=
ref
(
0
)
const
leftTextScrollHeightPercent
=
ref
(
100
)
const
rightTextScrollTopPercent
=
ref
(
0
)
const
rightTextScrollHeightPercent
=
ref
(
100
)
const
leftRenderScrollTopPercent
=
ref
(
0
)
const
leftRenderScrollHeightPercent
=
ref
(
100
)
const
rightRenderScrollTopPercent
=
ref
(
0
)
const
rightRenderScrollHeightPercent
=
ref
(
100
)
const
updateScrollPercentages
=
()
=>
{
// 1. 左文本
const
ltc
=
leftTextScrollContainer
.
value
if
(
ltc
&&
ltc
.
scrollHeight
>
0
)
{
const
sh
=
ltc
.
scrollHeight
const
ch
=
ltc
.
clientHeight
const
maxScrollTop
=
sh
-
ch
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
leftTextScrollHeightPercent
.
value
=
hSlider
leftTextScrollTopPercent
.
value
=
maxScrollTop
>
0
?
(
ltc
.
scrollTop
/
maxScrollTop
)
*
(
100
-
hSlider
)
:
0
}
// 2. 右文本
const
rtc
=
rightTextScrollContainer
.
value
if
(
rtc
&&
rtc
.
scrollHeight
>
0
)
{
const
sh
=
rtc
.
scrollHeight
const
ch
=
rtc
.
clientHeight
const
maxScrollTop
=
sh
-
ch
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
rightTextScrollHeightPercent
.
value
=
hSlider
rightTextScrollTopPercent
.
value
=
maxScrollTop
>
0
?
(
rtc
.
scrollTop
/
maxScrollTop
)
*
(
100
-
hSlider
)
:
0
}
// 3. 左渲染
const
lrc
=
leftScrollContainer
.
value
if
(
lrc
&&
lrc
.
scrollHeight
>
0
)
{
const
sh
=
lrc
.
scrollHeight
const
ch
=
lrc
.
clientHeight
const
maxScrollTop
=
sh
-
ch
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
leftRenderScrollHeightPercent
.
value
=
hSlider
leftRenderScrollTopPercent
.
value
=
maxScrollTop
>
0
?
(
lrc
.
scrollTop
/
maxScrollTop
)
*
(
100
-
hSlider
)
:
0
}
// 4. 右渲染
const
rrc
=
rightScrollContainer
.
value
if
(
rrc
&&
rrc
.
scrollHeight
>
0
)
{
const
sh
=
rrc
.
scrollHeight
const
ch
=
rrc
.
clientHeight
const
maxScrollTop
=
sh
-
ch
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
rightRenderScrollHeightPercent
.
value
=
hSlider
rightRenderScrollTopPercent
.
value
=
maxScrollTop
>
0
?
(
rrc
.
scrollTop
/
maxScrollTop
)
*
(
100
-
hSlider
)
:
0
}
}
// 模拟滚动滑块拖拽逻辑 (根据精确的缩放比映射 scrollTop)
const
startDragScroll
=
(
e
:
MouseEvent
,
type
:
'leftText'
|
'rightText'
|
'leftRender'
|
'rightRender'
)
=>
{
e
.
preventDefault
()
const
container
=
type
===
'leftText'
?
leftTextScrollContainer
.
value
:
type
===
'rightText'
?
rightTextScrollContainer
.
value
:
type
===
'leftRender'
?
leftScrollContainer
.
value
:
rightScrollContainer
.
value
if
(
!
container
)
return
const
startY
=
e
.
clientY
const
startScrollTop
=
container
.
scrollTop
const
sh
=
container
.
scrollHeight
const
ch
=
container
.
clientHeight
const
maxScrollTop
=
sh
-
ch
const
hSlider
=
Math
.
max
(
8
,
(
ch
/
sh
)
*
100
)
const
track
=
(
e
.
currentTarget
as
HTMLElement
).
parentElement
if
(
!
track
)
return
const
trackHeight
=
track
.
clientHeight
const
onMouseMove
=
(
moveEvent
:
MouseEvent
)
=>
{
const
deltaY
=
moveEvent
.
clientY
-
startY
const
deltaRatio
=
deltaY
/
trackHeight
if
(
maxScrollTop
>
0
)
{
// 在限制最小滑块高度后,滑块顶部实际的活动百分比区间是 100 - hSlider
// 故 deltaScroll = (deltaRatio * 100 / (100 - hSlider)) * maxScrollTop
const
deltaScroll
=
((
deltaRatio
*
100
)
/
(
100
-
hSlider
))
*
maxScrollTop
container
.
scrollTop
=
Math
.
max
(
0
,
Math
.
min
(
startScrollTop
+
deltaScroll
,
maxScrollTop
))
}
// 同步联锁其它容器
if
(
type
===
'leftText'
)
handleLeftTextScroll
()
if
(
type
===
'rightText'
)
handleRightTextScroll
()
if
(
type
===
'leftRender'
)
handleLeftScroll
()
if
(
type
===
'rightRender'
)
handleRightScroll
()
updateScrollPercentages
()
}
const
onMouseUp
=
()
=>
{
document
.
removeEventListener
(
'mousemove'
,
onMouseMove
)
document
.
removeEventListener
(
'mouseup'
,
onMouseUp
)
}
document
.
addEventListener
(
'mousemove'
,
onMouseMove
)
document
.
addEventListener
(
'mouseup'
,
onMouseUp
)
}
// 包装原生的 @scroll 监听器,确保每次滚动时同步更新模拟滚动滑块的物理占比
const
onLeftTextScroll
=
()
=>
{
handleLeftTextScroll
()
updateScrollPercentages
()
}
const
onRightTextScroll
=
()
=>
{
handleRightTextScroll
()
updateScrollPercentages
()
}
const
onLeftScroll
=
()
=>
{
handleLeftScroll
()
updateScrollPercentages
()
}
const
onRightScroll
=
()
=>
{
handleRightScroll
()
updateScrollPercentages
()
}
// 侦听渲染树变化 → 重算渲染区 marker
watch
(
[
oldDiffTree
,
newDiffTree
],
()
=>
{
nextTick
(()
=>
{
setTimeout
(()
=>
{
updateRenderDiffMarkers
()
updateScrollPercentages
()
},
200
)
})
},
{
deep
:
true
,
immediate
:
true
}
)
// 侦听文本行变化 → 重算文本 marker
watch
(
[
oldDiffLines
,
newDiffLines
],
()
=>
{
nextTick
(()
=>
{
setTimeout
(()
=>
{
updateTextDiffMarkers
()
updateScrollPercentages
()
},
200
)
})
},
{
immediate
:
true
}
)
// 侦听模态框打开,在动画完成后触发一次完整的重算,确保 DOM clientHeight/scrollHeight 能被正确读取
watch
(
showModal
,
(
newVal
)
=>
{
if
(
newVal
)
{
nextTick
(()
=>
{
setTimeout
(()
=>
{
updateTextDiffMarkers
()
updateRenderDiffMarkers
()
updateScrollPercentages
()
},
350
)
})
}
})
return
{
showModal
,
loading
,
oldXmlText
,
newXmlText
,
oldDiffTree
,
newDiffTree
,
leftScrollContainer
,
rightScrollContainer
,
handleLeftScroll
,
handleRightScroll
,
loadCurrentXml
,
resetDiff
,
open
,
handleCompare
,
handleXmlImportClick
,
formatXmlText
,
isEditingOld
,
isEditingNew
,
oldDiffLines
,
newDiffLines
,
updateTextDiff
,
leftTextScrollContainer
,
rightTextScrollContainer
,
handleLeftTextScroll
,
handleRightTextScroll
,
leftParentContainer
,
rightParentContainer
,
editingOldLineNum
,
editingNewLineNum
,
editingOldText
,
editingNewText
,
startEditLine
,
saveEditLine
,
cancelEditLine
,
handleOldXmlBlur
,
handleNewXmlBlur
,
highlightTargetNode
,
leftTextDiffMarkers
,
rightTextDiffMarkers
,
leftRenderDiffMarkers
,
rightRenderDiffMarkers
,
handleLeftTextMinimapClick
,
handleRightTextMinimapClick
,
handleLeftRenderMinimapClick
,
handleRightRenderMinimapClick
,
leftTextScrollTopPercent
,
leftTextScrollHeightPercent
,
rightTextScrollTopPercent
,
rightTextScrollHeightPercent
,
leftRenderScrollTopPercent
,
leftRenderScrollHeightPercent
,
rightRenderScrollTopPercent
,
rightRenderScrollHeightPercent
,
startDragScroll
,
onLeftTextScroll
,
onRightTextScroll
,
onLeftScroll
,
onRightScroll
,
editingOldRange
,
editingNewRange
,
editingOldRangeText
,
editingNewRangeText
,
toggleEditOld
,
toggleEditNew
,
saveEditRange
,
cancelEditRange
}
}
// ── 内部辅助比对函数 ──
function
alignNodes
(
oldChildren
:
XmlNode
[],
newChildren
:
XmlNode
[]):
{
oldNode
:
XmlNode
|
null
;
newNode
:
XmlNode
|
null
}[]
{
const
aligned
:
{
oldNode
:
XmlNode
|
null
;
newNode
:
XmlNode
|
null
}[]
=
[]
let
i
=
0
let
j
=
0
while
(
i
<
oldChildren
.
length
||
j
<
newChildren
.
length
)
{
if
(
i
>=
oldChildren
.
length
)
{
aligned
.
push
({
oldNode
:
null
,
newNode
:
newChildren
[
j
]
})
j
++
}
else
if
(
j
>=
newChildren
.
length
)
{
aligned
.
push
({
oldNode
:
oldChildren
[
i
],
newNode
:
null
})
i
++
}
else
{
const
oldChild
=
oldChildren
[
i
]
const
newChild
=
newChildren
[
j
]
if
(
oldChild
.
tagName
===
newChild
.
tagName
)
{
aligned
.
push
({
oldNode
:
oldChild
,
newNode
:
newChild
})
i
++
j
++
}
else
{
// 查找前方是否有匹配的标签
let
foundMatch
=
false
for
(
let
k
=
i
+
1
;
k
<
oldChildren
.
length
;
k
++
)
{
if
(
oldChildren
[
k
].
tagName
===
newChild
.
tagName
)
{
for
(
let
m
=
i
;
m
<
k
;
m
++
)
{
aligned
.
push
({
oldNode
:
oldChildren
[
m
],
newNode
:
null
})
}
aligned
.
push
({
oldNode
:
oldChildren
[
k
],
newNode
:
newChild
})
i
=
k
+
1
j
++
foundMatch
=
true
break
}
}
if
(
!
foundMatch
)
{
aligned
.
push
({
oldNode
:
null
,
newNode
:
newChild
})
j
++
}
}
}
}
return
aligned
}
/**
* 构建旧工卡差异树(仅保留未变更和已被删除的节点,删除的以红色高亮)
*/
function
buildOldDiffTree
(
oldNode
:
XmlNode
|
null
,
newNode
:
XmlNode
|
null
):
DiffXmlNode
|
null
{
if
(
!
oldNode
)
return
null
// 只有旧节点(说明是被删除的元素)
if
(
oldNode
&&
!
newNode
)
{
return
{
id
:
oldNode
.
id
,
tagName
:
oldNode
.
tagName
,
attributes
:
{
...
oldNode
.
attributes
},
textContent
:
oldNode
.
textContent
,
parentId
:
oldNode
.
parentId
,
diffStatus
:
'removed'
,
mixedContent
:
(
oldNode
.
mixedContent
||
[]).
map
((
item
)
=>
({
...
item
,
diffStatus
:
'removed'
})),
children
:
oldNode
.
children
.
map
((
child
)
=>
buildOldDiffTree
(
child
,
null
)).
filter
((
c
):
c
is
DiffXmlNode
=>
c
!==
null
)
}
}
// 两者都有
const
node
=
oldNode
!
const
other
=
newNode
!
let
diffStatus
:
'added'
|
'removed'
|
'modified'
|
'none'
=
'none'
const
attrsEqual
=
JSON
.
stringify
(
node
.
attributes
)
===
JSON
.
stringify
(
other
.
attributes
)
const
isMetadataTag
=
EFFECT_METADATA_TAGS
.
includes
(
node
.
tagName
)
const
oldText
=
isMetadataTag
?
getMetadataRenderedText
(
node
)
:
node
.
textContent
||
''
const
newText
=
isMetadataTag
?
getMetadataRenderedText
(
other
)
:
other
.
textContent
||
''
const
textEqual
=
oldText
===
newText
if
(
!
attrsEqual
||
!
textEqual
)
{
diffStatus
=
'modified'
}
const
mixedContent
:
DiffMixedContentItem
[]
=
[]
if
(
isMetadataTag
)
{
// 对于元数据标签,总是把渲染文本放入 mixedContent,这样渲染器就可以走 word diff
const
words
=
Diff
.
diffWords
(
oldText
,
newText
)
mixedContent
.
push
({
type
:
'text'
,
text
:
oldText
,
diffWords
:
words
.
filter
((
w
)
=>
!
w
.
added
)
.
map
((
w
)
=>
({
value
:
w
.
value
,
removed
:
w
.
removed
}))
})
}
else
if
(
node
.
textContent
||
other
.
textContent
)
{
const
words
=
Diff
.
diffWords
(
node
.
textContent
||
''
,
other
.
textContent
||
''
)
mixedContent
.
push
({
type
:
'text'
,
text
:
node
.
textContent
||
''
,
diffWords
:
words
.
filter
((
w
)
=>
!
w
.
added
)
// 过滤掉新增的单词,旧卡只显示删除的/未改动的
.
map
((
w
)
=>
({
value
:
w
.
value
,
removed
:
w
.
removed
}))
})
}
const
aligned
=
alignNodes
(
node
.
children
,
other
.
children
)
const
children
=
aligned
.
map
((
pair
)
=>
buildOldDiffTree
(
pair
.
oldNode
,
pair
.
newNode
)).
filter
((
c
):
c
is
DiffXmlNode
=>
c
!==
null
)
return
{
id
:
node
.
id
,
tagName
:
node
.
tagName
,
attributes
:
{
...
node
.
attributes
},
textContent
:
node
.
textContent
,
parentId
:
node
.
parentId
,
diffStatus
,
mixedContent
,
children
}
}
/**
* 构建新工卡差异树(仅保留未变更和新增的节点,新增的以绿色高亮)
*/
function
buildNewDiffTree
(
oldNode
:
XmlNode
|
null
,
newNode
:
XmlNode
|
null
):
DiffXmlNode
|
null
{
if
(
!
newNode
)
return
null
// 只有新节点(说明是新增的元素)
if
(
!
oldNode
&&
newNode
)
{
return
{
id
:
newNode
.
id
,
tagName
:
newNode
.
tagName
,
attributes
:
{
...
newNode
.
attributes
},
textContent
:
newNode
.
textContent
,
parentId
:
newNode
.
parentId
,
diffStatus
:
'added'
,
mixedContent
:
(
newNode
.
mixedContent
||
[]).
map
((
item
)
=>
({
...
item
,
diffStatus
:
'added'
})),
children
:
newNode
.
children
.
map
((
child
)
=>
buildNewDiffTree
(
null
,
child
)).
filter
((
c
):
c
is
DiffXmlNode
=>
c
!==
null
)
}
}
// 两者都有
const
node
=
oldNode
!
const
other
=
newNode
!
let
diffStatus
:
'added'
|
'removed'
|
'modified'
|
'none'
=
'none'
const
attrsEqual
=
JSON
.
stringify
(
node
.
attributes
)
===
JSON
.
stringify
(
other
.
attributes
)
const
isMetadataTag
=
EFFECT_METADATA_TAGS
.
includes
(
node
.
tagName
)
const
oldText
=
isMetadataTag
?
getMetadataRenderedText
(
node
)
:
node
.
textContent
||
''
const
newText
=
isMetadataTag
?
getMetadataRenderedText
(
other
)
:
other
.
textContent
||
''
const
textEqual
=
oldText
===
newText
if
(
!
attrsEqual
||
!
textEqual
)
{
diffStatus
=
'modified'
}
const
mixedContent
:
DiffMixedContentItem
[]
=
[]
if
(
isMetadataTag
)
{
const
words
=
Diff
.
diffWords
(
oldText
,
newText
)
mixedContent
.
push
({
type
:
'text'
,
text
:
newText
,
diffWords
:
words
.
filter
((
w
)
=>
!
w
.
removed
)
.
map
((
w
)
=>
({
value
:
w
.
value
,
added
:
w
.
added
}))
})
}
else
if
(
node
.
textContent
||
other
.
textContent
)
{
const
words
=
Diff
.
diffWords
(
node
.
textContent
||
''
,
other
.
textContent
||
''
)
mixedContent
.
push
({
type
:
'text'
,
text
:
other
.
textContent
||
''
,
diffWords
:
words
.
filter
((
w
)
=>
!
w
.
removed
)
// 过滤掉被删除的单词,新卡只显示新增的/未改动的
.
map
((
w
)
=>
({
value
:
w
.
value
,
added
:
w
.
added
}))
})
}
const
aligned
=
alignNodes
(
node
.
children
,
other
.
children
)
const
children
=
aligned
.
map
((
pair
)
=>
buildNewDiffTree
(
pair
.
oldNode
,
pair
.
newNode
)).
filter
((
c
):
c
is
DiffXmlNode
=>
c
!==
null
)
return
{
id
:
other
.
id
,
tagName
:
other
.
tagName
,
attributes
:
{
...
other
.
attributes
},
textContent
:
other
.
textContent
,
parentId
:
other
.
parentId
,
diffStatus
,
mixedContent
,
children
}
}
function
formatEff
(
eff
:
string
|
undefined
):
string
{
if
(
!
eff
)
return
'ALL'
const
cleaned
=
eff
.
replace
(
/
\s
+/g
,
''
)
if
(
cleaned
===
'001999'
)
return
'ALL'
if
(
/^
\d
+$/
.
test
(
cleaned
)
&&
cleaned
.
length
%
6
===
0
)
{
const
parts
:
string
[]
=
[]
for
(
let
i
=
0
;
i
<
cleaned
.
length
;
i
+=
6
)
{
const
block
=
cleaned
.
substring
(
i
,
i
+
6
)
parts
.
push
(
block
===
'001999'
?
'ALL'
:
block
)
}
return
parts
.
join
(
', '
)
}
return
eff
}
function
getMetadataRenderedText
(
n
:
XmlNode
):
string
{
if
(
n
.
tagName
===
'EFFECT'
||
n
.
tagName
===
'CONEFFECT'
)
{
const
type
=
n
.
tagName
===
'EFFECT'
?
'ON A/C'
:
'CONF'
const
val
=
n
.
attributes
.
EFFRG
||
n
.
textContent
||
''
return
`**
${
type
}
:
${
formatEff
(
val
)}
`
}
if
(
n
.
tagName
===
'SBEFF'
||
n
.
tagName
===
'SBEFFC'
)
{
const
cond
=
n
.
attributes
.
SBCOND
||
''
const
nbr
=
n
.
attributes
.
SBNBR
||
''
const
eff
=
formatEff
(
n
.
attributes
.
EFFRG
)
return
`** SB:
${
cond
}
SB
${
nbr
}
for A/C
${
eff
}
`
}
return
''
}
export
function
generateDoubleFolderDiff
(
oldText
:
string
,
newText
:
string
)
{
const
diff
=
Diff
.
diffLines
(
oldText
,
newText
)
const
leftLines
:
DiffLine
[]
=
[]
const
rightLines
:
DiffLine
[]
=
[]
let
leftNum
=
1
let
rightNum
=
1
let
i
=
0
while
(
i
<
diff
.
length
)
{
const
current
=
diff
[
i
]
if
(
!
current
.
added
&&
!
current
.
removed
)
{
const
lines
=
current
.
value
.
split
(
'
\
n'
)
if
(
lines
[
lines
.
length
-
1
]
===
''
)
lines
.
pop
()
for
(
const
line
of
lines
)
{
leftLines
.
push
({
type
:
'normal'
,
num
:
leftNum
++
,
content
:
line
})
rightLines
.
push
({
type
:
'normal'
,
num
:
rightNum
++
,
content
:
line
})
}
i
++
}
else
{
const
leftBlock
:
string
[]
=
[]
const
rightBlock
:
string
[]
=
[]
while
(
i
<
diff
.
length
&&
(
diff
[
i
].
added
||
diff
[
i
].
removed
))
{
const
block
=
diff
[
i
]
const
lines
=
block
.
value
.
split
(
'
\
n'
)
if
(
lines
[
lines
.
length
-
1
]
===
''
)
lines
.
pop
()
if
(
block
.
removed
)
{
leftBlock
.
push
(...
lines
)
}
else
{
rightBlock
.
push
(...
lines
)
}
i
++
}
const
maxLen
=
Math
.
max
(
leftBlock
.
length
,
rightBlock
.
length
)
for
(
let
k
=
0
;
k
<
maxLen
;
k
++
)
{
if
(
k
<
leftBlock
.
length
)
{
leftLines
.
push
({
type
:
'removed'
,
num
:
leftNum
++
,
content
:
leftBlock
[
k
]
})
}
else
{
leftLines
.
push
({
type
:
'empty'
,
content
:
''
})
}
if
(
k
<
rightBlock
.
length
)
{
rightLines
.
push
({
type
:
'added'
,
num
:
rightNum
++
,
content
:
rightBlock
[
k
]
})
}
else
{
rightLines
.
push
({
type
:
'empty'
,
content
:
''
})
}
}
}
}
return
{
leftLines
,
rightLines
}
}
src/views/editor/components/EditorToolbar/components/CompareModal/index.vue
View file @
f4b7cfd2
<
template
>
<CommonModal
v-model=
"showModal"
title=
"工卡 XML 差异实时对比"
fullscreen
:padding=
"16"
:scrollable=
"false"
:showFooter=
"true"
:loading=
"loading"
:show-confirm=
"false"
cancel-text=
"关闭"
class=
"compare-modal-nobar"
>
<div
class=
"compare-modal-content flex-1 flex flex-col min-h-0 h-full"
>
<!-- 顶栏:图例说明 -->
<div
class=
"flex flex-wrap items-center justify-between gap-3 mb-3 p-3 bg-fill-2 border border-divider rounded-lg shrink-0"
>
<div
class=
"flex items-center space-x-4 text-xs font-semibold select-none"
>
<span
class=
"text-color3"
>
差异图例:
</span>
<span
class=
"flex items-center space-x-1"
>
<span
class=
"w-3 h-3 rounded bg-success-1 border border-success-3"
></span>
<span
class=
"text-success-6 dark:text-success-5"
>
新增内容
</span>
</span>
<span
class=
"flex items-center space-x-1"
>
<span
class=
"w-3 h-3 rounded bg-danger-1 border border-danger-3"
></span>
<span
class=
"text-danger-6 dark:text-danger-5 line-through"
>
删除内容
</span>
</span>
<span
class=
"flex items-center space-x-1"
>
<span
class=
"w-1 h-3 bg-warning rounded-sm"
></span>
<span
class=
"text-warning-6 dark:text-warning-5"
>
属性变更 / 空白占位
</span>
</span>
</div>
<div
class=
"flex items-center gap-4"
>
<div
class=
"text-xs text-color3 italic"
>
上方为 XML 文本对比,下方为工卡实时渲染差异
</div>
<div
class=
"w-[1px] h-4 bg-divider"
></div>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"text-xs font-semibold text-color2"
>
XML 对比
</span>
<n-switch
:value=
"isTextExpanded"
size=
"small"
@
update:value=
"handleTextExpandedChange"
/>
</div>
<div
class=
"w-[1px] h-3 bg-divider"
></div>
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"text-xs font-semibold text-color2"
>
渲染对比
</span>
<n-switch
:value=
"isRenderExpanded"
size=
"small"
@
update:value=
"handleRenderExpandedChange"
/>
</div>
</div>
</div>
</div>
<!-- 主内容区:左右分栏 -->
<div
class=
"flex-1 flex gap-4 min-h-0 h-full"
>
<!-- 左侧栏:旧工卡输入与渲染 -->
<div
class=
"flex-1 flex flex-col min-w-0 min-h-0"
>
<!-- 输入区 -->
<div
class=
"flex flex-col shrink-0 compare-text-zone"
:class=
"
{
'is-collapsed': !isTextExpanded,
'expanded-full': !isRenderExpanded
}"
>
<div
class=
"flex items-center justify-between mb-2 shrink-0"
>
<span
class=
"text-sm font-bold text-color2 flex items-center space-x-1.5 whitespace-nowrap"
>
<span
class=
"w-2 h-2 rounded-full bg-warning"
></span>
<span>
源工卡 XML (旧版本)
</span>
</span>
<div
class=
"flex items-center space-x-1.5"
>
<CommonButton
v-if=
"oldXmlText"
size=
"tiny"
secondary
:type=
"isEditingOld || editingOldRange ? 'warning' : 'default'"
@
click=
"toggleEditOld"
>
{{
isEditingOld
||
editingOldRange
?
'查看对比'
:
'编辑 XML'
}}
</CommonButton>
<CommonButton
size=
"tiny"
secondary
type=
"warning"
@
click=
"loadCurrentXml"
>
使用当前打开的工卡
</CommonButton>
</div>
</div>
<div
ref=
"leftParentContainer"
class=
"flex-1 flex flex-col min-h-0 relative"
>
<n-input
v-if=
"isEditingOld"
v-model:value=
"oldXmlText"
type=
"textarea"
placeholder=
"请粘贴源工卡的完整 XML 内容..."
class=
"flex-1 font-mono text-xs"
:input-props=
"
{ style: { height: '100%' } }"
@blur="handleOldXmlBlur"
/>
<div
v-else
ref=
"leftTextScrollContainer"
class=
"flex-1 border border-divider rounded-lg bg-card overflow-auto p-3 font-mono text-[11px] leading-5 select-text whitespace-pre relative scroll-container text-color1"
title=
"双击行内容可编辑这一行"
@
scroll=
"onLeftTextScroll"
>
<div
v-for=
"(line, idx) in oldDiffLines"
:key=
"idx"
v-show=
"
!editingOldRange ||
!line.num ||
line.num
<
editingOldRange
.
start
||
line
.
num
>
editingOldRange.end ||
line.num === editingOldRange.start
"
class="flex w-full group hover:bg-fill-2 min-h-[20px] cursor-pointer diff-line-row"
:class="{
'line-row-removed': line.type === 'removed',
'line-row-empty': line.type === 'empty'
}"
:data-line-num="line.num"
@click="highlightTargetNode(line, false)"
@dblclick.stop="startEditLine(line, false)"
>
<span
class=
"line-num select-none w-10 text-right pr-2 border-r border-divider opacity-50"
>
{{
line
.
num
||
''
}}
</span>
<textarea
v-if=
"editingOldRange && line.num === editingOldRange.start"
:id=
"`old-range-input-$
{line.num}`"
v-model="editingOldRangeText"
:rows="Math.max(2, editingOldRange.end - editingOldRange.start + 1)"
class="flex-1 px-2 py-1 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none rounded resize-y"
@blur="saveEditRange(false)"
@keydown.enter.ctrl.stop="saveEditRange(false)"
@keydown.esc.stop="cancelEditRange(false)"
@click.stop
/>
<input
v-else-if=
"editingOldLineNum === line.num && line.num"
:id=
"`old-line-input-$
{line.num}`"
v-model="editingOldText"
class="flex-1 px-2 py-0.5 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none h-[18px] leading-[18px] rounded"
@blur="saveEditLine(false)"
@keyup.enter="saveEditLine(false)"
@keyup.esc="cancelEditLine(false)"
@click.stop
/>
<span
v-else
class=
"line-content pl-2 break-all whitespace-pre-wrap flex-1"
>
{{
line
.
content
}}
</span>
</div>
</div>
<!-- 左侧文本差异指示条:放在滚动容器外部右侧,加宽并支持点击跳转 and 模拟拖拽 -->
<div
v-if=
"!isEditingOld"
class=
"absolute right-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title=
"点击定位或拖动滑块"
@
click=
"handleLeftTextMinimapClick"
>
<!-- 差异 markers -->
<div
v-for=
"(marker, mIdx) in leftTextDiffMarkers"
:key=
"mIdx"
class=
"absolute left-0 right-0 h-[3px] bg-danger-6"
:style=
"
{ top: marker.top + '%' }"
>
</div>
<!-- 模拟滑块 -->
<div
class=
"absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style=
"
{
top: leftTextScrollTopPercent + '%',
height: leftTextScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'leftText')"
>
</div>
</div>
</div>
</div>
<!-- 渲染区 -->
<div
class=
"flex-grow flex flex-col compare-render-zone"
:class=
"
{
'is-collapsed': !isRenderExpanded
}"
>
<div
class=
"text-xs font-bold text-color3 mb-2 px-1 flex items-center space-x-1.5 shrink-0"
>
<span
class=
"w-2 h-2 rounded-full bg-danger"
></span>
<span>
源工卡渲染 (旧版本)
</span>
</div>
<!-- 滚动容器 + 指示条共用一个 relative 包裹 -->
<div
class=
"flex-1 min-h-0 relative"
>
<div
ref=
"leftScrollContainer"
class=
"is-diff-mode h-full overflow-auto border border-divider rounded-lg bg-card p-6 leading-relaxed relative scroll-container"
@
scroll=
"onLeftScroll"
>
<DocNodeRenderer
v-if=
"oldDiffTree"
:node=
"oldDiffTree"
:parent=
"null"
/>
<div
v-else
class=
"h-full flex items-center justify-center text-xs text-color3 select-none"
>
暂无源工卡数据,请在上方输入框中粘贴或导入
</div>
</div>
<!-- 左侧渲染差异指示条:滚动容器外部右侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if=
"oldDiffTree"
class=
"absolute right-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title=
"点击定位或拖动滑块"
@
click=
"handleLeftRenderMinimapClick"
>
<!-- 差异 markers -->
<div
v-for=
"(marker, mIdx) in leftRenderDiffMarkers"
:key=
"mIdx"
class=
"absolute left-0 right-0 h-[3px]"
:class=
"marker.type === 'removed' ? 'bg-danger-6' : 'bg-warning-6'"
:style=
"
{ top: marker.top + '%' }"
>
</div>
<!-- 模拟滑块 -->
<div
class=
"absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style=
"
{
top: leftRenderScrollTopPercent + '%',
height: leftRenderScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'leftRender')"
>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧栏:新工卡输入与渲染 -->
<div
class=
"flex-1 flex flex-col min-w-0 min-h-0"
>
<!-- 输入区 -->
<div
class=
"flex flex-col shrink-0 compare-text-zone"
:class=
"
{
'is-collapsed': !isTextExpanded,
'expanded-full': !isRenderExpanded
}"
>
<div
class=
"flex items-center justify-between mb-2 shrink-0"
>
<span
class=
"text-sm font-bold text-color2 flex items-center space-x-1.5 whitespace-nowrap"
>
<span
class=
"w-2 h-2 rounded-full bg-primary"
></span>
<span>
目标工卡 XML (新版本)
</span>
</span>
<div
class=
"flex items-center space-x-1.5"
>
<CommonButton
v-if=
"newXmlText"
size=
"tiny"
secondary
:type=
"isEditingNew || editingNewRange ? 'primary' : 'default'"
@
click=
"toggleEditNew"
>
{{
isEditingNew
||
editingNewRange
?
'查看对比'
:
'编辑 XML'
}}
</CommonButton>
<CommonButton
size=
"tiny"
secondary
type=
"primary"
@
click=
"handleXmlImportClick"
>
选择 XML 文件导入
</CommonButton>
</div>
</div>
<div
ref=
"rightParentContainer"
class=
"flex-1 flex flex-col min-h-0 relative"
>
<n-input
v-if=
"isEditingNew"
v-model:value=
"newXmlText"
type=
"textarea"
placeholder=
"请粘贴目标工卡的完整 XML 内容,或者点击上方按钮导入文件..."
class=
"flex-1 font-mono text-xs"
:input-props=
"
{ style: { height: '100%' } }"
@blur="handleNewXmlBlur"
/>
<div
v-else
ref=
"rightTextScrollContainer"
class=
"flex-1 border border-divider rounded-lg bg-card overflow-auto p-3 font-mono text-[11px] leading-5 select-text whitespace-pre relative scroll-container text-color1"
title=
"双击行内容可编辑这一行"
@
scroll=
"onRightTextScroll"
>
<div
v-for=
"(line, idx) in newDiffLines"
:key=
"idx"
v-show=
"
!editingNewRange ||
!line.num ||
line.num
<
editingNewRange
.
start
||
line
.
num
>
editingNewRange.end ||
line.num === editingNewRange.start
"
class="flex w-full group hover:bg-fill-2 min-h-[20px] cursor-pointer diff-line-row"
:class="{
'line-row-added': line.type === 'added',
'line-row-empty': line.type === 'empty'
}"
:data-line-num="line.num"
@click="highlightTargetNode(line, true)"
@dblclick.stop="startEditLine(line, true)"
>
<span
class=
"line-num select-none w-10 text-right pr-2 border-r border-divider opacity-50"
>
{{
line
.
num
||
''
}}
</span>
<textarea
v-if=
"editingNewRange && line.num === editingNewRange.start"
:id=
"`new-range-input-$
{line.num}`"
v-model="editingNewRangeText"
:rows="Math.max(2, editingNewRange.end - editingNewRange.start + 1)"
class="flex-1 px-2 py-1 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none rounded resize-y"
@blur="saveEditRange(true)"
@keydown.enter.ctrl.stop="saveEditRange(true)"
@keydown.esc.stop="cancelEditRange(true)"
@click.stop
/>
<input
v-else-if=
"editingNewLineNum === line.num && line.num"
:id=
"`new-line-input-$
{line.num}`"
v-model="editingNewText"
class="flex-1 px-2 py-0.5 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none h-[18px] leading-[18px] rounded"
@blur="saveEditLine(true)"
@keyup.enter="saveEditLine(true)"
@keyup.esc="cancelEditLine(true)"
@click.stop
/>
<span
v-else
class=
"line-content pl-2 break-all whitespace-pre-wrap flex-1"
>
{{
line
.
content
}}
</span>
</div>
</div>
<!-- 右侧文本差异指示条:放在滚动容器外部左侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if=
"!isEditingNew"
class=
"absolute left-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title=
"点击定位或拖动滑块"
@
click=
"handleRightTextMinimapClick"
>
<!-- 差异 markers -->
<div
v-for=
"(marker, mIdx) in rightTextDiffMarkers"
:key=
"mIdx"
class=
"absolute left-0 right-0 h-[3px] bg-success-6"
:style=
"
{ top: marker.top + '%' }"
>
</div>
<!-- 模拟滑块 -->
<div
class=
"absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style=
"
{
top: rightTextScrollTopPercent + '%',
height: rightTextScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'rightText')"
>
</div>
</div>
</div>
</div>
<!-- 渲染区 -->
<div
class=
"flex-grow flex flex-col compare-render-zone"
:class=
"
{
'is-collapsed': !isRenderExpanded
}"
>
<div
class=
"text-xs font-bold text-color3 mb-2 px-1 flex items-center space-x-1.5 shrink-0"
>
<span
class=
"w-2 h-2 rounded-full bg-success"
></span>
<span>
目标工卡渲染 (新版本)
</span>
</div>
<!-- 滚动容器 + 指示条共用一个 relative 包裹 -->
<div
class=
"flex-1 min-h-0 relative"
>
<div
ref=
"rightScrollContainer"
class=
"is-diff-mode h-full overflow-auto border border-divider rounded-lg bg-card p-6 leading-relaxed relative scroll-container"
@
scroll=
"onRightScroll"
>
<DocNodeRenderer
v-if=
"newDiffTree"
:node=
"newDiffTree"
:parent=
"null"
/>
<div
v-else
class=
"h-full flex items-center justify-center text-xs text-color3 select-none"
>
暂无目标工卡数据,请在上方输入框中粘贴或导入
</div>
</div>
<!-- 右侧渲染差异指示条:滚动容器外部左侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if=
"newDiffTree"
class=
"absolute left-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title=
"点击定位或拖动滑块"
@
click=
"handleRightRenderMinimapClick"
>
<!-- 差异 markers -->
<div
v-for=
"(marker, mIdx) in rightRenderDiffMarkers"
:key=
"mIdx"
class=
"absolute left-0 right-0 h-[3px]"
:class=
"marker.type === 'added' ? 'bg-success-6' : 'bg-warning-6'"
:style=
"
{ top: marker.top + '%' }"
>
</div>
<!-- 模拟滑块 -->
<div
class=
"absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style=
"
{
top: rightRenderScrollTopPercent + '%',
height: rightRenderScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'rightRender')"
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</CommonModal>
<div></div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
provide
}
from
'vue'
import
{
useCompareModal
}
from
'./functionals'
import
DocNodeRenderer
from
'../../../DocNodeRenderer/index.vue'
const
{
showModal
,
loading
,
oldXmlText
,
newXmlText
,
oldDiffTree
,
newDiffTree
,
leftScrollContainer
,
rightScrollContainer
,
handleLeftScroll
,
handleRightScroll
,
loadCurrentXml
,
resetDiff
,
open
,
handleCompare
,
handleXmlImportClick
,
formatXmlText
,
isEditingOld
,
isEditingNew
,
oldDiffLines
,
newDiffLines
,
updateTextDiff
,
leftTextScrollContainer
,
rightTextScrollContainer
,
handleLeftTextScroll
,
handleRightTextScroll
,
leftParentContainer
,
rightParentContainer
,
editingOldLineNum
,
editingNewLineNum
,
editingOldText
,
editingNewText
,
startEditLine
,
saveEditLine
,
cancelEditLine
,
handleOldXmlBlur
,
handleNewXmlBlur
,
highlightTargetNode
,
leftTextDiffMarkers
,
rightTextDiffMarkers
,
leftRenderDiffMarkers
,
rightRenderDiffMarkers
,
handleLeftTextMinimapClick
,
handleRightTextMinimapClick
,
handleLeftRenderMinimapClick
,
handleRightRenderMinimapClick
,
leftTextScrollTopPercent
,
leftTextScrollHeightPercent
,
rightTextScrollTopPercent
,
rightTextScrollHeightPercent
,
leftRenderScrollTopPercent
,
leftRenderScrollHeightPercent
,
rightRenderScrollTopPercent
,
rightRenderScrollHeightPercent
,
startDragScroll
,
onLeftTextScroll
,
onRightTextScroll
,
onLeftScroll
,
onRightScroll
,
editingOldRange
,
editingNewRange
,
editingOldRangeText
,
editingNewRangeText
,
toggleEditOld
,
toggleEditNew
,
saveEditRange
,
cancelEditRange
}
=
useCompareModal
()
const
isTextExpanded
=
ref
(
true
)
const
isRenderExpanded
=
ref
(
true
)
const
handleTextExpandedChange
=
(
val
:
boolean
)
=>
{
if
(
!
val
&&
!
isRenderExpanded
.
value
)
{
window
.
$message
.
warning
(
'请至少保留一个对比区域'
)
return
}
isTextExpanded
.
value
=
val
}
const
handleRenderExpandedChange
=
(
val
:
boolean
)
=>
{
if
(
!
val
&&
!
isTextExpanded
.
value
)
{
window
.
$message
.
warning
(
'请至少保留一个对比区域'
)
return
}
isRenderExpanded
.
value
=
val
}
// 注入 diffMode: true 到下级所有的 DocNodeRenderer 中
provide
(
'diffMode'
,
true
)
defineExpose
({
open
})
</
script
>
<
style
>
.app-modal.is-fullscreen
.scroll-container
{
background-image
:
radial-gradient
(
circle
,
rgba
(
0
,
0
,
0
,
0.015
)
1px
,
transparent
1px
);
background-size
:
24px
24px
;
}
/* 覆盖 n-input 文本域的高度以填充容器 */
.app-modal.is-fullscreen
.n-input.n-input--textarea
{
height
:
100%
!important
;
}
.app-modal.is-fullscreen
.n-input.n-input--textarea
.n-input-wrapper
{
height
:
100%
!important
;
}
.app-modal.is-fullscreen
.n-input.n-input--textarea
textarea
{
height
:
100%
!important
;
flex
:
1
!important
;
}
/* 比对模式样式覆写 */
.is-diff-mode
[
contenteditable
]
{
pointer-events
:
none
!important
;
user-select
:
text
!important
;
}
.is-diff-mode
.ring-2
,
.is-diff-mode
.ring-primary
,
.is-diff-mode
.focus
\
:bg-fill-3
{
box-shadow
:
none
!important
;
background-color
:
transparent
!important
;
}
/* 移除 hover 背景色 */
.is-diff-mode
.doc-node-wrapper
:hover
{
background-color
:
transparent
!important
;
}
/* 隐藏对比弹窗内滚动容器的原生垂直和水平滚动条,只保留我们自定义的模拟滑块 */
.compare-modal-content
.scroll-container
{
scrollbar-width
:
none
!important
;
/* Firefox */
-ms-overflow-style
:
none
!important
;
/* IE/Edge */
}
.compare-modal-content
.scroll-container
::-webkit-scrollbar
{
width
:
0
!important
;
height
:
0
!important
;
display
:
none
!important
;
/* Chrome/Safari */
}
/* 差异标记样式 */
.is-diff-mode
[
data-diff-status
=
'added'
]
{
background-color
:
color-mix
(
in
srgb
,
var
(
--success-color
)
8%
,
transparent
)
!important
;
border-left
:
3px
solid
var
(
--success-color
)
!important
;
padding-left
:
4px
!important
;
margin-left
:
-7px
!important
;
}
.is-diff-mode
[
data-diff-status
=
'removed'
]
{
background-color
:
color-mix
(
in
srgb
,
var
(
--error-color
)
8%
,
transparent
)
!important
;
border-left
:
3px
solid
var
(
--error-color
)
!important
;
text-decoration
:
line-through
!important
;
opacity
:
0.75
!important
;
padding-left
:
4px
!important
;
margin-left
:
-7px
!important
;
}
.is-diff-mode
[
data-diff-status
=
'modified'
]
{
background-color
:
color-mix
(
in
srgb
,
var
(
--warning-color
)
12%
,
transparent
)
!important
;
box-shadow
:
inset
3px
0
0
var
(
--warning-color
),
0
0
0
1.5px
color-mix
(
in
srgb
,
var
(
--warning-color
)
40%
,
transparent
)
!important
;
border-radius
:
2px
!important
;
padding
:
0
2px
!important
;
}
/* block 元素补充左侧缩进,避免 box-shadow 被裁剪 */
div
.doc-node-wrapper
[
data-diff-status
=
'modified'
]
{
margin-left
:
-5px
!important
;
padding-left
:
5px
!important
;
}
/* 新的 XML 文本比对行级差异高亮 */
.line-row-removed
{
background-color
:
color-mix
(
in
srgb
,
var
(
--error-color
)
10%
,
transparent
)
!important
;
border-left
:
3px
solid
var
(
--error-color
)
!important
;
color
:
color-mix
(
in
srgb
,
var
(
--error-color
)
90%
,
var
(
--text-color-1
))
!important
;
}
.line-row-added
{
background-color
:
color-mix
(
in
srgb
,
var
(
--success-color
)
10%
,
transparent
)
!important
;
border-left
:
3px
solid
var
(
--success-color
)
!important
;
color
:
color-mix
(
in
srgb
,
var
(
--success-color
)
90%
,
var
(
--text-color-1
))
!important
;
}
.line-row-empty
{
background-color
:
color-mix
(
in
srgb
,
var
(
--warning-color
)
5%
,
transparent
)
!important
;
background-image
:
repeating-linear-gradient
(
-45deg
,
color-mix
(
in
srgb
,
var
(
--warning-color
)
15%
,
transparent
)
0
,
color-mix
(
in
srgb
,
var
(
--warning-color
)
15%
,
transparent
)
1.5px
,
transparent
0
,
transparent
8px
)
!important
;
background-size
:
12px
12px
;
user-select
:
none
!important
;
}
/* XML行选中对应的工卡渲染节点高亮闪烁效果 */
@keyframes
highlight-glow
{
0
%
{
box-shadow
:
0
0
0
0px
color-mix
(
in
srgb
,
var
(
--warning-color
)
40%
,
transparent
);
background-color
:
color-mix
(
in
srgb
,
var
(
--warning-color
)
10%
,
transparent
)
!important
;
}
50
%
{
box-shadow
:
0
0
10px
4px
color-mix
(
in
srgb
,
var
(
--warning-color
)
60%
,
transparent
);
background-color
:
color-mix
(
in
srgb
,
var
(
--warning-color
)
25%
,
transparent
)
!important
;
}
100
%
{
box-shadow
:
0
0
0
0px
color-mix
(
in
srgb
,
var
(
--warning-color
)
40%
,
transparent
);
background-color
:
color-mix
(
in
srgb
,
var
(
--warning-color
)
10%
,
transparent
)
!important
;
}
}
.highlight-focused-node
{
animation
:
highlight-glow
1.5s
ease-in-out
infinite
;
border-radius
:
4px
;
transition
:
all
0.3s
ease
;
}
/* 隐藏比对弹框的头部 */
.app-modal.compare-modal-nobar
>
.n-card-header
{
display
:
none
!important
;
}
/* XML 文本区默认样式与展开/收起过渡 */
.compare-text-zone
{
flex
:
1
1
0%
!important
;
min-height
:
180px
!important
;
max-height
:
320px
!important
;
margin-bottom
:
12px
!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
,
margin-bottom
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
;
margin-bottom
:
0
!important
;
}
/* 当文本区收起时:不透明度立即淡出,尺寸延迟 0.08s 收折 */
.compare-text-zone.is-collapsed
{
flex
:
0
0
0%
!important
;
max-height
:
0
!important
;
min-height
:
0
!important
;
margin-bottom
:
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
,
margin-bottom
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
;
}
.compare-minimap-slider
{
background-color
:
color-mix
(
in
srgb
,
var
(
--primary-color
)
15%
,
transparent
);
border
:
1px
solid
color-mix
(
in
srgb
,
var
(
--primary-color
)
20%
,
transparent
);
border-radius
:
4px
;
transition
:
background-color
0.15s
,
border-color
0.15s
;
}
.compare-minimap-slider
:hover
{
background-color
:
color-mix
(
in
srgb
,
var
(
--primary-color
)
25%
,
transparent
);
}
.compare-minimap-slider
:active
{
background-color
:
color-mix
(
in
srgb
,
var
(
--primary-color
)
35%
,
transparent
);
}
</
style
>
<
script
setup
lang=
"ts"
></
script
>
<
style
lang=
"less"
scoped
></
style
>
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment