Commit 233926ec by pangchong

feat: 节点操作新增顺序调整

parent 158ac826
import type { DtdSchema, DtdElement, DtdAttribute, DtdContentModel } from '@/types/xmlNode' import type { DtdSchema, DtdElement, DtdAttribute, DtdContentModel, XmlNode } from '@/types/xmlNode'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
/** /**
* DTD 规则管理器 * DTD 规则管理器
...@@ -322,6 +323,135 @@ export function getDtdDefinedOrder(tagName: string): string[] { ...@@ -322,6 +323,135 @@ export function getDtdDefinedOrder(tagName: string): string[] {
} }
/** /**
* 从 DTD 内容模型中提取 sequence 顺序约束(内部辅助函数)。
*
* 规则:
* - 只对同一 sequence 组中处于不同位置(i < j)的元素对生成约束
* - choice 组内部的兄弟元素之间不产生约束(可任意顺序、交叉出现)
* - 递归处理嵌套结构(choice 内部也可能有嵌套 sequence)
*
* 返回值 [(a, b)] 表示"在该父节点下,a 必须整体早于 b 出现"
*/
function buildSequenceConstraints(model: DtdContentModel): Array<[string, string]> {
const constraints: Array<[string, string]> = []
/** 递归收集一个子模型中所有可能出现的元素名(去重) */
const getLeafNames = (m: DtdContentModel): string[] => {
const names: string[] = []
const collect = (n: DtdContentModel) => {
if (n.type === 'elementRef' && n.name && !names.includes(n.name)) {
names.push(n.name)
}
if (n.children) n.children.forEach(collect)
}
collect(m)
return names
}
const process = (m: DtdContentModel) => {
if (m.type === 'sequence' && m.children && m.children.length > 1) {
// 同一 sequence 中:位置 i 的元素集合必须早于位置 j (j>i) 的元素集合
for (let i = 0; i < m.children.length; i++) {
const before = getLeafNames(m.children[i])
for (let j = i + 1; j < m.children.length; j++) {
const after = getLeafNames(m.children[j])
for (const a of before) {
for (const b of after) {
if (a !== b && !constraints.some(([x, y]) => x === a && y === b)) {
constraints.push([a, b])
}
}
}
}
}
}
// 递归处理子节点(包括 choice 内部可能存在的嵌套 sequence)
if (m.children) m.children.forEach(process)
}
process(model)
return constraints
}
/**
* 判断某个子节点在父节点中能否上移 / 下移(DTD 顺序约束校验)
*
* 核心思路:
* 1. 通过 buildSequenceConstraints 提取 DTD sequence 约束对 [(a→b)]
* - choice 组内部元素(如 PARA、PARAC、WARNING)之间无约束,可自由移动
* - sequence 不同位置间的元素有严格顺序约束
* 2. 模拟交换后,对每个约束 (a→b) 验证:
* "a 的最后一次出现" 不得晚于 "b 的第一次出现"
* → 违反则拒绝移动
*
* 旧实现用 getDtdDefinedOrder + 位置单调检查,对 choice 组误判(
* 例如 [PARAC, PARA, PARAC] 被错误标记为无效,导致两个方向全灰)。
*
* @param parentTagName 父节点标签名
* @param childTags 当前所有子节点的标签名列表(按显示顺序)
* @param childIndex 要移动的子节点在 childTags 中的索引
* @returns { canMoveUp, canMoveDown }
*/
export function canMoveNode(
parentTagName: string,
childTags: string[],
childIndex: number
): { canMoveUp: boolean; canMoveDown: boolean } {
const atTop = childIndex <= 0
const atBottom = childIndex >= childTags.length - 1
const rule = getElementRule(parentTagName)
// 没有 DTD 规则:仅做边界判断
if (!rule || !rule.contentModel || !rule.contentModel.parsed) {
return { canMoveUp: !atTop, canMoveDown: !atBottom }
}
const model = rule.contentModel.parsed
// 混合内容 / 纯文本:DTD 不约束子节点顺序
if (model.type === 'mixed' || model.type === 'pcdata') {
return { canMoveUp: !atTop, canMoveDown: !atBottom }
}
// 提取 sequence 顺序约束(choice 组内无约束)
const constraints = buildSequenceConstraints(model)
// 没有顺序约束(如纯 choice* 模型):仅做边界判断
if (constraints.length === 0) {
return { canMoveUp: !atTop, canMoveDown: !atBottom }
}
/**
* 验证给定标签列表是否满足所有顺序约束。
* 对约束 (a→b):"a 的最后出现索引" 必须 ≤ "b 的最早出现索引"。
* 即:不允许存在任何 b 出现在 a 之前的情况。
*/
const isOrderValid = (tags: string[]): boolean => {
for (const [a, b] of constraints) {
const lastA = tags.lastIndexOf(a)
const firstB = tags.indexOf(b)
if (lastA !== -1 && firstB !== -1 && lastA > firstB) {
return false
}
}
return true
}
const swapCheck = (i: number, j: number): boolean => {
const copy = [...childTags]
;[copy[i], copy[j]] = [copy[j], copy[i]]
return isOrderValid(copy)
}
return {
canMoveUp: !atTop && swapCheck(childIndex, childIndex - 1),
canMoveDown: !atBottom && swapCheck(childIndex, childIndex + 1)
}
}
/**
* 根据父节点 DTD 定义的子节点顺序进行排序 * 根据父节点 DTD 定义的子节点顺序进行排序
*/ */
export function sortChildrenByDtd(parentTagName: string, childTags: string[]): string[] { export function sortChildrenByDtd(parentTagName: string, childTags: string[]): string[] {
...@@ -336,3 +466,127 @@ export function sortChildrenByDtd(parentTagName: string, childTags: string[]): s ...@@ -336,3 +466,127 @@ export function sortChildrenByDtd(parentTagName: string, childTags: string[]): s
return idxA - idxB return idxA - idxB
}) })
} }
/**
* 综合判断某个节点在父节点中能否上移 / 下移
* 优先处理 mixedContent 混合内容,其次处理纯元素子节点 DTD sequence 约束
*/
export function checkCanMoveNode(
parent: XmlNode | null | undefined,
nodeId: string,
isVirtual: boolean = false
): { canMoveUp: boolean; canMoveDown: boolean } {
if (!parent) return { canMoveUp: false, canMoveDown: false }
// 1. 如果父节点包含 mixedContent 且内容不为空,按 mixedContent 排布列表判断
if (parent.mixedContent && parent.mixedContent.length > 0) {
let mcIdx = -1
if (isVirtual || nodeId.includes('-txt-')) {
mcIdx = parseInt(nodeId.split('-txt-')[1], 10)
} else {
mcIdx = parent.mixedContent.findIndex((m) => m.nodeId === nodeId)
}
if (mcIdx !== -1) {
return {
canMoveUp: mcIdx > 0,
canMoveDown: mcIdx < parent.mixedContent.length - 1
}
}
}
// 2. 虚拟文本节点但在 mixedContent 未找到,不允许移动
if (isVirtual || nodeId.includes('-txt-')) {
return { canMoveUp: false, canMoveDown: false }
}
// 3. 规范结构元素:使用过滤后的真实 children,结合 DTD 进行 sequence 约束校验
if (VIRTUAL_LAYOUT_TAGS.includes(parent.tagName)) {
return { canMoveUp: false, canMoveDown: false }
}
const realSiblings = parent.children ? parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)) : []
const realIdx = realSiblings.findIndex((c) => c.id === nodeId)
if (realIdx === -1) {
return { canMoveUp: false, canMoveDown: false }
}
return canMoveNode(parent.tagName, realSiblings.map((c) => c.tagName), realIdx)
}
/**
* 通用节点移动执行函数(支持 mixedContent 数组与结构化 children 数组同步)
*/
export function moveNodeInParent(
parent: XmlNode | null | undefined,
nodeId: string,
direction: 'up' | 'down',
isVirtual: boolean = false
): boolean {
if (!parent) return false
// 1. 如果父节点存在 mixedContent,优先在 mixedContent 中执行交换
if (parent.mixedContent && parent.mixedContent.length > 0) {
let mcIdx = -1
if (isVirtual || nodeId.includes('-txt-')) {
mcIdx = parseInt(nodeId.split('-txt-')[1], 10)
} else {
mcIdx = parent.mixedContent.findIndex((m) => m.nodeId === nodeId)
}
if (mcIdx !== -1) {
const targetIdx = direction === 'up' ? mcIdx - 1 : mcIdx + 1
if (targetIdx < 0 || targetIdx >= parent.mixedContent.length) {
return false
}
// 交换 mixedContent
const mc = parent.mixedContent
;[mc[mcIdx], mc[targetIdx]] = [mc[targetIdx], mc[mcIdx]]
// 同步刷新 parent.children 顺序,与 mixedContent 中出现元素的先后顺序一致
if (parent.children && parent.children.length > 0) {
const newChildrenOrder: XmlNode[] = []
for (const item of mc) {
if (item.type === 'element' && item.nodeId) {
const childNode = parent.children.find((c) => c.id === item.nodeId)
if (childNode && !newChildrenOrder.some((c) => c.id === childNode.id)) {
newChildrenOrder.push(childNode)
}
}
}
for (const childNode of parent.children) {
if (!newChildrenOrder.some((c) => c.id === childNode.id)) {
newChildrenOrder.push(childNode)
}
}
parent.children = newChildrenOrder
}
return true
}
}
// 2. 规范结构元素:在 children 中按物理索引做相邻交换
if (!parent.children) return false
const allChildren = parent.children
const realSiblings = allChildren.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName))
const realIdx = realSiblings.findIndex((c) => c.id === nodeId)
if (realIdx === -1) return false
const targetRealIdx = direction === 'up' ? realIdx - 1 : realIdx + 1
if (targetRealIdx < 0 || targetRealIdx >= realSiblings.length) return false
const currentReal = realSiblings[realIdx]
const targetReal = realSiblings[targetRealIdx]
const fullIdx1 = allChildren.findIndex((c) => c.id === currentReal.id)
const fullIdx2 = allChildren.findIndex((c) => c.id === targetReal.id)
if (fullIdx1 !== -1 && fullIdx2 !== -1) {
;[allChildren[fullIdx1], allChildren[fullIdx2]] = [allChildren[fullIdx2], allChildren[fullIdx1]]
return true
}
return false
}
...@@ -269,6 +269,31 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -269,6 +269,31 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return canDeleteChild(mapped.parent.tagName, mapped.node.tagName, existingTags) return canDeleteChild(mapped.parent.tagName, mapped.node.tagName, existingTags)
}) })
// 是否允许上移(DTD 顺序约束)
const canMoveUpActive = computed(() => {
const nodeId = activeNodeId.value
if (!nodeId) return false
const isVirtual = nodeId.includes('-txt-')
const realId = isVirtual ? nodeId.split('-txt-')[0] : nodeId
const mapped = editorStore.nodeMap.get(realId)
if (!mapped || !mapped.parent) return false
const parent = isVirtual ? mapped.node : mapped.parent
return checkCanMoveNode(parent, nodeId, isVirtual).canMoveUp
})
// 是否允许下移(DTD 顺序约束)
const canMoveDownActive = computed(() => {
const nodeId = activeNodeId.value
if (!nodeId) return false
const isVirtual = nodeId.includes('-txt-')
const realId = isVirtual ? nodeId.split('-txt-')[0] : nodeId
const mapped = editorStore.nodeMap.get(realId)
if (!mapped || !mapped.parent) return false
const parent = isVirtual ? mapped.node : mapped.parent
return checkCanMoveNode(parent, nodeId, isVirtual).canMoveDown
})
// 根据 DTD 获取允许添加的子标签列表 // 根据 DTD 获取允许添加的子标签列表
const allowedChildrenList = computed<string[]>(() => { const allowedChildrenList = computed<string[]>(() => {
const node = activeNode.value const node = activeNode.value
...@@ -509,6 +534,56 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -509,6 +534,56 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
addNodeAllowedTags.value = insertableParentList.value addNodeAllowedTags.value = insertableParentList.value
addNodeVisible.value = true addNodeVisible.value = true
} }
// 11. 上移节点
else if (actionName === 'moveUp') {
const mapped2 = editorStore.nodeMap.get(realId)
if (!mapped2 || !mapped2.parent) return
const parent = isVirtual ? mapped2.node : mapped2.parent
const nodeIdToMove = activeNodeId.value || realId
const { canMoveUp } = checkCanMoveNode(parent, nodeIdToMove, isVirtual)
if (!canMoveUp) {
window.$message.warning('已在最顶部或 DTD 顺序不支持上移')
return
}
editorStore.saveSnapshot()
const success = moveNodeInParent(parent, nodeIdToMove, 'up', isVirtual)
if (success) {
editorStore.rebuildNodeMap()
if (!isVirtual) {
editorStore.setSelectedNodeId(realId)
}
window.$message.success('上移成功')
} else {
window.$message.warning('上移失败')
}
}
// 12. 下移节点
else if (actionName === 'moveDown') {
const mapped2 = editorStore.nodeMap.get(realId)
if (!mapped2 || !mapped2.parent) return
const parent = isVirtual ? mapped2.node : mapped2.parent
const nodeIdToMove = activeNodeId.value || realId
const { canMoveDown } = checkCanMoveNode(parent, nodeIdToMove, isVirtual)
if (!canMoveDown) {
window.$message.warning('已在最底部或 DTD 顺序不支持下移')
return
}
editorStore.saveSnapshot()
const success = moveNodeInParent(parent, nodeIdToMove, 'down', isVirtual)
if (success) {
editorStore.rebuildNodeMap()
if (!isVirtual) {
editorStore.setSelectedNodeId(realId)
}
window.$message.success('下移成功')
} else {
window.$message.warning('下移失败')
}
}
} }
const pasteXmlOptions = computed(() => [ const pasteXmlOptions = computed(() => [
...@@ -523,6 +598,11 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -523,6 +598,11 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
{ label: '插入到下方', key: 'insertAfter', disabled: !insertableParentList.value.length } { label: '插入到下方', key: 'insertAfter', disabled: !insertableParentList.value.length }
]) ])
const editOrderOptions = computed(() => [
{ label: '上移', key: 'moveUp', disabled: !canMoveUpActive.value },
{ label: '下移', key: 'moveDown', disabled: !canMoveDownActive.value }
])
const pasteNodeOptions = computed(() => [ const pasteNodeOptions = computed(() => [
{ label: '粘贴到上方', key: 'pasteNodeAbove', disabled: !insertableParentList.value.length }, { label: '粘贴到上方', key: 'pasteNodeAbove', disabled: !insertableParentList.value.length },
{ label: '粘贴到下方', key: 'pasteNodeBelow', disabled: !insertableParentList.value.length }, { label: '粘贴到下方', key: 'pasteNodeBelow', disabled: !insertableParentList.value.length },
...@@ -538,6 +618,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -538,6 +618,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
isVirtualLayoutNode, isVirtualLayoutNode,
canTranslateActive, canTranslateActive,
canDeleteActive, canDeleteActive,
canMoveUpActive,
canMoveDownActive,
allowedChildrenList, allowedChildrenList,
insertableParentList, insertableParentList,
hasCopyCache, hasCopyCache,
...@@ -545,6 +627,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -545,6 +627,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
runAction, runAction,
pasteXmlOptions, pasteXmlOptions,
editStructureOptions, editStructureOptions,
editOrderOptions,
pasteNodeOptions pasteNodeOptions
} }
} }
...@@ -74,7 +74,7 @@ ...@@ -74,7 +74,7 @@
</n-dropdown> </n-dropdown>
</div> </div>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-4 gap-2">
<!-- 4. 删除节点 --> <!-- 4. 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')"> <CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon> <template #icon>
...@@ -93,6 +93,16 @@ ...@@ -93,6 +93,16 @@
</CommonButton> </CommonButton>
</n-dropdown> </n-dropdown>
<!-- 5b. 编辑顺序 -->
<n-dropdown trigger="click" :options="editOrderOptions" :disabled="isTextNode || (!canMoveUpActive && !canMoveDownActive)" @select="runAction">
<CommonButton type="warning" secondary block :disabled="isTextNode || (!canMoveUpActive && !canMoveDownActive)">
<template #icon>
<n-icon><swap-vertical-outline /></n-icon>
</template>
编辑顺序
</CommonButton>
</n-dropdown>
<!-- 6. 粘贴节点 (DTD) --> <!-- 6. 粘贴节点 (DTD) -->
<n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction"> <n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="warning" secondary block :disabled="!hasCopyCache"> <CommonButton type="warning" secondary block :disabled="!hasCopyCache">
...@@ -160,6 +170,7 @@ import { ...@@ -160,6 +170,7 @@ import {
ClipboardOutline, ClipboardOutline,
TrashOutline, TrashOutline,
BuildOutline, BuildOutline,
SwapVerticalOutline,
LanguageOutline, LanguageOutline,
CodeWorkingOutline, CodeWorkingOutline,
EyeOutline, EyeOutline,
...@@ -187,11 +198,14 @@ const { ...@@ -187,11 +198,14 @@ const {
isVirtualLayoutNode, isVirtualLayoutNode,
canTranslateActive, canTranslateActive,
canDeleteActive, canDeleteActive,
canMoveUpActive,
canMoveDownActive,
hasCopyCache, hasCopyCache,
isTranslating, isTranslating,
runAction, runAction,
pasteXmlOptions, pasteXmlOptions,
editStructureOptions, editStructureOptions,
editOrderOptions,
pasteNodeOptions pasteNodeOptions
} = useEditAreaContextMenu(props, (event, val) => { } = useEditAreaContextMenu(props, (event, val) => {
if (event === 'update:visible') { if (event === 'update:visible') {
......
...@@ -874,14 +874,15 @@ export function useNodeTree( ...@@ -874,14 +874,15 @@ export function useNodeTree(
return options return options
} }
// 查看XML // ================= 组 1:查看与辅助 =================
// 1. 查看XML
options.push({ options.push({
label: '查看XML', label: '查看XML',
key: 'viewXml', key: 'viewXml',
icon: icon(CodeWorkingOutline) icon: icon(CodeWorkingOutline)
}) })
// 查看规则(虚拟文本节点不需要) // 2. 查看规则(虚拟文本节点不需要)
if (!isVirtual) { if (!isVirtual) {
options.push({ options.push({
label: '查看规则', label: '查看规则',
...@@ -890,14 +891,7 @@ export function useNodeTree( ...@@ -890,14 +891,7 @@ export function useNodeTree(
}) })
} }
// 编辑节点(属性/内容) // 3. 智能翻译
options.push({
label: isVirtual ? '编辑文本' : '编辑节点',
key: 'editNode',
icon: icon(CreateOutline)
})
// 智能翻译
const enSourceNode = getEnglishSourceNode(node, parent) const enSourceNode = getEnglishSourceNode(node, parent)
if (enSourceNode && !isVirtual && hasTranslatableText(enSourceNode)) { if (enSourceNode && !isVirtual && hasTranslatableText(enSourceNode)) {
options.push({ options.push({
...@@ -907,21 +901,17 @@ export function useNodeTree( ...@@ -907,21 +901,17 @@ export function useNodeTree(
}) })
} }
// 复制节点 // ================= 组 2:复制 / 粘贴 / 暂存 =================
options.push({ type: 'divider', key: 'd1' })
// 4. 复制节点
options.push({ options.push({
label: '复制节点', label: '复制节点',
key: 'copyNode', key: 'copyNode',
icon: icon(CopyOutline) icon: icon(CopyOutline)
}) })
// 本地暂存 // 5. 粘贴节点(有缓存时显示)
options.push({
label: '本地暂存',
key: 'localStash',
icon: icon(ArchiveOutline)
})
// 粘贴节点(有缓存时显示)
if (copyNodeCache.value) { if (copyNodeCache.value) {
const pasteChildren: DropdownOption[] = isVirtual const pasteChildren: DropdownOption[] = isVirtual
? [ ? [
...@@ -941,7 +931,7 @@ export function useNodeTree( ...@@ -941,7 +931,7 @@ export function useNodeTree(
}) })
} }
// 粘贴XML // 6. 粘贴XML
const pasteFragmentChildren: DropdownOption[] = isVirtual const pasteFragmentChildren: DropdownOption[] = isVirtual
? [ ? [
{ label: '粘贴到上方', key: 'pasteFragmentAbove', icon: icon(ClipboardOutline), disabled: !parent }, { label: '粘贴到上方', key: 'pasteFragmentAbove', icon: icon(ClipboardOutline), disabled: !parent },
...@@ -959,28 +949,24 @@ export function useNodeTree( ...@@ -959,28 +949,24 @@ export function useNodeTree(
children: pasteFragmentChildren children: pasteFragmentChildren
}) })
// 删除节点(受 DTD 约束,文本节点和分页符节点永远允许删除) // 7. 本地暂存
if (parent) {
const deletable =
isVirtual || VIRTUAL_LAYOUT_TAGS.includes(node.tagName)
? true
: (() => {
const existingTags = parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
// 位置特许规则:父级中最后一个子节点(按位置)允许删除
if (existingTags.length > 0 && existingTags[existingTags.length - 1] === node.tagName) {
return true
}
return canDeleteChild(parent.tagName, node.tagName, existingTags)
})()
options.push({ options.push({
label: '删除节点', label: '本地暂存',
key: 'deleteNode', key: 'localStash',
icon: icon(TrashOutline), icon: icon(ArchiveOutline)
disabled: !deletable })
// ================= 组 3:节点编辑与结构调整 =================
options.push({ type: 'divider', key: 'd2' })
// 8. 编辑节点(属性/内容)
options.push({
label: isVirtual ? '编辑文本' : '编辑节点',
key: 'editNode',
icon: icon(CreateOutline)
}) })
}
// 编辑结构子菜单 // 9. 编辑结构
const structureChildren: DropdownOption[] = [] const structureChildren: DropdownOption[] = []
// 添加子节点(文本节点不能拥有子节点) // 添加子节点(文本节点不能拥有子节点)
...@@ -1024,6 +1010,33 @@ export function useNodeTree( ...@@ -1024,6 +1010,33 @@ export function useNodeTree(
}) })
} }
// 10. 编辑顺序(上移 / 下移)
if (parent) {
const { canMoveUp, canMoveDown } = checkCanMoveNode(parent, nodeId, isVirtual)
options.push({
label: '编辑顺序',
key: 'editOrder',
icon: icon(ArrowUpOutline),
children: [
{
label: '上移',
key: 'moveUp',
icon: icon(ArrowUpOutline),
disabled: !canMoveUp
},
{
label: '下移',
key: 'moveDown',
icon: icon(ArrowDownOutline),
disabled: !canMoveDown
}
]
})
}
// ================= 组 4:更多与删除 =================
options.push({ type: 'divider', key: 'd3' })
// 保存为模板 // 保存为模板
options.push({ options.push({
label: '保存为模板', label: '保存为模板',
...@@ -1031,6 +1044,27 @@ export function useNodeTree( ...@@ -1031,6 +1044,27 @@ export function useNodeTree(
icon: icon(SaveOutline) icon: icon(SaveOutline)
}) })
// 删除节点(受 DTD 约束,文本节点和分页符节点永远允许删除)
if (parent) {
const deletable =
isVirtual || VIRTUAL_LAYOUT_TAGS.includes(node.tagName)
? true
: (() => {
const existingTags = parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
// 位置特许规则:父级中最后一个子节点(按位置)允许删除
if (existingTags.length > 0 && existingTags[existingTags.length - 1] === node.tagName) {
return true
}
return canDeleteChild(parent.tagName, node.tagName, existingTags)
})()
options.push({
label: '删除节点',
key: 'deleteNode',
icon: icon(TrashOutline),
disabled: !deletable
})
}
return options return options
} }
...@@ -1406,6 +1440,51 @@ export function useNodeTree( ...@@ -1406,6 +1440,51 @@ export function useNodeTree(
} }
break break
} }
// ── 上移节点 ──
case 'moveUp': {
if (!parent) break
const { canMoveUp } = checkCanMoveNode(parent, nodeId, isVirtual)
if (!canMoveUp) {
window.$message?.warning('已在最顶部或 DTD 顺序不支持上移')
break
}
editorStore.saveSnapshot()
const success = moveNodeInParent(parent, nodeId, 'up', isVirtual)
if (success) {
editorStore.rebuildNodeMap()
if (!isVirtual) {
editorStore.setSelectedNodeId(realNodeId)
}
window.$message?.success('上移成功')
} else {
window.$message?.warning('上移失败')
}
break
}
// ── 下移节点 ──
case 'moveDown': {
if (!parent) break
const { canMoveDown } = checkCanMoveNode(parent, nodeId, isVirtual)
if (!canMoveDown) {
window.$message?.warning('已在最底部或 DTD 顺序不支持下移')
break
}
editorStore.saveSnapshot()
const success = moveNodeInParent(parent, nodeId, 'down', isVirtual)
if (success) {
editorStore.rebuildNodeMap()
if (!isVirtual) {
editorStore.setSelectedNodeId(realNodeId)
}
window.$message?.success('下移成功')
} else {
window.$message?.warning('下移失败')
}
break
}
} }
} }
......
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