Commit 9612753d by pangchong

fix(editor): 优化节点删除逻辑及混合内容文本节点处理

- 改进节点删除接口,支持传入指定节点ID删除,默认删除当前选中节点
- 增加虚拟文本节点(#text)删除处理,修改对应父节点混合内容(mixedContent)及文本内容(textContent)
- 删除后智能设置下一个选中节点,优先选择同父节点相邻兄弟或父节点
- 修改文档节点渲染组件,支持混合内容文本节点的高亮显示与选中逻辑
- 更新批量删除确认逻辑,添加基于节点深度排序和DTD约束的删除安全性校验
- 修正多个编辑器组件中节点删除调用,统一使用deleteNode方法执行删除
- 细节修正编辑器侧对选中节点更新和删除后界面状态处理
- 调整API响应成功判断逻辑,支持success字段为true的情况
parent 7072224e
......@@ -50,7 +50,13 @@ const createService = (baseURL: string) => {
if (isJson) {
json = (await response.json()) as ResponseData
if (json) {
if (json.code === 200 || json.code === '200' || (json.code === undefined && json.success === undefined)) {
if (
json.code === 200 ||
json.code === '200' ||
json.success === true ||
json.success === 'true' ||
(json.code === undefined && json.success === undefined)
) {
json.code = 200
return json
}
......@@ -76,7 +82,13 @@ const createService = (baseURL: string) => {
}
if (json) {
if (json.code === 200 || json.code === '200' || (json.code === undefined && json.success === undefined)) {
if (
json.code === 200 ||
json.code === '200' ||
json.success === true ||
json.success === 'true' ||
(json.code === undefined && json.success === undefined)
) {
json.code = 200
}
}
......
......@@ -556,13 +556,11 @@ export const useEditorStore = defineStore('editor', {
this.selectedNodeId = id
let renderedId: string | null = null
if (id) {
let realId = id
if (id.includes('-txt-')) {
realId = id.split('-txt-')[0]
}
const isVirtualText = id ? id.includes('-txt-') : false
let curr = this.nodeMap.get(realId)
if (id && !isVirtualText) {
// 普通节点:沿祖先链找到已挂载的 DocNodeRenderer 节点并高亮其外框
let curr = this.nodeMap.get(id)
while (curr) {
if (nodeSelectedRefs.has(curr.node.id)) {
renderedId = curr.node.id
......@@ -572,9 +570,10 @@ export const useEditorStore = defineStore('editor', {
}
if (!renderedId) {
renderedId = realId
renderedId = id
}
}
// 虚拟文本节点(#text):不高亮父节点外框,由文本 span 自身通过 selectedNodeId 比较来显示高亮
const oldRenderedId = this.renderedSelectedNodeId
this.renderedSelectedNodeId = renderedId
......@@ -721,23 +720,30 @@ export const useEditorStore = defineStore('editor', {
},
/**
* 删除当前选中的节点
* 删除指定的节点(默认为当前选中节点)
*/
deleteSelectedNode() {
if (!this.xmlTree || !this.selectedNodeId || this.selectedNodeId === this.xmlTree.id) {
deleteNode(nodeId?: string) {
const targetId = nodeId || this.selectedNodeId
if (!this.xmlTree || !targetId || targetId === this.xmlTree.id) {
return // 不能删除根节点
}
const parent = this.selectedNodeParent
if (!parent) return
let isVirtual = false
let realId = targetId
if (targetId.includes('-txt-')) {
isVirtual = true
realId = targetId.split('-txt-')[0]
}
const item = this.nodeMap.get(realId)
if (!item) return
this.saveSnapshot()
const index = parent.children.findIndex((c: XmlNode) => c.id === this.selectedNodeId)
if (index !== -1) {
parent.children.splice(index, 1)
// 处理混合内容 (mixedContent) 中的对应项
parent.mixedContent = parent.mixedContent.filter((item: any) => item.nodeId !== this.selectedNodeId)
if (isVirtual) {
const parent = item.node
const textIdx = parseInt(targetId.split('-txt-')[1], 10)
parent.mixedContent.splice(textIdx, 1)
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) {
......@@ -748,13 +754,91 @@ export const useEditorStore = defineStore('editor', {
parent.textContent = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
}
// 选中父节点
this.selectedNodeId = parent.id
// 判断是否是当前选中节点
if (targetId === this.selectedNodeId) {
let nextSelectedId = parent.id
const mixedLength = parent.mixedContent.length
if (mixedLength > 0) {
let siblingIdx = -1
if (textIdx > 0) {
siblingIdx = textIdx - 1
} else if (textIdx < mixedLength) {
siblingIdx = textIdx
}
if (siblingIdx !== -1) {
const sib = parent.mixedContent[siblingIdx]
if (sib.type === 'text') {
nextSelectedId = `${parent.id}-txt-${siblingIdx}`
} else {
nextSelectedId = sib.nodeId || parent.id
}
}
}
this.setSelectedNodeId(nextSelectedId)
}
} else {
const parent = item.parent
if (!parent) return
const index = parent.children.findIndex((c: XmlNode) => c.id === realId)
if (index !== -1) {
parent.children.splice(index, 1)
// 处理混合内容 (mixedContent) 中的对应项
parent.mixedContent = parent.mixedContent.filter((item: any) => item.nodeId !== realId)
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) {
const mergedText = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
parent.textContent = mergedText
parent.mixedContent = []
} else {
parent.textContent = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
}
// 判断被删除的节点是否是选中的节点,或者选中的节点是否是被删除节点的后代
let isSelectedDeleted = false
if (this.selectedNodeId) {
let checkId = this.selectedNodeId
if (checkId.includes('-txt-')) {
checkId = checkId.split('-txt-')[0]
}
let curr = this.nodeMap.get(checkId)
while (curr) {
if (curr.node.id === realId) {
isSelectedDeleted = true
break
}
curr = curr.parent ? this.nodeMap.get(curr.parent.id) : undefined
}
}
if (isSelectedDeleted) {
let nextSelectedId = parent.id // 默认是父节点
if (parent.children.length > 0) {
if (index > 0) {
nextSelectedId = parent.children[index - 1].id
} else if (index < parent.children.length) {
nextSelectedId = parent.children[index].id
}
}
this.setSelectedNodeId(nextSelectedId)
}
}
}
this.rebuildNodeMap()
},
/**
* 删除当前选中的节点
*/
deleteSelectedNode() {
if (this.selectedNodeId) {
this.deleteNode(this.selectedNodeId)
}
},
/**
* 批量删除指定的多个节点(支持跨层级跨父节点)
*/
batchDeleteMultipleNodes(nodeIds: string[]) {
......
......@@ -103,8 +103,11 @@
<template v-for="(item, index) in node.mixedContent" :key="index">
<span
v-if="item.type === 'text'"
:data-node-id="`${node.id}-txt-${index}`"
contenteditable="true"
class="focus:outline-none focus:bg-fill-3 px-0.5 rounded inline"
class="focus:outline-none px-0.5 rounded inline transition-all"
:class="editorStore.selectedNodeId === `${node.id}-txt-${index}` ? 'ring-2 ring-primary ring-offset-1 bg-primary/10' : 'focus:bg-fill-3'"
@click.stop="editorStore.setSelectedNodeId(`${node.id}-txt-${index}`)"
@blur="handleMixedTextBlur(index, $event)"
v-text="item.text"
></span>
......@@ -147,8 +150,11 @@
<template v-for="(item, index) in node.mixedContent" :key="index">
<span
v-if="item.type === 'text'"
:data-node-id="`${node.id}-txt-${index}`"
contenteditable="true"
class="focus:outline-none focus:bg-fill-3 px-0.5 rounded inline"
class="focus:outline-none px-0.5 rounded inline transition-all"
:class="editorStore.selectedNodeId === `${node.id}-txt-${index}` ? 'ring-2 ring-primary ring-offset-1 bg-primary/10' : 'focus:bg-fill-3'"
@click.stop="editorStore.setSelectedNodeId(`${node.id}-txt-${index}`)"
@blur="handleMixedTextBlur(index, $event)"
v-text="item.text"
></span>
......
......@@ -338,16 +338,13 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
title: '确认删除',
content: `您确定要删除选中的节点 <${mapped.node.tagName}> 吗?该操作不可撤销。`
})
const parentId = mapped.parent?.id || null
editorStore.setSelectedNodeId(realId)
editorStore.deleteSelectedNode()
editorStore.deleteNode(realId)
window.$message.success('节点删除成功')
// 💡 智能降级定位:删除当前节点后,尝试将操作目标转移至其父节点以防止数据链路断裂空白
if (parentId && editorStore.nodeMap.has(parentId)) {
activeNodeId.value = parentId
if (editorStore.selectedNodeId && editorStore.nodeMap.has(editorStore.selectedNodeId)) {
activeNodeId.value = editorStore.selectedNodeId
} else {
emit('update:visible', false) // 若没有存活父级,则主动关闭当前弹窗
emit('update:visible', false) // 若没有存活选中节点,则主动关闭当前弹窗
}
} catch (e) {
// 取消
......
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants'
import { getMinChildCount } from '@/utils/dtdManager'
import { canDeleteChild } from '@/utils/dtdManager'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore()
......@@ -127,18 +127,33 @@ const isAncestorSelected = (nodeId: string, selectedSet: Set<string>, nodeMap: M
return false
}
const getNodeDepth = (nodeId: string, nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>): number => {
let depth = 0
let currentId: string | null = nodeId
while (currentId) {
const item = nodeMap.get(currentId)
currentId = item?.parent?.id || null
if (currentId) depth++
}
return depth
}
const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>) => {
const safeIds: string[] = []
const blocked: BlockedNodeDetail[] = []
const selectedSet = new Set(selectedNodeIds)
const confirmedSafeSet = new Set<string>()
// 缓存每个父节点当前剩余的子节点 Tag 列表
const parentRemainingTagsMap = new Map<string, string[]>()
// 1. 按 parentId -> tagName 分组
const parentTagGroups = new Map<string, Map<string, string[]>>()
// 按深度从浅到深排序,保证优先处理祖先节点
const sortedNodeIds = [...selectedNodeIds].sort((a, b) => getNodeDepth(a, nodeMap) - getNodeDepth(b, nodeMap))
for (const nodeId of selectedNodeIds) {
for (const nodeId of sortedNodeIds) {
const item = nodeMap.get(nodeId)
if (!item) continue
// 根节点不允许删除
if (!item.parent) {
blocked.push({
id: nodeId,
......@@ -150,55 +165,52 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
}
const parentId = item.parent.id
const tagName = item.node.tagName
if (!parentTagGroups.has(parentId)) {
parentTagGroups.set(parentId, new Map())
}
const tagMap = parentTagGroups.get(parentId)!
if (!tagMap.has(tagName)) {
tagMap.set(tagName, [])
// 检查该节点的祖先中是否有已经被判定为“安全删除”的节点
let ancestorSafe = false
let currParent: XmlNode | null = item.parent
while (currParent) {
if (confirmedSafeSet.has(currParent.id)) {
ancestorSafe = true
break
}
const pItem = nodeMap.get(currParent.id)
currParent = pItem?.parent || null
}
tagMap.get(tagName)!.push(nodeId)
}
// 2. 校验每个分组
for (const [parentId, tagMap] of parentTagGroups.entries()) {
const parentItem = nodeMap.get(parentId)
if (!parentItem) continue
const parentNode = parentItem.node
if (isAncestorSelected(parentId, selectedSet, nodeMap)) {
for (const [_, nodeIds] of tagMap.entries()) {
safeIds.push(...nodeIds)
}
if (ancestorSafe) {
// 如果其祖先会被删除,则该子节点隐式安全,直接允许删除
safeIds.push(nodeId)
confirmedSafeSet.add(nodeId)
continue
}
for (const [tagName, nodeIds] of tagMap.entries()) {
const currentChildren = parentNode.children.filter((c) => c.tagName === tagName)
const totalCount = currentChildren.length
const minRequired = getMinChildCount(parentNode.tagName, tagName)
const maxDeletable = Math.max(0, totalCount - minRequired)
if (nodeIds.length <= maxDeletable) {
safeIds.push(...nodeIds)
} else {
const deletableCount = maxDeletable
const blockedCount = nodeIds.length - deletableCount
for (let i = 0; i < blockedCount; i++) {
blocked.push({
id: nodeIds[i],
tagName,
parentTagName: parentNode.tagName,
reason: `父节点 <${parentNode.tagName}> 下的子节点 <${tagName}> 最少需要保留 ${minRequired} 个(当前共 ${totalCount} 个,最多可删 ${maxDeletable} 个)`
})
}
for (let i = blockedCount; i < nodeIds.length; i++) {
safeIds.push(nodeIds[i])
}
// 获取或初始化父节点当前的剩余子节点列表
if (!parentRemainingTagsMap.has(parentId)) {
parentRemainingTagsMap.set(parentId, item.parent.children.map((c) => c.tagName))
}
const remaining = parentRemainingTagsMap.get(parentId)!
// 使用 canDeleteChild 校验在当前 remaining 状态下删除该节点是否合法
const isValid = canDeleteChild(item.parent.tagName, item.node.tagName, remaining)
if (isValid) {
// 如果合法,从 remaining 中移除一个该标签实例,并更新缓存
const index = remaining.indexOf(item.node.tagName)
if (index !== -1) {
remaining.splice(index, 1)
}
parentRemainingTagsMap.set(parentId, remaining)
safeIds.push(nodeId)
confirmedSafeSet.add(nodeId)
} else {
blocked.push({
id: nodeId,
tagName: item.node.tagName,
parentTagName: item.parent.tagName,
reason: `节点 <${item.node.tagName}> 是父节点 <${item.parent.tagName}> 的必要子元素,删除它将违反 DTD 约束`
})
}
}
......
......@@ -1128,26 +1128,7 @@ export function useNodeTree(
title: '确认删除',
content: `确定要删除${desc}吗?该操作将连带删除其所有子节点,且不可撤销!`
})
editorStore.saveSnapshot()
if (isVirtual && parent) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
parent.mixedContent.splice(textIdx, 1)
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) {
const mergedText = parent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
parent.textContent = mergedText
parent.mixedContent = []
} else {
parent.textContent = parent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
}
editorStore.setSelectedNodeId(parent.id)
editorStore.rebuildNodeMap()
} else {
editorStore.setSelectedNodeId(realNodeId)
editorStore.deleteSelectedNode()
}
editorStore.deleteNode(nodeId)
window.$message?.success('删除成功')
} catch {
// 取消
......
import { ref, computed } from 'vue'
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode'
......
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