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) => { ...@@ -50,7 +50,13 @@ const createService = (baseURL: string) => {
if (isJson) { if (isJson) {
json = (await response.json()) as ResponseData json = (await response.json()) as ResponseData
if (json) { 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 json.code = 200
return json return json
} }
...@@ -76,7 +82,13 @@ const createService = (baseURL: string) => { ...@@ -76,7 +82,13 @@ const createService = (baseURL: string) => {
} }
if (json) { 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 json.code = 200
} }
} }
......
...@@ -556,13 +556,11 @@ export const useEditorStore = defineStore('editor', { ...@@ -556,13 +556,11 @@ export const useEditorStore = defineStore('editor', {
this.selectedNodeId = id this.selectedNodeId = id
let renderedId: string | null = null let renderedId: string | null = null
if (id) { const isVirtualText = id ? id.includes('-txt-') : false
let realId = id
if (id.includes('-txt-')) {
realId = id.split('-txt-')[0]
}
let curr = this.nodeMap.get(realId) if (id && !isVirtualText) {
// 普通节点:沿祖先链找到已挂载的 DocNodeRenderer 节点并高亮其外框
let curr = this.nodeMap.get(id)
while (curr) { while (curr) {
if (nodeSelectedRefs.has(curr.node.id)) { if (nodeSelectedRefs.has(curr.node.id)) {
renderedId = curr.node.id renderedId = curr.node.id
...@@ -572,9 +570,10 @@ export const useEditorStore = defineStore('editor', { ...@@ -572,9 +570,10 @@ export const useEditorStore = defineStore('editor', {
} }
if (!renderedId) { if (!renderedId) {
renderedId = realId renderedId = id
} }
} }
// 虚拟文本节点(#text):不高亮父节点外框,由文本 span 自身通过 selectedNodeId 比较来显示高亮
const oldRenderedId = this.renderedSelectedNodeId const oldRenderedId = this.renderedSelectedNodeId
this.renderedSelectedNodeId = renderedId this.renderedSelectedNodeId = renderedId
...@@ -721,23 +720,71 @@ export const useEditorStore = defineStore('editor', { ...@@ -721,23 +720,71 @@ export const useEditorStore = defineStore('editor', {
}, },
/** /**
* 删除当前选中的节点 * 删除指定的节点(默认为当前选中节点)
*/ */
deleteSelectedNode() { deleteNode(nodeId?: string) {
if (!this.xmlTree || !this.selectedNodeId || this.selectedNodeId === this.xmlTree.id) { const targetId = nodeId || this.selectedNodeId
if (!this.xmlTree || !targetId || targetId === this.xmlTree.id) {
return // 不能删除根节点 return // 不能删除根节点
} }
const parent = this.selectedNodeParent let isVirtual = false
if (!parent) return 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() this.saveSnapshot()
const index = parent.children.findIndex((c: XmlNode) => c.id === 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) {
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('')
}
// 判断是否是当前选中节点
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) { if (index !== -1) {
parent.children.splice(index, 1) parent.children.splice(index, 1)
// 处理混合内容 (mixedContent) 中的对应项 // 处理混合内容 (mixedContent) 中的对应项
parent.mixedContent = parent.mixedContent.filter((item: any) => item.nodeId !== this.selectedNodeId) parent.mixedContent = parent.mixedContent.filter((item: any) => item.nodeId !== realId)
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组 // 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) { if (parent.children.length === 0) {
...@@ -748,13 +795,50 @@ export const useEditorStore = defineStore('editor', { ...@@ -748,13 +795,50 @@ export const useEditorStore = defineStore('editor', {
parent.textContent = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('') parent.textContent = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
} }
// 选中父节点 // 判断被删除的节点是否是选中的节点,或者选中的节点是否是被删除节点的后代
this.selectedNodeId = parent.id 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() this.rebuildNodeMap()
}, },
/** /**
* 删除当前选中的节点
*/
deleteSelectedNode() {
if (this.selectedNodeId) {
this.deleteNode(this.selectedNodeId)
}
},
/**
* 批量删除指定的多个节点(支持跨层级跨父节点) * 批量删除指定的多个节点(支持跨层级跨父节点)
*/ */
batchDeleteMultipleNodes(nodeIds: string[]) { batchDeleteMultipleNodes(nodeIds: string[]) {
......
...@@ -103,8 +103,11 @@ ...@@ -103,8 +103,11 @@
<template v-for="(item, index) in node.mixedContent" :key="index"> <template v-for="(item, index) in node.mixedContent" :key="index">
<span <span
v-if="item.type === 'text'" v-if="item.type === 'text'"
:data-node-id="`${node.id}-txt-${index}`"
contenteditable="true" 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)" @blur="handleMixedTextBlur(index, $event)"
v-text="item.text" v-text="item.text"
></span> ></span>
...@@ -147,8 +150,11 @@ ...@@ -147,8 +150,11 @@
<template v-for="(item, index) in node.mixedContent" :key="index"> <template v-for="(item, index) in node.mixedContent" :key="index">
<span <span
v-if="item.type === 'text'" v-if="item.type === 'text'"
:data-node-id="`${node.id}-txt-${index}`"
contenteditable="true" 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)" @blur="handleMixedTextBlur(index, $event)"
v-text="item.text" v-text="item.text"
></span> ></span>
......
...@@ -338,16 +338,13 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -338,16 +338,13 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
title: '确认删除', title: '确认删除',
content: `您确定要删除选中的节点 <${mapped.node.tagName}> 吗?该操作不可撤销。` content: `您确定要删除选中的节点 <${mapped.node.tagName}> 吗?该操作不可撤销。`
}) })
const parentId = mapped.parent?.id || null editorStore.deleteNode(realId)
editorStore.setSelectedNodeId(realId)
editorStore.deleteSelectedNode()
window.$message.success('节点删除成功') window.$message.success('节点删除成功')
// 💡 智能降级定位:删除当前节点后,尝试将操作目标转移至其父节点以防止数据链路断裂空白 if (editorStore.selectedNodeId && editorStore.nodeMap.has(editorStore.selectedNodeId)) {
if (parentId && editorStore.nodeMap.has(parentId)) { activeNodeId.value = editorStore.selectedNodeId
activeNodeId.value = parentId
} else { } else {
emit('update:visible', false) // 若没有存活父级,则主动关闭当前弹窗 emit('update:visible', false) // 若没有存活选中节点,则主动关闭当前弹窗
} }
} catch (e) { } catch (e) {
// 取消 // 取消
......
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants' import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants'
import { getMinChildCount } from '@/utils/dtdManager' import { canDeleteChild } from '@/utils/dtdManager'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) { export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
...@@ -127,18 +127,33 @@ const isAncestorSelected = (nodeId: string, selectedSet: Set<string>, nodeMap: M ...@@ -127,18 +127,33 @@ const isAncestorSelected = (nodeId: string, selectedSet: Set<string>, nodeMap: M
return false 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 partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>) => {
const safeIds: string[] = [] const safeIds: string[] = []
const blocked: BlockedNodeDetail[] = [] 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) const item = nodeMap.get(nodeId)
if (!item) continue if (!item) continue
// 根节点不允许删除
if (!item.parent) { if (!item.parent) {
blocked.push({ blocked.push({
id: nodeId, id: nodeId,
...@@ -150,56 +165,53 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { ...@@ -150,56 +165,53 @@ const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, {
} }
const parentId = item.parent.id const parentId = item.parent.id
const tagName = item.node.tagName
if (!parentTagGroups.has(parentId)) { // 检查该节点的祖先中是否有已经被判定为“安全删除”的节点
parentTagGroups.set(parentId, new Map()) let ancestorSafe = false
} let currParent: XmlNode | null = item.parent
const tagMap = parentTagGroups.get(parentId)! while (currParent) {
if (!tagMap.has(tagName)) { if (confirmedSafeSet.has(currParent.id)) {
tagMap.set(tagName, []) ancestorSafe = true
break
} }
tagMap.get(tagName)!.push(nodeId) const pItem = nodeMap.get(currParent.id)
currParent = pItem?.parent || null
} }
// 2. 校验每个分组 if (ancestorSafe) {
for (const [parentId, tagMap] of parentTagGroups.entries()) { // 如果其祖先会被删除,则该子节点隐式安全,直接允许删除
const parentItem = nodeMap.get(parentId) safeIds.push(nodeId)
if (!parentItem) continue confirmedSafeSet.add(nodeId)
const parentNode = parentItem.node
if (isAncestorSelected(parentId, selectedSet, nodeMap)) {
for (const [_, nodeIds] of tagMap.entries()) {
safeIds.push(...nodeIds)
}
continue continue
} }
for (const [tagName, nodeIds] of tagMap.entries()) { // 获取或初始化父节点当前的剩余子节点列表
const currentChildren = parentNode.children.filter((c) => c.tagName === tagName) if (!parentRemainingTagsMap.has(parentId)) {
const totalCount = currentChildren.length parentRemainingTagsMap.set(parentId, item.parent.children.map((c) => c.tagName))
const minRequired = getMinChildCount(parentNode.tagName, tagName) }
const maxDeletable = Math.max(0, totalCount - minRequired) const remaining = parentRemainingTagsMap.get(parentId)!
if (nodeIds.length <= maxDeletable) { // 使用 canDeleteChild 校验在当前 remaining 状态下删除该节点是否合法
safeIds.push(...nodeIds) const isValid = canDeleteChild(item.parent.tagName, item.node.tagName, remaining)
} else {
const deletableCount = maxDeletable if (isValid) {
const blockedCount = nodeIds.length - deletableCount // 如果合法,从 remaining 中移除一个该标签实例,并更新缓存
const index = remaining.indexOf(item.node.tagName)
if (index !== -1) {
remaining.splice(index, 1)
}
parentRemainingTagsMap.set(parentId, remaining)
for (let i = 0; i < blockedCount; i++) { safeIds.push(nodeId)
confirmedSafeSet.add(nodeId)
} else {
blocked.push({ blocked.push({
id: nodeIds[i], id: nodeId,
tagName, tagName: item.node.tagName,
parentTagName: parentNode.tagName, parentTagName: item.parent.tagName,
reason: `父节点 <${parentNode.tagName}> 下的子节点 <${tagName}> 最少需要保留 ${minRequired} 个(当前共 ${totalCount} 个,最多可删 ${maxDeletable} 个)` reason: `节点 <${item.node.tagName}> 是父节点 <${item.parent.tagName}> 的必要子元素,删除它将违反 DTD 约束`
}) })
} }
for (let i = blockedCount; i < nodeIds.length; i++) {
safeIds.push(nodeIds[i])
}
}
}
} }
return { return {
......
...@@ -1128,26 +1128,7 @@ export function useNodeTree( ...@@ -1128,26 +1128,7 @@ export function useNodeTree(
title: '确认删除', title: '确认删除',
content: `确定要删除${desc}吗?该操作将连带删除其所有子节点,且不可撤销!` content: `确定要删除${desc}吗?该操作将连带删除其所有子节点,且不可撤销!`
}) })
editorStore.saveSnapshot() editorStore.deleteNode(nodeId)
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()
}
window.$message?.success('删除成功') window.$message?.success('删除成功')
} catch { } catch {
// 取消 // 取消
......
import { ref, computed } from 'vue'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app' import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode' 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