Commit a43883bf by pangchong

feat(editor): 支持虚拟文本节点的编辑和操作

- getNodeDisplayName 函数新增对虚拟文本节点的文本内容预览显示支持,限制最大长度180字符
- 节点列表收集逻辑中过滤虚拟文本节点并正确展示顺序,增强节点展示准确性
- 编辑器中混合内容渲染增加虚拟文本节点的点击选中功能,支持对子文本内容的单独操作
- 编辑面板处理选中虚拟文本节点时,调整允许操作的标签类型为 '#text'
- 右键菜单新增对虚拟文本节点的编辑、删除、移动等操作支持,删除时同步更新混合内容数组并重建节点映射
- 编辑面板新增对虚拟文本节点的上移、下移操作,保持活动选中状态同步更新为对应的虚拟节点ID
- 针对文本节点特殊处理编辑结构和剪贴板相关选项,优化交互体验
- 节点添加时优先提取混合内容中第一个文本内容作为节点文本内容显示和编辑基础,提高显示准确性
parent dce75aea
......@@ -169,11 +169,27 @@ const getHighlightedHtml = (text: string) => {
}
// ── 格式化节点显示文本 ──
const getNodeDisplayName = (node: XmlNode): string => {
const getNodeDisplayName = (node: XmlNode, rawId?: string): string => {
if (rawId && rawId.includes('-txt-')) {
const realParentId = rawId.split('-txt-')[0]
const textIdx = parseInt(rawId.split('-txt-')[1], 10)
const parentItem = editorStore.nodeMap.get(realParentId)
if (parentItem && Array.isArray(parentItem.node.mixedContent)) {
let mcItem = parentItem.node.mixedContent[textIdx] as any
if (!mcItem || mcItem.type !== 'text') {
mcItem = parentItem.node.mixedContent.find((m: any) => m.type === 'text')
}
const text = mcItem?.text?.trim() || ''
const preview = text.length > 180 ? text.substring(0, 180) + '...' : text
return `文本内容 "${preview}"`
}
}
let suffix = ''
if (node.tagName === '#text') {
const text = node.textContent?.trim() || ''
suffix = ` "${text}"`
const preview = text.length > 180 ? text.substring(0, 180) + '...' : text
suffix = ` "${preview}"`
return `文本内容${suffix}`
}
......@@ -184,7 +200,8 @@ const getNodeDisplayName = (node: XmlNode): string => {
} else {
const text = node.textContent?.trim() || ''
if (text) {
suffix = ` "${text}"`
const preview = text.length > 180 ? text.substring(0, 180) + '...' : text
suffix = ` "${preview}"`
}
}
return `<${node.tagName}>${suffix}`
......@@ -273,22 +290,18 @@ const processedItems = computed(() => {
const list: any[] = []
// 🟢 A. 收集三代父辈节点 (自顶向下排列:曾爷爷 -> 爷爷 -> 爸爸,置于最顶层)
// props.nodeIds 包含 [自身, 爸爸, 爷爷, 曾爷爷, ...]
// 我们取 index 在 1 到 5 之间的最近四代父辈并反转,呈自顶向下顺序,避免追溯到更顶层无关的根节点
// 四代父辈,自身,三代子辈,同辈
const ancestorIds = props.nodeIds.slice(1, 5).reverse()
const ancestorIds = props.nodeIds
.filter((id) => !id.includes('-txt-'))
.slice(1, 5)
.reverse()
ancestorIds.forEach((id) => {
let rid = id
if (id.includes('-txt-')) {
rid = id.split('-txt-')[0]
}
if (rid !== baseRealId) {
const mapped = nodeMap.get(rid)
if (id !== baseRealId) {
const mapped = nodeMap.get(id)
if (mapped) {
const node = mapped.node
list.push({
id: id,
displayName: getNodeDisplayName(node),
displayName: getNodeDisplayName(node, id),
pathString: mapped.parent ? getNodeParentPath(mapped.parent.id, nodeMap) : '无 (根节点)',
checked: false,
disabled: false
......@@ -297,7 +310,7 @@ const processedItems = computed(() => {
}
})
// 🟢 B. 添加自身
// 🟢 B. 添加基础真实元素节点 (如 <PARAC>)
list.push({
id: baseRealId,
displayName: getNodeDisplayName(baseNode),
......@@ -369,27 +382,44 @@ const processedItems = computed(() => {
}
}
// 🟢 F. 虚拟文本节点还原与精确定位插入
if (leafId.includes('-txt-')) {
const textIdx = parseInt(leafId.split('-txt-')[1], 10)
const textVal = baseNode.mixedContent?.[textIdx]?.text || ''
const virtualNode = {
id: leafId,
tagName: '#text',
attributes: {},
children: [],
textContent: textVal,
mixedContent: [],
parentId: baseRealId
// 🟢 F. 虚拟文本节点还原与精确定位插入(根据 mixedContent 实时真实索引查找)
if (baseNode.mixedContent && baseNode.mixedContent.length > 0) {
let actualMcIdx = -1
let textVal = ''
const targetVirtualId = (props.selectedId && props.selectedId.includes('-txt-'))
? props.selectedId
: (leafId.includes('-txt-') ? leafId : '')
if (targetVirtualId.includes('-txt-')) {
const targetIdx = parseInt(targetVirtualId.split('-txt-')[1], 10)
if (baseNode.mixedContent[targetIdx]?.type === 'text') {
actualMcIdx = targetIdx
textVal = baseNode.mixedContent[targetIdx].text || ''
}
}
if (actualMcIdx === -1) {
actualMcIdx = baseNode.mixedContent.findIndex((m: any) => m.type === 'text' && (m.text || '').trim())
if (actualMcIdx !== -1) {
textVal = baseNode.mixedContent[actualMcIdx].text || ''
}
}
if (actualMcIdx !== -1) {
const liveVirtualId = `${baseRealId}-txt-${actualMcIdx}`
const trimmed = textVal.trim()
const preview = trimmed.length > 180 ? trimmed.substring(0, 180) + '...' : trimmed
const vItem = {
id: leafId,
displayName: getNodeDisplayName(virtualNode),
id: liveVirtualId,
displayName: `文本内容 "${preview}"`,
pathString: getNodeParentPath(baseRealId, nodeMap),
checked: false,
disabled: false
}
if (!list.some((item) => item.id === liveVirtualId)) {
const parentIdx = list.findIndex((item) => item.id === baseRealId)
if (parentIdx !== -1) {
list.splice(parentIdx + 1, 0, vItem)
......@@ -397,6 +427,8 @@ const processedItems = computed(() => {
list.push(vItem)
}
}
}
}
return list
}
......
......@@ -1010,7 +1010,12 @@
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
<template v-if="node.mixedContent && node.mixedContent.length > 0">
<span v-for="(mixed, idx) in node.mixedContent" :key="idx">
<span
v-for="(mixed, idx) in node.mixedContent"
:key="idx"
:data-node-id="mixed.type === 'text' ? `${node.id}-txt-${idx}` : undefined"
@click.stop="mixed.type === 'text' ? editorStore.setSelectedNodeId(`${node.id}-txt-${idx}`) : undefined"
>
<template v-if="mixed.type === 'text'">{{ mixed.text }}</template>
<template v-else-if="mixed.type === 'element'">
<DocNodeRenderer :node="node.children.find((c) => c.id === mixed.nodeId)!" :parent="node" is-inline />
......
......@@ -346,15 +346,11 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const mapped = editorStore.nodeMap.get(realId)
if (!mapped) return
// 1. 编辑节点属性
// 1. 编辑节点 (包括元素属性与文本片段内容)
if (actionName === 'editNode') {
if (isVirtual) {
window.$message.warning('文本节点无属性可编辑,请直接在内容区双击编辑文本')
return
}
addNodeTargetId.value = realId
addNodeTargetId.value = nodeId
addNodeMode.value = 'edit'
addNodeAllowedTags.value = [mapped.node.tagName]
addNodeAllowedTags.value = [isVirtual ? '#text' : mapped.node.tagName]
addNodeVisible.value = true
}
......@@ -370,15 +366,39 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
// 3. 删除节点
else if (actionName === 'deleteNode') {
if (isVirtual) {
window.$message.warning('文本节点暂不支持直接删除,请直接编辑清空内容')
return
}
try {
await window.$dialog.warning({
title: '确认删除',
content: `您确定要删除选中的节点 <${mapped.node.tagName}> 吗?该操作不可撤销。`
content: isVirtual ? '您确定要删除此段文本内容吗?该操作不可撤销。' : `您确定要删除选中的节点 <${mapped?.node?.tagName || ''}> 吗?该操作不可撤销。`
})
if (isVirtual) {
const realParentId = activeNodeId.value.split('-txt-')[0]
const textIdx = parseInt(activeNodeId.value.split('-txt-')[1], 10)
const parentItem = editorStore.nodeMap.get(realParentId)
if (parentItem && Array.isArray(parentItem.node.mixedContent)) {
editorStore.saveSnapshot()
if (parentItem.node.mixedContent[textIdx]?.type === 'text') {
parentItem.node.mixedContent.splice(textIdx, 1)
} else {
const foundIdx = parentItem.node.mixedContent.findIndex((m: any) => m.type === 'text')
if (foundIdx !== -1) {
parentItem.node.mixedContent.splice(foundIdx, 1)
}
}
// 重新拼合父节点的 textContent
parentItem.node.textContent = parentItem.node.mixedContent
.map((item: any) => (item.type === 'text' ? item.text || '' : ''))
.join('')
editorStore.rebuildNodeMap()
editorStore.setSelectedNodeId(realParentId)
activeNodeId.value = realParentId
window.$message.success('已成功删除文本内容')
}
} else {
editorStore.deleteNode(realId)
window.$message.success('节点删除成功')
......@@ -387,8 +407,9 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
} else {
emit('update:visible', false) // 若没有存活选中节点,则主动关闭当前弹窗
}
}
} catch (e) {
// 取消
// 用户取消
}
}
......@@ -546,10 +567,20 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return
}
editorStore.saveSnapshot()
const oldMcIdx = isVirtual ? parseInt(nodeIdToMove.split('-txt-')[1], 10) : -1
const movedTextVal = isVirtual && parent.mixedContent?.[oldMcIdx] ? parent.mixedContent[oldMcIdx].text || '' : ''
const success = moveNodeInParent(parent, nodeIdToMove, 'up', isVirtual)
if (success) {
editorStore.rebuildNodeMap()
if (!isVirtual) {
if (isVirtual) {
const newMcIdx = parent.mixedContent.findIndex((m) => m.type === 'text' && (m.text || '').trim() === movedTextVal.trim())
const targetIdx = newMcIdx !== -1 ? newMcIdx : Math.max(0, oldMcIdx - 1)
const newVirtualId = `${realId}-txt-${targetIdx}`
activeNodeId.value = newVirtualId
editorStore.setSelectedNodeId(newVirtualId)
} else {
editorStore.setSelectedNodeId(realId)
}
window.$message.success('上移成功')
......@@ -570,10 +601,20 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return
}
editorStore.saveSnapshot()
const oldMcIdx = isVirtual ? parseInt(nodeIdToMove.split('-txt-')[1], 10) : -1
const movedTextVal = isVirtual && parent.mixedContent?.[oldMcIdx] ? parent.mixedContent[oldMcIdx].text || '' : ''
const success = moveNodeInParent(parent, nodeIdToMove, 'down', isVirtual)
if (success) {
editorStore.rebuildNodeMap()
if (!isVirtual) {
if (isVirtual) {
const newMcIdx = parent.mixedContent.findIndex((m) => m.type === 'text' && (m.text || '').trim() === movedTextVal.trim())
const targetIdx = newMcIdx !== -1 ? newMcIdx : oldMcIdx + 1
const newVirtualId = `${realId}-txt-${targetIdx}`
activeNodeId.value = newVirtualId
editorStore.setSelectedNodeId(newVirtualId)
} else {
editorStore.setSelectedNodeId(realId)
}
window.$message.success('下移成功')
......@@ -589,11 +630,14 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
{ label: '粘贴为子节点', key: 'insertFragmentInside', disabled: isTextNode.value || !allowedChildrenList.value.length }
])
const editStructureOptions = computed(() => [
{ label: '添加子节点', key: 'addChildNode', disabled: isTextNode.value || !allowedChildrenList.value.length },
const editStructureOptions = computed(() => {
const disableChild = isTextNode.value ? !insertableParentList.value.length : !allowedChildrenList.value.length
return [
{ label: isTextNode.value ? '插入行内元素' : '添加子节点', key: 'addChildNode', disabled: disableChild },
{ label: '插入到上方', key: 'insertBefore', disabled: !insertableParentList.value.length },
{ label: '插入到下方', key: 'insertAfter', disabled: !insertableParentList.value.length }
])
]
})
const editOrderOptions = computed(() => [
{ label: '上移', key: 'moveUp', disabled: !canMoveUpActive.value },
......
......@@ -46,120 +46,126 @@
</div>
<template v-else>
<div class="flex flex-col gap-2 pt-1">
<!-- 第一排:核心编辑与结构 (编辑节点 | 编辑结构 | 编辑顺序) -->
<div class="grid grid-cols-3 gap-2">
<!-- 1. 编辑节点 -->
<CommonButton type="success" secondary block @click="runAction('editNode')">
<CommonButton type="primary" block @click="runAction('editNode')">
<template #icon>
<n-icon><create-outline /></n-icon>
</template>
编辑节点
</CommonButton>
<!-- 2. 复制节点 -->
<CommonButton type="primary" secondary block @click="runAction('copyNode')">
<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><copy-outline /></n-icon>
<n-icon class="text-amber-500"><build-outline /></n-icon>
</template>
复制节点
编辑结构
</CommonButton>
</n-dropdown>
<!-- 3. 粘贴 XML -->
<n-dropdown trigger="click" :options="pasteXmlOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="success" secondary block :disabled="!hasCopyCache">
<n-dropdown
trigger="click"
:options="editOrderOptions"
:disabled="!canMoveUpActive && !canMoveDownActive"
@select="runAction"
>
<CommonButton secondary block :disabled="!canMoveUpActive && !canMoveDownActive">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
<n-icon class="text-amber-500"><swap-vertical-outline /></n-icon>
</template>
粘贴XML
编辑顺序
</CommonButton>
</n-dropdown>
</div>
<div class="grid grid-cols-4 gap-2">
<!-- 4. 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon>
<n-icon><trash-outline /></n-icon>
</template>
删除节点
</CommonButton>
<!-- 5. 编辑结构 -->
<n-dropdown trigger="click" :options="editStructureOptions" :disabled="isTextNode" @select="runAction">
<CommonButton type="warning" secondary block :disabled="isTextNode">
<!-- 第二排:剪贴板类 (复制节点 | 粘贴XML | 粘贴节点) -->
<div class="grid grid-cols-3 gap-2">
<CommonButton secondary block @click="runAction('copyNode')">
<template #icon>
<n-icon><build-outline /></n-icon>
<n-icon class="text-primary"><copy-outline /></n-icon>
</template>
编辑结构
复制节点
</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)">
<n-dropdown trigger="click" :options="pasteXmlOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon><swap-vertical-outline /></n-icon>
<n-icon class="text-primary"><clipboard-outline /></n-icon>
</template>
编辑顺序
粘贴XML
</CommonButton>
</n-dropdown>
<!-- 6. 粘贴节点 (DTD) -->
<n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="warning" secondary block :disabled="!hasCopyCache">
<CommonButton secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
<n-icon class="text-primary"><clipboard-outline /></n-icon>
</template>
粘贴节点
</CommonButton>
</n-dropdown>
</div>
<!-- 第三排:查看与检索类 (查看 XML | 查看规则 | 智能翻译) -->
<div class="grid grid-cols-3 gap-2">
<!-- 7. 智能翻译 -->
<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
type="info"
secondary
block
:disabled="!canTranslateActive || isTranslating(activeNodeId)"
@click="runAction('translateNode')"
>
<template #icon>
<n-icon v-if="isTranslating(activeNodeId)" class="animate-spin"><sync-outline /></n-icon>
<n-icon v-else><language-outline /></n-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>
<!-- 8. 查看 XML 片段 -->
<CommonButton type="info" secondary block @click="runAction('viewXml')">
<!-- 第四排:保存与删除类 (保存为模板 | 删除节点) -->
<div class="grid grid-cols-3 gap-2">
<CommonButton secondary block class="col-span-2" @click="runAction('saveTemplate')">
<template #icon>
<n-icon><code-working-outline /></n-icon>
<n-icon class="text-purple-500"><save-outline /></n-icon>
</template>
查看 XML
保存为模板
</CommonButton>
<!-- 9. 查看 DTD 规则 -->
<CommonButton type="info" secondary block :disabled="isTextNode" @click="runAction('checkRule')">
<CommonButton
type="error"
secondary
block
class="col-span-1"
:disabled="!canDeleteActive"
@click="runAction('deleteNode')"
>
<template #icon>
<n-icon><eye-outline /></n-icon>
<n-icon><trash-outline /></n-icon>
</template>
查看规则
删除节点
</CommonButton>
</div>
<!-- 保存为模板 -->
<div class="grid grid-cols-3 gap-2">
<CommonButton type="primary" secondary block class="col-span-3" @click="runAction('saveTemplate')">
<template #icon>
<n-icon><save-outline /></n-icon>
</template>
保存为模板
</CommonButton>
</div>
</template>
</div>
......
......@@ -140,9 +140,14 @@ useKeyboardShortcuts({
const handleEditSelectedNode = () => {
if (!selectedNode.value) return
addNodeTargetId.value = selectedNode.value.id
const targetId = editorStore.selectedNodeId || selectedNode.value.id
addNodeTargetId.value = targetId
addNodeMode.value = 'edit'
if (targetId.includes('-txt-')) {
addNodeAllowedTags.value = ['#text']
} else {
addNodeAllowedTags.value = [selectedNode.value.tagName]
}
addNodeVisible.value = true
}
......@@ -155,18 +160,31 @@ const handleContextMenu = (e: MouseEvent) => {
const wrapper = target.closest('[data-node-id]')
if (!wrapper) return
const nodeId = wrapper.getAttribute('data-node-id')
if (!nodeId) return
const rawNodeId = wrapper.getAttribute('data-node-id')
if (!rawNodeId) return
const isVirtualText = rawNodeId.includes('-txt-')
const realId = isVirtualText ? rawNodeId.split('-txt-')[0] : rawNodeId
// 向上回溯构建祖先节点 ID 链
const ids: string[] = []
let currentId: string | null = nodeId
if (isVirtualText) {
ids.push(rawNodeId)
}
let currentId: string | null = realId
let isInsideTable = false
while (currentId) {
const item = editorStore.nodeMap.get(currentId)
if (item) {
if (item.node.tagName === 'TABLE' || item.node.tagName === 'ENTRY' || item.node.tagName === 'ROW') {
if (
item.node.tagName === 'TABLE' ||
item.node.tagName === 'ENTRY' ||
item.node.tagName === 'ROW' ||
item.node.tagName === 'TBODY' ||
item.node.tagName === 'TGROUP'
) {
isInsideTable = true
}
ids.push(currentId)
......
......@@ -137,7 +137,12 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
if (node) {
form.tagName = node.tagName
if (isMixedContentElement(node.tagName) && Array.isArray(node.mixedContent) && node.mixedContent.length > 0) {
const firstText = node.mixedContent.find((i) => i.type === 'text')?.text
form.textContent = firstText !== undefined && firstText !== '' ? firstText : (node.textContent || '')
} else {
form.textContent = node.textContent || ''
}
const defs = getElementAttributes(node.tagName)
const attrs: Record<string, string | null> = {}
for (const name of Object.keys(defs)) {
......
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