Commit 047f00db by pangchong

refactor(editor): 重构编辑区右键菜单为函数式下拉菜单

- 将 EditAreaContextMenuModal 由模态窗口改为基于 Dropdown 的浮动下拉菜单
- 支持右键位置自适应,新增 contextMenuX、contextMenuY 用于定位菜单坐标
- 新增 TargetNodeItem 组件,支持目标节点层级列表字典树形选择与高亮
- 优化编辑节点菜单选项结构,包含节点编辑、结构调整、顺序调整、复制粘贴、智能翻译、查看规则等分组
- 实现目标节点切换逻辑,切换选中目标节点时同步更新左侧树视图选中状态
- 统一菜单项图标,提升菜单视觉一致性和用户体验
- 根据 DTD 规则及节点状态动态启用或禁用菜单项
- 移除了原有的 CommonModal 结构,简化组件实现与提升性能
- 调整 EditorPanel 组件代码,新增右键点击坐标传递及菜单弹出控制逻辑
- 修复节点标签和文本内容显示逻辑,完善标签名称与文本摘要展示
- 删除 xmlTags.ts 中未使用的 GRPHCREF 标签注释处理
parent 1e86e866
...@@ -31,7 +31,7 @@ export const INLINE_ELEMENTS = [ ...@@ -31,7 +31,7 @@ export const INLINE_ELEMENTS = [
'REFBLOCK', 'REFBLOCK',
'REFINT', 'REFINT',
'REFEXT', 'REFEXT',
'GRPHCREF', // 'GRPHCREF',
// 零件编号类 // 零件编号类
'EIN', 'EIN',
'EINMFR', 'EINMFR',
......
<template>
<div
class="target-node-item"
:class="{ 'is-selected': isSelected }"
:style="{ paddingLeft: `${(item.depth || 0) * 12 + 12}px` }"
:title="item.displayName"
>
<span class="check-space">
<span v-if="isSelected" class="check-icon"></span>
</span>
<span class="node-tag">{{ item.tagName }}</span>
<span v-if="item.suffix" class="node-suffix">{{ item.suffix }}</span>
</div>
</template>
<script setup lang="ts">
export interface TargetItem {
id: string
tagName: string
suffix: string
displayName: string
pathString: string
depth: number
}
defineProps<{
item: TargetItem
isSelected?: boolean
}>()
</script>
<style scoped>
.target-node-item {
display: flex;
align-items: center;
height: 32px;
line-height: 32px;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--n-text-color);
user-select: none;
margin: 0 -12px;
padding-right: 12px;
box-sizing: border-box;
border-radius: 3px;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.target-node-item.is-selected {
color: var(--n-primary-color);
background-color: var(--n-option-color-active, rgba(112, 91, 246, 0.12));
font-weight: 600;
}
.check-space {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-right: 4px;
flex-shrink: 0;
}
.check-icon {
color: var(--n-primary-color);
font-weight: 800;
font-size: 13px;
line-height: 1;
}
.node-tag {
font-weight: 600;
font-family: monospace, var(--n-font-family);
flex-shrink: 0;
}
.target-node-item.is-selected .node-tag {
color: var(--n-primary-color);
font-weight: 700;
}
.node-suffix {
margin-left: 6px;
font-weight: 400;
opacity: 0.65;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
max-width: 220px;
}
.target-node-item.is-selected .node-suffix {
color: var(--n-primary-color);
opacity: 0.9;
}
</style>
export interface EditAreaContextMenuProps { export interface EditAreaContextMenuProps {
modelValue: boolean modelValue: boolean
nodeIds: string[] nodeIds: string[]
x?: number
y?: number
} }
import type { DropdownOption } from 'naive-ui'
import TargetNodeItem from '../components/TargetNodeItem.vue'
import type { TargetItem } from '../components/TargetNodeItem.vue'
import {
CreateOutline,
CopyOutline,
ClipboardOutline,
TrashOutline,
BuildOutline,
SwapVerticalOutline,
LanguageOutline,
CodeWorkingOutline,
EyeOutline,
SaveOutline,
SyncOutline,
AddCircleOutline,
ArrowUpOutline,
ArrowDownOutline,
LayersOutline
} from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { EditAreaContextMenuProps } from '../constants' import type { EditAreaContextMenuProps } from '../constants'
import {
getElementRule,
canDeleteChild,
isMixedContentElement,
isTextOnlyElement,
getInsertableChildren,
sortChildrenByDtd,
checkCanMoveNode,
moveNodeInParent
} from '@/utils/dtdManager'
import { serializeTreeToXml } from '@/utils/xmlParser'
import { saveTemplate } from '@/utils/bridge'
// 导入共享弹窗状态 // 导入共享弹窗状态
import { import {
...@@ -18,6 +50,7 @@ import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags' ...@@ -18,6 +50,7 @@ import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (event: 'update:visible', val: boolean) => void) { export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (event: 'update:visible', val: boolean) => void) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const themeVars = useThemeVars()
const getEnglishSourceNode = (node: XmlNode, parent: XmlNode | null): XmlNode | null => { const getEnglishSourceNode = (node: XmlNode, parent: XmlNode | null): XmlNode | null => {
if (!parent || !node.tagName.endsWith('C')) return null if (!parent || !node.tagName.endsWith('C')) return null
...@@ -152,14 +185,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -152,14 +185,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
// 当前操作的目标节点 ID,默认是叶子节点(即列表中的最底层节点) // 当前操作的目标节点 ID,默认是叶子节点(即列表中的最底层节点)
const activeNodeId = ref<string>('') const activeNodeId = ref<string>('')
// 监听当前操作目标节点的变化,将其同步更新到全局 editorStore,以驱动左侧树菜单同步滚动和定位
watch(activeNodeId, (newId) => {
if (newId && editorStore.selectedNodeId !== newId) {
editorStore.setSelectedNodeId(newId)
}
})
// 当传入的 nodeIds 改变时,默认选择最底部的节点作为初始操作节点 // 当传入的 nodeIds 改变时,默认选择最底部的节点作为初始操作节点
// 注意:不在此处将 activeNodeId 同步到全局 setSelectedNodeId,避免菜单内切换层级时导致左侧树跳动
watch( watch(
() => props.nodeIds, () => props.nodeIds,
(ids) => { (ids) => {
...@@ -207,7 +234,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -207,7 +234,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const mapped = editorStore.nodeMap.get(realId) const mapped = editorStore.nodeMap.get(realId)
if (!mapped) return '未知' if (!mapped) return '未知'
if (leafId.includes('-txt-')) { if (leafId.includes('-txt-')) {
return '文本内容' return '#text'
} }
return mapped.node.tagName return mapped.node.tagName
}) })
...@@ -216,7 +243,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -216,7 +243,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const activeNodeName = computed(() => { const activeNodeName = computed(() => {
const node = activeNode.value const node = activeNode.value
if (!node) return '未知' if (!node) return '未知'
if (node.tagName === '#text') return '文本内容' if (node.tagName === '#text') return '#text'
return node.tagName return node.tagName
}) })
...@@ -293,32 +320,38 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -293,32 +320,38 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return checkCanMoveNode(parent, nodeId, isVirtual).canMoveDown return checkCanMoveNode(parent, nodeId, isVirtual).canMoveDown
}) })
// 根据 DTD 获取允许添加的子标签列表 // 根据 DTD 获取允许添加的子标签列表(完全对齐 NodeTree 筛选与 DTD 排序)
const allowedChildrenList = computed<string[]>(() => { const allowedChildrenList = computed<string[]>(() => {
const node = activeNode.value const node = activeNode.value
if (!node || isTextNode.value) return [] if (!node || isTextNode.value) return []
const rule = getElementRule(node.tagName) const existing = node.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
if (!rule || !rule.allowedChildren) return [] let list = getInsertableChildren(node.tagName, existing)
return rule.allowedChildren if (isMixedContentElement(node.tagName) || isTextOnlyElement(node.tagName)) {
if (!list.includes('#text')) list.push('#text')
}
return sortChildrenByDtd(node.tagName, list)
}) })
// 根据 DTD 获取允许包裹或插入的父级/兄弟标签列表(取其父级的可接受子节点 // 根据 DTD 获取允许包裹或插入的父级/兄弟标签列表(完全对齐 NodeTree 筛选与 DTD 排序
const insertableParentList = computed<string[]>(() => { const insertableParentList = computed<string[]>(() => {
const nodeId = activeNodeId.value const nodeId = activeNodeId.value
if (!nodeId) return [] if (!nodeId) return []
let realId = nodeId const isVirtual = nodeId.includes('-txt-')
if (nodeId.includes('-txt-')) { const realId = isVirtual ? nodeId.split('-txt-')[0] : nodeId
realId = nodeId.split('-txt-')[0]
}
const mapped = editorStore.nodeMap.get(realId) const mapped = editorStore.nodeMap.get(realId)
const parentNode = mapped?.parent if (!mapped) return []
const parentNode = isVirtual ? mapped.node : mapped.parent
if (!parentNode) return [] if (!parentNode) return []
const rule = getElementRule(parentNode.tagName) const existing = parentNode.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
if (!rule || !rule.allowedChildren) return [] let list = getInsertableChildren(parentNode.tagName, existing)
return rule.allowedChildren if (isMixedContentElement(parentNode.tagName) || isTextOnlyElement(parentNode.tagName)) {
if (!list.includes('#text')) list.push('#text')
}
return sortChildrenByDtd(parentNode.tagName, list)
}) })
// 剪贴板中是否有缓存的已复制节点 // 剪贴板中是否有缓存的已复制节点
...@@ -333,6 +366,17 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -333,6 +366,17 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
// 核心动作处理 // 核心动作处理
const runAction = async (actionName: string) => { const runAction = async (actionName: string) => {
if (actionName.startsWith('switchTargetNode:')) {
// 用户主动切换目标层级:更新内部目标节点,并同步高亮左侧树以便识别
const targetId = actionName.replace('switchTargetNode:', '')
if (targetId) {
activeNodeId.value = targetId
// 同步左侧树高亮,让用户能看清当前切换到哪个树节点
editorStore.setSelectedNodeId(targetId)
}
return
}
const nodeId = activeNodeId.value const nodeId = activeNodeId.value
if (!nodeId) return if (!nodeId) return
...@@ -541,17 +585,17 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -541,17 +585,17 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
// 10. 编辑结构 // 10. 编辑结构
else if (actionName === 'addChildNode') { else if (actionName === 'addChildNode') {
addNodeTargetId.value = realId addNodeTargetId.value = activeNodeId.value || realId
addNodeMode.value = 'child' addNodeMode.value = 'child'
addNodeAllowedTags.value = allowedChildrenList.value addNodeAllowedTags.value = allowedChildrenList.value
addNodeVisible.value = true addNodeVisible.value = true
} else if (actionName === 'insertBefore') { } else if (actionName === 'insertBefore') {
addNodeTargetId.value = realId addNodeTargetId.value = activeNodeId.value || realId
addNodeMode.value = 'before' addNodeMode.value = 'before'
addNodeAllowedTags.value = insertableParentList.value addNodeAllowedTags.value = insertableParentList.value
addNodeVisible.value = true addNodeVisible.value = true
} else if (actionName === 'insertAfter') { } else if (actionName === 'insertAfter') {
addNodeTargetId.value = realId addNodeTargetId.value = activeNodeId.value || realId
addNodeMode.value = 'after' addNodeMode.value = 'after'
addNodeAllowedTags.value = insertableParentList.value addNodeAllowedTags.value = insertableParentList.value
addNodeVisible.value = true addNodeVisible.value = true
...@@ -626,31 +670,413 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -626,31 +670,413 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
} }
} }
const pasteXmlOptions = computed(() => [ // ── 格式化节点显示信息(拆分标签名与内容后缀)──
{ label: '粘贴到上方', key: 'insertFragmentAbove', disabled: !insertableParentList.value.length }, const getNodeItemInfo = (node: XmlNode, rawId?: string) => {
{ label: '粘贴到下方', key: 'insertFragmentBelow', disabled: !insertableParentList.value.length }, if (rawId && rawId.includes('-txt-')) {
{ label: '粘贴为子节点', key: 'insertFragmentInside', disabled: isTextNode.value || !allowedChildrenList.value.length } const realParentId = rawId.split('-txt-')[0]
]) const textIdx = parseInt(rawId.split('-txt-')[1], 10)
const parentItem = editorStore.nodeMap.get(realParentId)
const editStructureOptions = computed(() => { if (parentItem && Array.isArray(parentItem.node.mixedContent)) {
const disableChild = isTextNode.value ? !insertableParentList.value.length : !allowedChildrenList.value.length const mcItem = parentItem.node.mixedContent[textIdx] as any
return [ const text = (mcItem?.type === 'text' ? mcItem.text : '')?.trim() || ''
{ label: isTextNode.value ? '插入行内元素' : '添加子节点', key: 'addChildNode', disabled: disableChild }, const suffix = text ? `"${text}"` : ''
{ label: '插入到上方', key: 'insertBefore', disabled: !insertableParentList.value.length }, return {
{ label: '插入到下方', key: 'insertAfter', disabled: !insertableParentList.value.length } tagName: '#text',
] suffix,
displayName: suffix ? `#text ${suffix}` : '#text'
}
}
}
if (!node) return { tagName: '未知', suffix: '', displayName: '未知' }
if (node.tagName === '#text') {
const text = node.textContent?.trim() || ''
const suffix = text ? `"${text}"` : ''
return {
tagName: '#text',
suffix,
displayName: suffix ? `#text ${suffix}` : '#text'
}
}
let suffix = ''
const attrParts: string[] = []
if (node.attributes?.ID) {
attrParts.push(node.attributes.ID)
}
if (node.attributes?.EFFRG) {
const eff = node.attributes.EFFRG.replace(/\s+/g, '')
if (eff !== '001999') attrParts.push(`A/C: ${eff}`)
}
if (node.attributes?.REFID) {
attrParts.push(`REFID: ${node.attributes.REFID}`)
}
// 提取节点的直接文本内容 preview(当仅包含 1 个文本段时,将其合并至元素节点本行展示;包含多个时交由子 #text 节点拆分)
let directText = ''
if (node.mixedContent && node.mixedContent.length > 0) {
const textItems = node.mixedContent.filter((m) => m.type === 'text' && (m.text || '').trim())
if (textItems.length === 1) {
directText = (textItems[0].text || '').trim()
}
} else if (!node.children || node.children.length === 0) {
directText = (node.textContent || '').trim()
}
if (directText) {
const shortTxt = directText.length > 40 ? directText.substring(0, 40) + '...' : directText
attrParts.push(`"${shortTxt}"`)
}
suffix = attrParts.join(' ')
const tagName = `<${node.tagName}>`
return {
tagName,
suffix,
displayName: suffix ? `${tagName} ${suffix}` : tagName
}
}
// ── 构建目标节点切换列表(复刻 CommonNodeDetailList.vue A-2 回溯算法,附带路径面包屑)──
const targetSwitchItems = computed<Array<TargetItem>>(() => {
const nodeMap = editorStore.nodeMap
if (!props.nodeIds || props.nodeIds.length === 0) return []
const leafId = props.nodeIds[0]
let baseRealId = leafId.includes('-txt-') ? leafId.split('-txt-')[0] : leafId
const baseMapped = nodeMap.get(baseRealId)
if (!baseMapped) return []
const baseNode = baseMapped.node
const list: Array<TargetItem> = []
const addedIds = new Set<string>()
// 获取节点的祖先路径字符串与深度
const getAncestorInfo = (nodeId: string) => {
const realId = nodeId.includes('-txt-') ? nodeId.split('-txt-')[0] : nodeId
let mapped = nodeMap.get(realId)
const path: string[] = []
let depth = nodeId.includes('-txt-') ? 1 : 0
while (mapped?.parent) {
depth += 1
path.unshift(mapped.parent.tagName)
mapped = nodeMap.get(mapped.parent.id)
}
return {
pathString: path.join(' › '),
depth
}
}
const addItem = (id: string, node: XmlNode, rawId?: string) => {
if (!addedIds.has(id)) {
addedIds.add(id)
const info = getNodeItemInfo(node, rawId)
const anc = getAncestorInfo(id)
list.push({
id,
tagName: info.tagName,
suffix: info.suffix,
displayName: info.displayName,
pathString: anc.pathString,
depth: anc.depth
})
}
}
// 递归按 DOM 真实顺序展开节点的下层 mixedContent / children (最多递归 2 层)
const addNodeWithChildren = (node: XmlNode, currentDepthLevel: number = 0, maxDepthLevel: number = 2) => {
if (node.mixedContent && node.mixedContent.length > 0) {
const textItems = node.mixedContent.filter((m) => m.type === 'text' && (m.text || '').trim())
const singleTextNodeMode = textItems.length === 1
node.mixedContent.forEach((mc, idx) => {
if (mc.type === 'text') {
// 若为单文本段模式,该文本已在父元素节点本行展示,不再生成独立的子 #text 节点行
if (singleTextNodeMode) return
const textVal = (mc.text || '').trim()
if (textVal) {
const vId = `${node.id}-txt-${idx}`
addItem(vId, node, vId)
}
} else if (mc.type === 'element' && mc.nodeId) {
const childMapped = nodeMap.get(mc.nodeId)
if (childMapped) {
addItem(childMapped.node.id, childMapped.node)
if (currentDepthLevel < maxDepthLevel) {
addNodeWithChildren(childMapped.node, currentDepthLevel + 1, maxDepthLevel)
}
}
}
})
} else if (node.children && node.children.length > 0) {
node.children.slice(0, 5).forEach((child) => {
addItem(child.id, child)
if (currentDepthLevel < maxDepthLevel) {
addNodeWithChildren(child, currentDepthLevel + 1, maxDepthLevel)
}
})
}
}
// A. 祖先节点(自 nodeIds 链取最多 4 层,自顶向下排列)
const ancestorIds = props.nodeIds
.filter((id) => !id.includes('-txt-'))
.slice(1, 5)
.reverse()
ancestorIds.forEach((id) => {
if (id !== baseRealId) {
const mapped = nodeMap.get(id)
if (mapped) addItem(id, mapped.node)
}
})
// 获取父节点下的所有同级子项(包含 XML 元素与 mixedContent 虚拟文本节点)
const getParentItems = (parent: XmlNode) => {
const items: Array<{ id: string; node: XmlNode; rawId?: string }> = []
if (parent.mixedContent && parent.mixedContent.length > 0) {
const textItems = parent.mixedContent.filter((m) => m.type === 'text' && (m.text || '').trim())
const singleTextMode = textItems.length === 1
parent.mixedContent.forEach((mc, idx) => {
if (mc.type === 'text') {
if (!singleTextMode) {
const textVal = (mc.text || '').trim()
if (textVal) {
const vId = `${parent.id}-txt-${idx}`
items.push({ id: vId, node: parent, rawId: vId })
}
}
} else if (mc.type === 'element' && mc.nodeId) {
const childMapped = nodeMap.get(mc.nodeId)
if (childMapped) {
items.push({ id: childMapped.node.id, node: childMapped.node })
}
}
})
} else if (parent.children && parent.children.length > 0) {
parent.children.forEach((child) => {
items.push({ id: child.id, node: child })
})
}
return items
}
// B. 同级节点与基础目标节点(严格按父节点的 mixedContent / children DOM 真实顺序自上而下排列)
const parentNode = baseMapped.parent
if (parentNode) {
const parentItems = getParentItems(parentNode)
const selfIndex = parentItems.findIndex((item) => item.id === baseRealId || item.id === leafId)
if (selfIndex !== -1) {
const startIdx = Math.max(0, selfIndex - 4)
const endIdx = Math.min(parentItems.length - 1, selfIndex + 4)
for (let i = startIdx; i <= endIdx; i++) {
const item = parentItems[i]
addItem(item.id, item.node, item.rawId)
// 当遍历到基础目标节点时,紧接着按 DOM 真实顺序展开其直系 mixedContent / children
if (item.id === baseRealId) {
addNodeWithChildren(baseNode, 0, 2)
}
}
} else {
addItem(baseRealId, baseNode)
addNodeWithChildren(baseNode, 0, 2)
}
} else {
addItem(baseRealId, baseNode)
addNodeWithChildren(baseNode, 0, 2)
}
// 计算相对 depth
if (list.length > 0) {
const minDepth = Math.min(...list.map((i) => i.depth))
list.forEach((i) => {
i.depth = Math.max(0, i.depth - minDepth)
})
}
return list
}) })
const editOrderOptions = computed(() => [ const icon = (component: any) => () => h(NIcon, null, { default: () => h(component) })
{ label: '上移', key: 'moveUp', disabled: !canMoveUpActive.value },
{ label: '下移', key: 'moveDown', disabled: !canMoveDownActive.value }
])
const pasteNodeOptions = computed(() => [ const dropdownOptions = computed<DropdownOption[]>(() => {
{ label: '粘贴到上方', key: 'pasteNodeAbove', disabled: !insertableParentList.value.length }, const options: DropdownOption[] = []
{ label: '粘贴到下方', key: 'pasteNodeBelow', disabled: !insertableParentList.value.length },
{ label: '粘贴为子节点', key: 'pasteNodeInside', disabled: isTextNode.value || !allowedChildrenList.value.length } // 0. 目标节点层级切换 Header / 选项(使用树链回溯算法,单行树形缩进)
]) if (props.nodeIds && props.nodeIds.length > 0) {
const isTargetInList = targetSwitchItems.value.some((i) => i.id === activeNodeId.value)
const levelChildren: DropdownOption[] = targetSwitchItems.value.map((item) => {
const isSelected = item.id === activeNodeId.value || (activeNodeId.value.startsWith(item.id + '-txt-') && !isTargetInList)
return {
key: `switchTargetNode:${item.id}`,
// 由 TargetNodeItem 内部完整渲染单行树形缩进 + 打勾标识,避免样式错乱
label: () => h(TargetNodeItem, { item, isSelected })
}
})
options.push({
label: () =>
h(
'span',
{
style: 'display: inline-block; max-width: 85px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;',
title: `目标: ${activeNodeName.value}`
},
`目标: ${activeNodeName.value}`
),
key: 'headerTarget',
icon: icon(LayersOutline),
children: levelChildren.length > 1 ? levelChildren : undefined
})
options.push({ type: 'divider', key: 'd0' })
}
// 虚拟排版节点(如分页符)精简菜单
if (isVirtualLayoutNode.value) {
options.push({
label: '查看 XML',
key: 'viewXml',
icon: icon(CodeWorkingOutline)
})
options.push({
label: '删除节点',
key: 'deleteNode',
icon: icon(TrashOutline),
disabled: !canDeleteActive.value
})
return options
}
// 1. 编辑节点
options.push({
label: isTextNode.value ? '编辑文本' : '编辑节点',
key: 'editNode',
icon: icon(CreateOutline)
})
// 2. 结构调整 (子菜单)
options.push({
label: '结构调整',
key: 'groupStructure',
icon: icon(BuildOutline),
children: [
{
label: '添加子节点',
key: 'addChildNode',
icon: icon(AddCircleOutline),
disabled: isTextNode.value || !allowedChildrenList.value.length
},
{
label: '插入到上方',
key: 'insertBefore',
icon: icon(ArrowUpOutline),
disabled: !insertableParentList.value.length
},
{
label: '插入到下方',
key: 'insertAfter',
icon: icon(ArrowDownOutline),
disabled: !insertableParentList.value.length
}
]
})
// 3. 顺序调整 (子菜单)
options.push({
label: '顺序调整',
key: 'groupOrder',
icon: icon(SwapVerticalOutline),
children: [
{
label: '上移',
key: 'moveUp',
icon: icon(ArrowUpOutline),
disabled: !canMoveUpActive.value
},
{
label: '下移',
key: 'moveDown',
icon: icon(ArrowDownOutline),
disabled: !canMoveDownActive.value
}
]
})
options.push({ type: 'divider', key: 'd1' })
// 4. 复制与粘贴
options.push({
label: '复制节点',
key: 'copyNode',
icon: icon(CopyOutline),
disabled: isTextNode.value
})
options.push({
label: '粘贴节点',
key: 'groupPasteNode',
icon: icon(ClipboardOutline),
disabled: !hasCopyCache.value,
children: [
{ label: '粘贴到上方', key: 'pasteNodeAbove', disabled: !insertableParentList.value.length },
{ label: '粘贴到下方', key: 'pasteNodeBelow', disabled: !insertableParentList.value.length },
{ label: '粘贴为子节点', key: 'pasteNodeInside', disabled: isTextNode.value || !allowedChildrenList.value.length }
]
})
options.push({
label: '粘贴 XML',
key: 'groupPasteXml',
icon: icon(CodeWorkingOutline),
disabled: !hasCopyCache.value,
children: [
{ label: '粘贴到上方', key: 'insertFragmentAbove', disabled: !insertableParentList.value.length },
{ label: '粘贴到下方', key: 'insertFragmentBelow', disabled: !insertableParentList.value.length },
{ label: '粘贴为子节点', key: 'insertFragmentInside', disabled: isTextNode.value || !allowedChildrenList.value.length }
]
})
options.push({ type: 'divider', key: 'd2' })
// 5. 智能翻译
options.push({
label: isTranslating(activeNodeId.value) ? '翻译中...' : '智能翻译',
key: 'translateNode',
icon: isTranslating(activeNodeId.value) ? icon(SyncOutline) : icon(LanguageOutline),
disabled: !canTranslateActive.value || isTranslating(activeNodeId.value)
})
// 6. 查看与规则
options.push({
label: '查看与规则',
key: 'groupView',
icon: icon(EyeOutline),
children: [
{ label: '查看 XML', key: 'viewXml', icon: icon(CodeWorkingOutline) },
{ label: '查看 DTD 规则', key: 'checkRule', icon: icon(EyeOutline), disabled: isTextNode.value }
]
})
// 7. 保存为模板
options.push({
label: '保存为模板',
key: 'saveTemplate',
icon: icon(SaveOutline)
})
options.push({ type: 'divider', key: 'd3' })
// 8. 删除节点
options.push({
label: '删除节点',
key: 'deleteNode',
icon: icon(TrashOutline),
disabled: !canDeleteActive.value
})
return options
})
return { return {
activeNodeId, activeNodeId,
...@@ -668,9 +1094,6 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -668,9 +1094,6 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
hasCopyCache, hasCopyCache,
isTranslating, isTranslating,
runAction, runAction,
pasteXmlOptions, dropdownOptions
editStructureOptions,
editOrderOptions,
pasteNodeOptions
} }
} }
<template> <template>
<CommonModal v-model="show" :title="`操作节点 - ${sourceNodeName}`" :width="800" :show-footer="false" :scrollable="false"> <n-dropdown
<div class="space-y-4"> placement="bottom-start"
<!-- 顶部节点层级选择 --> trigger="manual"
<div> :x="x"
<div class="text-xs text-color3 mb-2 font-medium">节点结构(点击切换操作目标):</div> :y="y"
<CommonNodeDetailList :show="show"
v-model:selected-id="activeNodeId" :options="dropdownOptions"
:node-ids="nodeIds" @clickoutside="show = false"
:source-node-id="nodeIds[0]" @select="handleSelect"
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">
<n-icon class="animate-spin"><sync-outline /></n-icon>
<span class="font-medium">翻译中...</span>
</div>
</template>
</CommonNodeDetailList>
</div>
<n-divider class="my-2" />
<!-- 底部操作网格 -->
<div>
<div class="text-xs text-color3 mb-2 font-medium">可用操作:</div>
<div class="space-y-2">
<div v-if="isVirtualLayoutNode" class="grid grid-cols-2 gap-2">
<!-- 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon>
<n-icon><trash-outline /></n-icon>
</template>
删除节点
</CommonButton>
<!-- 查看 XML 片段 -->
<CommonButton type="info" secondary block @click="runAction('viewXml')">
<template #icon>
<n-icon><code-working-outline /></n-icon>
</template>
查看 XML
</CommonButton>
</div>
<template v-else>
<div class="flex flex-col gap-2 pt-1">
<!-- 第一排:核心编辑与结构 (编辑节点 | 编辑结构 | 编辑顺序) -->
<div class="grid grid-cols-3 gap-2">
<CommonButton type="primary" block @click="runAction('editNode')">
<template #icon>
<n-icon><create-outline /></n-icon>
</template>
编辑节点
</CommonButton>
<n-dropdown
trigger="click"
:options="editStructureOptions"
:disabled="!editStructureOptions.some((o) => !o.disabled)"
@select="runAction"
>
<CommonButton secondary block :disabled="!editStructureOptions.some((o) => !o.disabled)">
<template #icon>
<n-icon class="text-amber-500"><build-outline /></n-icon>
</template>
编辑结构
</CommonButton>
</n-dropdown>
<n-dropdown
trigger="click"
:options="editOrderOptions"
:disabled="!canMoveUpActive && !canMoveDownActive"
@select="runAction"
>
<CommonButton secondary block :disabled="!canMoveUpActive && !canMoveDownActive">
<template #icon>
<n-icon class="text-amber-500"><swap-vertical-outline /></n-icon>
</template>
编辑顺序
</CommonButton>
</n-dropdown>
</div>
<!-- 第二排:剪贴板类 (复制节点 | 粘贴XML | 粘贴节点) -->
<div class="grid grid-cols-3 gap-2">
<CommonButton secondary block @click="runAction('copyNode')">
<template #icon>
<n-icon class="text-primary"><copy-outline /></n-icon>
</template>
复制节点
</CommonButton>
<n-dropdown trigger="click" :options="pasteXmlOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon class="text-primary"><clipboard-outline /></n-icon>
</template>
粘贴XML
</CommonButton>
</n-dropdown>
<n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon class="text-primary"><clipboard-outline /></n-icon>
</template>
粘贴节点
</CommonButton>
</n-dropdown>
</div>
<!-- 第三排:查看与检索类 (查看 XML | 查看规则 | 智能翻译) -->
<div class="grid grid-cols-3 gap-2">
<CommonButton secondary block @click="runAction('viewXml')">
<template #icon>
<n-icon class="text-sky-500"><code-working-outline /></n-icon>
</template>
查看 XML
</CommonButton>
<CommonButton secondary block :disabled="isTextNode" @click="runAction('checkRule')">
<template #icon>
<n-icon class="text-sky-500"><eye-outline /></n-icon>
</template>
查看规则
</CommonButton>
<CommonButton
secondary
block
:disabled="!canTranslateActive || isTranslating(activeNodeId)"
@click="runAction('translateNode')"
>
<template #icon>
<n-icon v-if="isTranslating(activeNodeId)" class="animate-spin text-sky-500"><sync-outline /></n-icon>
<n-icon v-else class="text-sky-500"><language-outline /></n-icon>
</template>
智能翻译
</CommonButton>
</div>
<!-- 第四排:保存与删除类 (保存为模板 | 删除节点) -->
<div class="grid grid-cols-3 gap-2">
<CommonButton secondary block class="col-span-2" @click="runAction('saveTemplate')">
<template #icon>
<n-icon class="text-purple-500"><save-outline /></n-icon>
</template>
保存为模板
</CommonButton>
<CommonButton
type="error"
secondary
block
class="col-span-1"
:disabled="!canDeleteActive"
@click="runAction('deleteNode')"
>
<template #icon>
<n-icon><trash-outline /></n-icon>
</template>
删除节点
</CommonButton>
</div>
</div>
</template>
</div>
</div>
</div>
</CommonModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {
CreateOutline,
CopyOutline,
ClipboardOutline,
TrashOutline,
BuildOutline,
SwapVerticalOutline,
LanguageOutline,
CodeWorkingOutline,
EyeOutline,
SaveOutline,
SyncOutline
} from '@vicons/ionicons5'
import { useEditAreaContextMenu } from './functionals/index' import { useEditAreaContextMenu } from './functionals/index'
import type { EditAreaContextMenuProps } from './constants' import type { EditAreaContextMenuProps } from './constants'
const props = defineProps<EditAreaContextMenuProps>() const props = withDefaults(defineProps<EditAreaContextMenuProps>(), {
x: 0,
y: 0
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void (e: 'update:modelValue', val: boolean): void
}>() }>()
...@@ -201,28 +29,18 @@ const show = computed({ ...@@ -201,28 +29,18 @@ const show = computed({
set: (val) => emit('update:modelValue', val) set: (val) => emit('update:modelValue', val)
}) })
const { const { dropdownOptions, runAction } = useEditAreaContextMenu(props, (event, val) => {
activeNodeId,
activeNodeName,
sourceNodeName,
isTextNode,
isVirtualLayoutNode,
canTranslateActive,
canDeleteActive,
canMoveUpActive,
canMoveDownActive,
hasCopyCache,
isTranslating,
runAction,
pasteXmlOptions,
editStructureOptions,
editOrderOptions,
pasteNodeOptions
} = useEditAreaContextMenu(props, (event, val) => {
if (event === 'update:visible') { if (event === 'update:visible') {
show.value = val show.value = val
} }
}) })
const handleSelect = (key: string) => {
runAction(key)
if (!key.startsWith('switchTargetNode:')) {
show.value = false
}
}
</script> </script>
<style scoped></style> <style scoped></style>
...@@ -67,8 +67,14 @@ ...@@ -67,8 +67,14 @@
<!-- 查找与替换浮动面板 --> <!-- 查找与替换浮动面板 -->
<FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" /> <FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" />
<!-- 编辑区非 Table 元素右键上下文菜单弹窗 --> <!-- 编辑区非 Table 元素右键上下文菜单浮动下拉 -->
<EditAreaContextMenuModal v-model="contextMenuVisible" :node-ids="contextMenuNodeIds" /> <EditAreaContextMenuModal
v-model="contextMenuVisible"
:node-ids="contextMenuNodeIds"
:x="contextMenuX"
:y="contextMenuY"
/>
<!-- 文本选择悬浮工具栏 --> <!-- 文本选择悬浮工具栏 -->
<SelectionToolbar /> <SelectionToolbar />
...@@ -153,6 +159,8 @@ const handleEditSelectedNode = () => { ...@@ -153,6 +159,8 @@ const handleEditSelectedNode = () => {
const contextMenuVisible = ref(false) const contextMenuVisible = ref(false)
const contextMenuNodeIds = ref<string[]>([]) const contextMenuNodeIds = ref<string[]>([])
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const handleContextMenu = (e: MouseEvent) => { const handleContextMenu = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
...@@ -198,10 +206,13 @@ const handleContextMenu = (e: MouseEvent) => { ...@@ -198,10 +206,13 @@ const handleContextMenu = (e: MouseEvent) => {
if (isInsideTable) return if (isInsideTable) return
if (ids.length > 0) { if (ids.length > 0) {
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
contextMenuNodeIds.value = ids contextMenuNodeIds.value = ids
contextMenuVisible.value = true contextMenuVisible.value = true
} }
} }
</script> </script>
<style scoped> <style scoped>
......
...@@ -73,8 +73,11 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -73,8 +73,11 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const node = store.nodeMap.get(nodeId)?.node ?? null const node = store.nodeMap.get(nodeId)?.node ?? null
return node ? `编辑节点 <${node.tagName}>` : '编辑节点' return node ? `编辑节点 <${node.tagName}>` : '编辑节点'
} }
if (nodeId && nodeId.includes('-txt-')) {
return `#text ${map[addNodeMode.value]}`
}
const node = store.nodeMap.get(nodeId)?.node ?? null const node = store.nodeMap.get(nodeId)?.node ?? null
return node ? `${node.tagName} ${map[addNodeMode.value]}` : map[addNodeMode.value] return node ? `<${node.tagName}> ${map[addNodeMode.value]}` : map[addNodeMode.value]
}) })
// 标签下拉选项 // 标签下拉选项
......
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