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 规则管理器
......@@ -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 定义的子节点顺序进行排序
*/
export function sortChildrenByDtd(parentTagName: string, childTags: string[]): string[] {
......@@ -336,3 +466,127 @@ export function sortChildrenByDtd(parentTagName: string, childTags: string[]): s
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
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 获取允许添加的子标签列表
const allowedChildrenList = computed<string[]>(() => {
const node = activeNode.value
......@@ -509,6 +534,56 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
addNodeAllowedTags.value = insertableParentList.value
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(() => [
......@@ -523,6 +598,11 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
{ 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(() => [
{ label: '粘贴到上方', key: 'pasteNodeAbove', disabled: !insertableParentList.value.length },
{ label: '粘贴到下方', key: 'pasteNodeBelow', disabled: !insertableParentList.value.length },
......@@ -538,6 +618,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
isVirtualLayoutNode,
canTranslateActive,
canDeleteActive,
canMoveUpActive,
canMoveDownActive,
allowedChildrenList,
insertableParentList,
hasCopyCache,
......@@ -545,6 +627,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
runAction,
pasteXmlOptions,
editStructureOptions,
editOrderOptions,
pasteNodeOptions
}
}
......@@ -74,7 +74,7 @@
</n-dropdown>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-4 gap-2">
<!-- 4. 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon>
......@@ -93,6 +93,16 @@
</CommonButton>
</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) -->
<n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="warning" secondary block :disabled="!hasCopyCache">
......@@ -160,6 +170,7 @@ import {
ClipboardOutline,
TrashOutline,
BuildOutline,
SwapVerticalOutline,
LanguageOutline,
CodeWorkingOutline,
EyeOutline,
......@@ -187,11 +198,14 @@ const {
isVirtualLayoutNode,
canTranslateActive,
canDeleteActive,
canMoveUpActive,
canMoveDownActive,
hasCopyCache,
isTranslating,
runAction,
pasteXmlOptions,
editStructureOptions,
editOrderOptions,
pasteNodeOptions
} = useEditAreaContextMenu(props, (event, val) => {
if (event === 'update:visible') {
......
......@@ -874,14 +874,15 @@ export function useNodeTree(
return options
}
// 查看XML
// ================= 组 1:查看与辅助 =================
// 1. 查看XML
options.push({
label: '查看XML',
key: 'viewXml',
icon: icon(CodeWorkingOutline)
})
// 查看规则(虚拟文本节点不需要)
// 2. 查看规则(虚拟文本节点不需要)
if (!isVirtual) {
options.push({
label: '查看规则',
......@@ -890,14 +891,7 @@ export function useNodeTree(
})
}
// 编辑节点(属性/内容)
options.push({
label: isVirtual ? '编辑文本' : '编辑节点',
key: 'editNode',
icon: icon(CreateOutline)
})
// 智能翻译
// 3. 智能翻译
const enSourceNode = getEnglishSourceNode(node, parent)
if (enSourceNode && !isVirtual && hasTranslatableText(enSourceNode)) {
options.push({
......@@ -907,21 +901,17 @@ export function useNodeTree(
})
}
// 复制节点
// ================= 组 2:复制 / 粘贴 / 暂存 =================
options.push({ type: 'divider', key: 'd1' })
// 4. 复制节点
options.push({
label: '复制节点',
key: 'copyNode',
icon: icon(CopyOutline)
})
// 本地暂存
options.push({
label: '本地暂存',
key: 'localStash',
icon: icon(ArchiveOutline)
})
// 粘贴节点(有缓存时显示)
// 5. 粘贴节点(有缓存时显示)
if (copyNodeCache.value) {
const pasteChildren: DropdownOption[] = isVirtual
? [
......@@ -941,7 +931,7 @@ export function useNodeTree(
})
}
// 粘贴XML
// 6. 粘贴XML
const pasteFragmentChildren: DropdownOption[] = isVirtual
? [
{ label: '粘贴到上方', key: 'pasteFragmentAbove', icon: icon(ClipboardOutline), disabled: !parent },
......@@ -959,28 +949,24 @@ export function useNodeTree(
children: pasteFragmentChildren
})
// 删除节点(受 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)
})()
// 7. 本地暂存
options.push({
label: '删除节点',
key: 'deleteNode',
icon: icon(TrashOutline),
disabled: !deletable
label: '本地暂存',
key: 'localStash',
icon: icon(ArchiveOutline)
})
// ================= 组 3:节点编辑与结构调整 =================
options.push({ type: 'divider', key: 'd2' })
// 8. 编辑节点(属性/内容)
options.push({
label: isVirtual ? '编辑文本' : '编辑节点',
key: 'editNode',
icon: icon(CreateOutline)
})
}
// 编辑结构子菜单
// 9. 编辑结构
const structureChildren: DropdownOption[] = []
// 添加子节点(文本节点不能拥有子节点)
......@@ -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({
label: '保存为模板',
......@@ -1031,6 +1044,27 @@ export function useNodeTree(
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
}
......@@ -1406,6 +1440,51 @@ export function useNodeTree(
}
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