Commit f837beb0 by pangchong

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

- CommonNodeDetailList 组件支持高亮检索匹配文本显示
- 优化节点列表祖先、子孙、同辈节点的收集逻辑,层级展示更合理
- 添加 sourceNodeId 属性以标记右键操作的源节点
- EditAreaContextMenuModal 使用 sourceNodeName 替代 activeNodeName 展示右键发起节点名称
- FindReplacePanel 传入高亮关键词支持节点名称高亮显示
- 统一调整模板代码格式,提升可读性
- 细节优化选中项滚动和动画触发逻辑
parent 29c7bfa7
......@@ -9,7 +9,8 @@
<!-- 撑开高度以生成物理滚动条的占位容器 -->
<div :style="{ height: totalHeight + 'px', position: 'relative', width: '100%' }">
<!-- 实际渲染可视切片列表 neighborhood 的容器 -->
<div :style="{
<div
:style="{
transform: `translateY(${offsetY}px)`,
position: 'absolute',
left: 0,
......@@ -19,7 +20,8 @@
gap: (dense ? 4 : 6) + 'px',
padding: (dense ? 4 : 8) + 'px',
boxSizing: 'border-box'
}">
}"
>
<div
v-for="item in visibleItems"
:key="item.id"
......@@ -32,7 +34,7 @@
:style="{
height: (dense ? 38 : 50) + 'px',
boxSizing: 'border-box',
padding: (dense ? '2px 6px' : '6px 8px')
padding: dense ? '2px 6px' : '6px 8px'
}"
@click="handleItemClick(item)"
@animationend="onAnimationEnd"
......@@ -50,8 +52,17 @@
<!-- 3. 内容区 -->
<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">
<span class="node-item-title font-bold truncate text-color1" :title="item.displayName">
{{ item.displayName }}
<span
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>
<!-- 局部操作状态插槽,例如翻译中动画等 -->
......@@ -64,7 +75,9 @@
</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>
......@@ -95,6 +108,8 @@ interface Props {
selectedId?: string
maxHeight?: number | string
activeMatchIndex?: number
highlight?: string
sourceNodeId?: string
}
const props = withDefaults(defineProps<Props>(), {
......@@ -102,7 +117,9 @@ const props = withDefaults(defineProps<Props>(), {
dense: false,
mode: 'readonly',
maxHeight: 240,
activeMatchIndex: -1
activeMatchIndex: -1,
highlight: '',
sourceNodeId: ''
})
const emit = defineEmits<{
......@@ -132,6 +149,26 @@ const computedMaxHeight = computed(() => {
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 => {
let suffix = ''
......@@ -199,7 +236,7 @@ const processedItems = computed(() => {
// 情况 A-2:回溯整棵树链并收集其周边的兄弟节点、子孙节点以拓展操作目标
if (!props.flat && props.nodeIds && props.nodeIds.length > 0) {
let leafId = localSelectedId.value || props.nodeIds[0]
const leafId = props.nodeIds[0]
let baseRealId = leafId
if (leafId.includes('-txt-')) {
......@@ -236,15 +273,28 @@ const processedItems = computed(() => {
const baseNode = baseMapped.node
const list: any[] = []
// 🟢 A. 收集有限层级的祖先节点 (最多向上限制为 3 级,避免追溯到顶层无关的大根节点)
const activeIdsSet = new Set<string>()
props.nodeIds.slice(0, 3).forEach((id) => {
// 🟢 A. 收集三代父辈节点 (自顶向下排列:曾爷爷 -> 爷爷 -> 爸爸,置于最顶层)
// props.nodeIds 包含 [自身, 爸爸, 爷爷, 曾爷爷, ...]
// 我们取 index 在 1 到 5 之间的最近四代父辈并反转,呈自顶向下顺序,避免追溯到更顶层无关的根节点
// 四代父辈,自身,三代子辈,同辈
const ancestorIds = props.nodeIds.slice(1, 5).reverse()
ancestorIds.forEach((id) => {
let rid = id
if (id.includes('-txt-')) {
rid = id.split('-txt-')[0]
}
if (nodeMap.has(rid)) {
activeIdsSet.add(rid)
if (rid !== baseRealId) {
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,36 +307,13 @@ const processedItems = computed(() => {
disabled: false
})
// 🟢 C. 添加上下的兄弟节点
const parentNode = baseMapped.parent
if (parentNode) {
const siblings = parentNode.children
const selfIndex = siblings.findIndex((c) => c.id === baseRealId)
if (selfIndex !== -1) {
const siblingRange = 2
const startIdx = Math.max(0, selfIndex - siblingRange)
const endIdx = Math.min(siblings.length - 1, selfIndex + siblingRange)
for (let i = startIdx; i <= endIdx; i++) {
const sib = siblings[i]
if (sib.id !== baseRealId) {
list.push({
id: sib.id,
displayName: getNodeDisplayName(sib),
pathString: getNodeParentPath(parentNode.id, nodeMap),
checked: false,
disabled: false
})
}
}
}
}
// 🟢 D. 收集子节点和孙节点 (防止列表过长)
const childCountLimit = 3
const grandsonCountLimit = 2
// 🟢 C. 收集三代子辈 (儿子、孙子、曾孙子,置于自身之下)
const childLimit = 3
const grandsonLimit = 2
const greatGrandsonLimit = 2
baseNode.children.slice(0, childCountLimit).forEach((child) => {
baseNode.children.slice(0, childLimit).forEach((child) => {
// 1. 儿子辈
list.push({
id: child.id,
displayName: getNodeDisplayName(child),
......@@ -295,7 +322,8 @@ const processedItems = computed(() => {
disabled: false
})
child.children.slice(0, grandsonCountLimit).forEach((grand) => {
child.children.slice(0, grandsonLimit).forEach((grand) => {
// 2. 孙子辈
list.push({
id: grand.id,
displayName: getNodeDisplayName(grand),
......@@ -303,23 +331,44 @@ const processedItems = computed(() => {
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
})
})
})
})
// 🟢 E. 添加非自身的高优先级祖先节点
for (const id of activeIdsSet) {
if (id === baseRealId) continue
const mapped = nodeMap.get(id)
if (!mapped) continue
const node = mapped.node
// 🟢 D. 添加同级的兄弟节点 (置于最下方作为补充操作项)
const parentNode = baseMapped.parent
if (parentNode) {
const siblings = parentNode.children
const selfIndex = siblings.findIndex((c) => c.id === baseRealId)
if (selfIndex !== -1) {
const siblingRange = 2
const startIdx = Math.max(0, selfIndex - siblingRange)
const endIdx = Math.min(siblings.length - 1, selfIndex + siblingRange)
for (let i = startIdx; i <= endIdx; i++) {
const sib = siblings[i]
if (sib.id !== baseRealId) {
list.push({
id: node.id,
displayName: getNodeDisplayName(node),
pathString: mapped.parent ? getNodeParentPath(mapped.parent.id, nodeMap) : '无 (根节点)',
id: sib.id,
displayName: getNodeDisplayName(sib),
pathString: getNodeParentPath(parentNode.id, nodeMap),
checked: false,
disabled: false
})
}
}
}
}
// 🟢 F. 虚拟文本节点还原与精确定位插入
if (leafId.includes('-txt-')) {
......@@ -463,24 +512,31 @@ const scrollToSelected = (id: string) => {
// ── 闪烁反馈动画(当索引改变时触发高亮项微动效,给用户切换确认) ──
const isFlashing = ref(false)
watch(() => props.activeMatchIndex, () => {
watch(
() => props.activeMatchIndex,
() => {
isFlashing.value = false
nextTick(() => {
isFlashing.value = true
})
})
}
)
const onAnimationEnd = () => {
isFlashing.value = false
}
watch(localSelectedId, (newId) => {
watch(
localSelectedId,
(newId) => {
if (!newId) return
// 💡 如果是手动点击引起的选中项变更,则不执行滚动定位,防止画面闪动
if (isManualClick.value) return
nextTick(() => {
scrollToSelected(newId)
})
}, { immediate: true })
},
{ immediate: true }
)
</script>
<style scoped>
......
......@@ -199,6 +199,22 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
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 node = activeNode.value
......@@ -491,6 +507,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
activeNodeId,
activeNode,
activeNodeName,
sourceNodeName,
isTextNode,
canTranslateActive,
canDeleteActive,
......
<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>
<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 }">
<!-- 局部翻译中动画 -->
<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({
const {
activeNodeId,
activeNodeName,
sourceNodeName,
isTextNode,
canTranslateActive,
canDeleteActive,
......
......@@ -126,6 +126,7 @@
:max-height="isExpanded ? 480 : 240"
:active-match-index="currentMatchIndex"
:node-match-stats="nodeMatchStats"
:highlight="findQuery"
/>
</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