Commit f837beb0 by pangchong

feat(editor): 优化节点列表展示与右键操作源节点标识

- CommonNodeDetailList 组件支持高亮检索匹配文本显示
- 优化节点列表祖先、子孙、同辈节点的收集逻辑,层级展示更合理
- 添加 sourceNodeId 属性以标记右键操作的源节点
- EditAreaContextMenuModal 使用 sourceNodeName 替代 activeNodeName 展示右键发起节点名称
- FindReplacePanel 传入高亮关键词支持节点名称高亮显示
- 统一调整模板代码格式,提升可读性
- 细节优化选中项滚动和动画触发逻辑
parent 29c7bfa7
...@@ -9,17 +9,19 @@ ...@@ -9,17 +9,19 @@
<!-- 撑开高度以生成物理滚动条的占位容器 --> <!-- 撑开高度以生成物理滚动条的占位容器 -->
<div :style="{ height: totalHeight + 'px', position: 'relative', width: '100%' }"> <div :style="{ height: totalHeight + 'px', position: 'relative', width: '100%' }">
<!-- 实际渲染可视切片列表 neighborhood 的容器 --> <!-- 实际渲染可视切片列表 neighborhood 的容器 -->
<div :style="{ <div
transform: `translateY(${offsetY}px)`, :style="{
position: 'absolute', transform: `translateY(${offsetY}px)`,
left: 0, position: 'absolute',
right: 0, left: 0,
display: 'flex', right: 0,
flexDirection: 'column', display: 'flex',
gap: (dense ? 4 : 6) + 'px', flexDirection: 'column',
padding: (dense ? 4 : 8) + 'px', gap: (dense ? 4 : 6) + 'px',
boxSizing: 'border-box' padding: (dense ? 4 : 8) + 'px',
}"> boxSizing: 'border-box'
}"
>
<div <div
v-for="item in visibleItems" v-for="item in visibleItems"
:key="item.id" :key="item.id"
...@@ -32,7 +34,7 @@ ...@@ -32,7 +34,7 @@
:style="{ :style="{
height: (dense ? 38 : 50) + 'px', height: (dense ? 38 : 50) + 'px',
boxSizing: 'border-box', boxSizing: 'border-box',
padding: (dense ? '2px 6px' : '6px 8px') padding: dense ? '2px 6px' : '6px 8px'
}" }"
@click="handleItemClick(item)" @click="handleItemClick(item)"
@animationend="onAnimationEnd" @animationend="onAnimationEnd"
...@@ -50,8 +52,17 @@ ...@@ -50,8 +52,17 @@
<!-- 3. 内容区 --> <!-- 3. 内容区 -->
<div class="flex-1 min-w-0 flex flex-col select-none justify-center h-full"> <div class="flex-1 min-w-0 flex flex-col select-none justify-center h-full">
<div class="flex items-center justify-between min-w-0"> <div class="flex items-center justify-between min-w-0">
<span class="node-item-title font-bold truncate text-color1" :title="item.displayName"> <span
{{ item.displayName }} class="node-item-title font-bold truncate text-color1"
:title="item.displayName"
v-html="getHighlightedHtml(item.displayName || '')"
></span>
<span
v-if="sourceNodeId && item.id === sourceNodeId"
class="ml-2 px-1.5 py-0.5 text-[9px] font-bold rounded bg-primary/10 text-primary border border-primary/20 shrink-0 select-none"
>
右键发起
</span> </span>
<!-- 局部操作状态插槽,例如翻译中动画等 --> <!-- 局部操作状态插槽,例如翻译中动画等 -->
...@@ -64,7 +75,9 @@ ...@@ -64,7 +75,9 @@
</span> </span>
<!-- 祖先级联位置 --> <!-- 祖先级联位置 -->
<span v-if="item.pathString" :title="item.pathString" class="node-item-path text-color3 opacity-80 truncate mt-0.5">位置: {{ item.pathString }}</span> <span v-if="item.pathString" :title="item.pathString" class="node-item-path text-color3 opacity-80 truncate mt-0.5">
位置: {{ item.pathString }}
</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -95,6 +108,8 @@ interface Props { ...@@ -95,6 +108,8 @@ interface Props {
selectedId?: string selectedId?: string
maxHeight?: number | string maxHeight?: number | string
activeMatchIndex?: number activeMatchIndex?: number
highlight?: string
sourceNodeId?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -102,7 +117,9 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -102,7 +117,9 @@ const props = withDefaults(defineProps<Props>(), {
dense: false, dense: false,
mode: 'readonly', mode: 'readonly',
maxHeight: 240, maxHeight: 240,
activeMatchIndex: -1 activeMatchIndex: -1,
highlight: '',
sourceNodeId: ''
}) })
const emit = defineEmits<{ const emit = defineEmits<{
...@@ -132,6 +149,26 @@ const computedMaxHeight = computed(() => { ...@@ -132,6 +149,26 @@ const computedMaxHeight = computed(() => {
return '240px' return '240px'
}) })
// ── 安全转义 HTML ──
const escapeHtml = (unsafe: string) => {
return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
// ── 检索词高亮渲染 ──
const getHighlightedHtml = (text: string) => {
if (!props.highlight || !props.highlight.trim()) {
return escapeHtml(text)
}
const q = props.highlight.trim()
const escapedQuery = q.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')
try {
const reg = new RegExp(`(${escapedQuery})`, 'gi')
return escapeHtml(text).replace(reg, '<mark class="bg-warning/20 text-warning font-bold px-0.5 rounded">$1</mark>')
} catch (e) {
return escapeHtml(text)
}
}
// ── 格式化节点显示文本 ── // ── 格式化节点显示文本 ──
const getNodeDisplayName = (node: XmlNode): string => { const getNodeDisplayName = (node: XmlNode): string => {
let suffix = '' let suffix = ''
...@@ -199,7 +236,7 @@ const processedItems = computed(() => { ...@@ -199,7 +236,7 @@ const processedItems = computed(() => {
// 情况 A-2:回溯整棵树链并收集其周边的兄弟节点、子孙节点以拓展操作目标 // 情况 A-2:回溯整棵树链并收集其周边的兄弟节点、子孙节点以拓展操作目标
if (!props.flat && props.nodeIds && props.nodeIds.length > 0) { if (!props.flat && props.nodeIds && props.nodeIds.length > 0) {
let leafId = localSelectedId.value || props.nodeIds[0] const leafId = props.nodeIds[0]
let baseRealId = leafId let baseRealId = leafId
if (leafId.includes('-txt-')) { if (leafId.includes('-txt-')) {
...@@ -236,15 +273,28 @@ const processedItems = computed(() => { ...@@ -236,15 +273,28 @@ const processedItems = computed(() => {
const baseNode = baseMapped.node const baseNode = baseMapped.node
const list: any[] = [] const list: any[] = []
// 🟢 A. 收集有限层级的祖先节点 (最多向上限制为 3 级,避免追溯到顶层无关的大根节点) // 🟢 A. 收集三代父辈节点 (自顶向下排列:曾爷爷 -> 爷爷 -> 爸爸,置于最顶层)
const activeIdsSet = new Set<string>() // props.nodeIds 包含 [自身, 爸爸, 爷爷, 曾爷爷, ...]
props.nodeIds.slice(0, 3).forEach((id) => { // 我们取 index 在 1 到 5 之间的最近四代父辈并反转,呈自顶向下顺序,避免追溯到更顶层无关的根节点
// 四代父辈,自身,三代子辈,同辈
const ancestorIds = props.nodeIds.slice(1, 5).reverse()
ancestorIds.forEach((id) => {
let rid = id let rid = id
if (id.includes('-txt-')) { if (id.includes('-txt-')) {
rid = id.split('-txt-')[0] rid = id.split('-txt-')[0]
} }
if (nodeMap.has(rid)) { if (rid !== baseRealId) {
activeIdsSet.add(rid) const mapped = nodeMap.get(rid)
if (mapped) {
const node = mapped.node
list.push({
id: id,
displayName: getNodeDisplayName(node),
pathString: mapped.parent ? getNodeParentPath(mapped.parent.id, nodeMap) : '无 (根节点)',
checked: false,
disabled: false
})
}
} }
}) })
...@@ -257,7 +307,45 @@ const processedItems = computed(() => { ...@@ -257,7 +307,45 @@ const processedItems = computed(() => {
disabled: false disabled: false
}) })
// 🟢 C. 添加上下的兄弟节点 // 🟢 C. 收集三代子辈 (儿子、孙子、曾孙子,置于自身之下)
const childLimit = 3
const grandsonLimit = 2
const greatGrandsonLimit = 2
baseNode.children.slice(0, childLimit).forEach((child) => {
// 1. 儿子辈
list.push({
id: child.id,
displayName: getNodeDisplayName(child),
pathString: getNodeParentPath(baseRealId, nodeMap),
checked: false,
disabled: false
})
child.children.slice(0, grandsonLimit).forEach((grand) => {
// 2. 孙子辈
list.push({
id: grand.id,
displayName: getNodeDisplayName(grand),
pathString: getNodeParentPath(child.id, nodeMap),
checked: false,
disabled: false
})
grand.children.slice(0, greatGrandsonLimit).forEach((great) => {
// 3. 曾孙子辈
list.push({
id: great.id,
displayName: getNodeDisplayName(great),
pathString: getNodeParentPath(grand.id, nodeMap),
checked: false,
disabled: false
})
})
})
})
// 🟢 D. 添加同级的兄弟节点 (置于最下方作为补充操作项)
const parentNode = baseMapped.parent const parentNode = baseMapped.parent
if (parentNode) { if (parentNode) {
const siblings = parentNode.children const siblings = parentNode.children
...@@ -282,45 +370,6 @@ const processedItems = computed(() => { ...@@ -282,45 +370,6 @@ const processedItems = computed(() => {
} }
} }
// 🟢 D. 收集子节点和孙节点 (防止列表过长)
const childCountLimit = 3
const grandsonCountLimit = 2
baseNode.children.slice(0, childCountLimit).forEach((child) => {
list.push({
id: child.id,
displayName: getNodeDisplayName(child),
pathString: getNodeParentPath(baseRealId, nodeMap),
checked: false,
disabled: false
})
child.children.slice(0, grandsonCountLimit).forEach((grand) => {
list.push({
id: grand.id,
displayName: getNodeDisplayName(grand),
pathString: getNodeParentPath(child.id, nodeMap),
checked: false,
disabled: false
})
})
})
// 🟢 E. 添加非自身的高优先级祖先节点
for (const id of activeIdsSet) {
if (id === baseRealId) continue
const mapped = nodeMap.get(id)
if (!mapped) continue
const node = mapped.node
list.push({
id: node.id,
displayName: getNodeDisplayName(node),
pathString: mapped.parent ? getNodeParentPath(mapped.parent.id, nodeMap) : '无 (根节点)',
checked: false,
disabled: false
})
}
// 🟢 F. 虚拟文本节点还原与精确定位插入 // 🟢 F. 虚拟文本节点还原与精确定位插入
if (leafId.includes('-txt-')) { if (leafId.includes('-txt-')) {
const textIdx = parseInt(leafId.split('-txt-')[1], 10) const textIdx = parseInt(leafId.split('-txt-')[1], 10)
...@@ -463,24 +512,31 @@ const scrollToSelected = (id: string) => { ...@@ -463,24 +512,31 @@ const scrollToSelected = (id: string) => {
// ── 闪烁反馈动画(当索引改变时触发高亮项微动效,给用户切换确认) ── // ── 闪烁反馈动画(当索引改变时触发高亮项微动效,给用户切换确认) ──
const isFlashing = ref(false) const isFlashing = ref(false)
watch(() => props.activeMatchIndex, () => { watch(
isFlashing.value = false () => props.activeMatchIndex,
nextTick(() => { () => {
isFlashing.value = true isFlashing.value = false
}) nextTick(() => {
}) isFlashing.value = true
})
}
)
const onAnimationEnd = () => { const onAnimationEnd = () => {
isFlashing.value = false isFlashing.value = false
} }
watch(localSelectedId, (newId) => { watch(
if (!newId) return localSelectedId,
// 💡 如果是手动点击引起的选中项变更,则不执行滚动定位,防止画面闪动 (newId) => {
if (isManualClick.value) return if (!newId) return
nextTick(() => { // 💡 如果是手动点击引起的选中项变更,则不执行滚动定位,防止画面闪动
scrollToSelected(newId) if (isManualClick.value) return
}) nextTick(() => {
}, { immediate: true }) scrollToSelected(newId)
})
},
{ immediate: true }
)
</script> </script>
<style scoped> <style scoped>
......
...@@ -199,6 +199,22 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -199,6 +199,22 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return editorStore.nodeMap.get(activeNodeId.value)?.node ?? null return editorStore.nodeMap.get(activeNodeId.value)?.node ?? null
}) })
// 最初右键发起的源节点名称 (固定不变)
const sourceNodeName = computed(() => {
if (!props.nodeIds || props.nodeIds.length === 0) return '未知'
const leafId = props.nodeIds[0]
let realId = leafId
if (leafId.includes('-txt-')) {
realId = leafId.split('-txt-')[0]
}
const mapped = editorStore.nodeMap.get(realId)
if (!mapped) return '未知'
if (leafId.includes('-txt-')) {
return '文本内容'
}
return mapped.node.tagName
})
// 当前选中的节点名称 // 当前选中的节点名称
const activeNodeName = computed(() => { const activeNodeName = computed(() => {
const node = activeNode.value const node = activeNode.value
...@@ -491,6 +507,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -491,6 +507,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
activeNodeId, activeNodeId,
activeNode, activeNode,
activeNodeName, activeNodeName,
sourceNodeName,
isTextNode, isTextNode,
canTranslateActive, canTranslateActive,
canDeleteActive, canDeleteActive,
......
<template> <template>
<CommonModal v-model="show" :title="`操作节点 - ${activeNodeName}`" :width="800" :show-footer="false" :scrollable="false"> <CommonModal v-model="show" :title="`操作节点 - ${sourceNodeName}`" :width="800" :show-footer="false" :scrollable="false">
<div class="space-y-4"> <div class="space-y-4">
<!-- 顶部节点层级选择 --> <!-- 顶部节点层级选择 -->
<div> <div>
<div class="text-xs text-color3 mb-2 font-medium">节点结构(点击切换操作目标):</div> <div class="text-xs text-color3 mb-2 font-medium">节点结构(点击切换操作目标):</div>
<CommonNodeDetailList v-model:selected-id="activeNodeId" :node-ids="nodeIds" mode="radio" max-height="500"> <CommonNodeDetailList
v-model:selected-id="activeNodeId"
:node-ids="nodeIds"
:source-node-id="nodeIds[0]"
mode="radio"
max-height="300"
>
<template #extra="{ item }"> <template #extra="{ item }">
<!-- 局部翻译中动画 --> <!-- 局部翻译中动画 -->
<div v-if="isTranslating(item.id)" class="flex items-center space-x-1 text-primary text-xs shrink-0 pl-2"> <div v-if="isTranslating(item.id)" class="flex items-center space-x-1 text-primary text-xs shrink-0 pl-2">
...@@ -156,6 +162,7 @@ const show = computed({ ...@@ -156,6 +162,7 @@ const show = computed({
const { const {
activeNodeId, activeNodeId,
activeNodeName, activeNodeName,
sourceNodeName,
isTextNode, isTextNode,
canTranslateActive, canTranslateActive,
canDeleteActive, canDeleteActive,
......
...@@ -126,6 +126,7 @@ ...@@ -126,6 +126,7 @@
:max-height="isExpanded ? 480 : 240" :max-height="isExpanded ? 480 : 240"
:active-match-index="currentMatchIndex" :active-match-index="currentMatchIndex"
:node-match-stats="nodeMatchStats" :node-match-stats="nodeMatchStats"
:highlight="findQuery"
/> />
</div> </div>
</div> </div>
......
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