Commit f7d98cfa by pangchong

refactor(editor): 优化 XML 编辑器文本节点相关交互与显示

- 修改双击词选中逻辑,统一选中完整单词的两端
- 序列化函数支持文本节点的正确转义与输出
- 右键菜单目标节点项增加 tooltip 显示完整内容,防止截断
- 智能翻译判定规则调整,精确匹配中文文本与对应英文原文
- 复制节点逻辑支持文本节点复制,提示和复制内容改进
- 粘贴文本节点实现多场景插入,包括混排内容和虚拟节点
- 节点显示名称增加 tooltip 字段,显示原始完整文本
- 面包屑路径生成时,单段文本不单列节点,仅多段时展示 #text 级别
- 编辑器面板复制按钮文本区分普通节点和文本节点显示“复制文本”
parent 1815f01c
......@@ -1069,21 +1069,14 @@ const initCodeMirror = () => {
// 检查双击点是否在属性区(tagText 包含空格、= 或引号)
const isInsideAttribute = /[\s='"]/.test(tagText)
let checkPos = clickPos
if (isInsideAttribute) {
// 1. 双击属性名/属性值:定位到词首,前缀为空 -> 展示全量候选列表
while (checkPos > 0 && /[a-zA-Z0-9_-]/.test(docText[checkPos - 1])) {
checkPos--
}
} else {
// 2. 双击节点标签名:定位到词尾,带有完整单词前缀 -> 进行完全匹配
while (checkPos < docText.length && /[a-zA-Z0-9_-]/.test(docText[checkPos])) {
checkPos++
}
}
// 折叠光标到目标位置
editorView.dispatch({ selection: { anchor: checkPos } })
// 无论双击的是属性名、属性值还是标签名,均向两侧扩展选中完整单词
let wordStart = clickPos
let wordEnd = clickPos
while (wordStart > 0 && /[a-zA-Z0-9_-]/.test(docText[wordStart - 1])) wordStart--
while (wordEnd < docText.length && /[a-zA-Z0-9_-]/.test(docText[wordEnd])) wordEnd++
// 选中整个词,光标停在词尾(CodeMirror anchor=start, head=end 即左→右高亮)
editorView.dispatch({ selection: { anchor: wordStart, head: wordEnd } })
editorView.focus()
setTimeout(() => {
......
......@@ -119,6 +119,9 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
* 将 XmlNode 树序列化回 XML 字符串
*/
export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: boolean = false): string {
if (node.tagName === '#text') {
return escapeXmlText(node.textContent || '')
}
const pad = compact ? '' : ' '.repeat(indent)
const newline = compact ? '' : '\n'
const attrs = Object.entries(node.attributes)
......
......@@ -3,7 +3,7 @@
class="target-node-item"
:class="{ 'is-selected': isSelected }"
:style="{ paddingLeft: `${(item.depth || 0) * 12 + 12}px` }"
:title="item.displayName"
:title="item.tooltip || item.displayName"
>
<span class="check-space">
<span v-if="isSelected" class="check-icon"></span>
......@@ -19,6 +19,8 @@ export interface TargetItem {
tagName: string
suffix: string
displayName: string
/** 完整未截断的内容,供原生 title 属性悬停时展示 */
tooltip?: string
pathString: string
depth: number
}
......
......@@ -252,21 +252,18 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
return activeNode.value ? VIRTUAL_LAYOUT_TAGS.includes(activeNode.value.tagName) : false
})
// 智能翻译是否可用(非纯结构节点
// 智能翻译是否可用(判定规则与树节点及选区工具栏完全一致:目标节点需为中文C节点、拥有对应的英文原文节点、且原文有内容
const canTranslateActive = computed(() => {
const node = activeNode.value
if (!node) return false
if (node.tagName === '#text') return true
// 如果是元素,检查它是否包含文本
const text = node.textContent?.trim() || ''
if (text) return true
// 或者是混排段落
if (node.mixedContent && node.mixedContent.some((item) => item.type === 'text' && item.text?.trim())) {
return true
if (!activeNodeId.value) return false
let realId = activeNodeId.value
if (realId.includes('-txt-')) {
realId = realId.split('-txt-')[0]
}
return false
const mapped = editorStore.nodeMap.get(realId)
if (!mapped) return false
const enSourceNode = getEnglishSourceNode(mapped.node, mapped.parent)
return !!enSourceNode && hasTranslatableText(enSourceNode)
})
// 是否允许删除(非根节点 + DTD 约束校验)
......@@ -396,12 +393,23 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
// 2. 复制节点
else if (actionName === 'copyNode') {
if (isVirtual) {
window.$message.warning('文本节点暂不支持单独复制')
return
const textIdx = parseInt(activeNodeId.value.split('-txt-')[1], 10)
const textVal = mapped.node.mixedContent?.[textIdx]?.text || mapped.node.textContent || ''
copyNodeCache.value = {
id: activeNodeId.value,
tagName: '#text',
attributes: {},
children: [],
textContent: textVal,
mixedContent: [],
parentId: mapped.node.id
}
window.$message.success('文本已复制')
} else {
copyNodeCache.value = JSON.parse(JSON.stringify(mapped.node))
window.$message.success(`已复制节点 <${mapped.node.tagName}>`)
}
}
// 3. 删除节点
else if (actionName === 'deleteNode') {
......@@ -526,6 +534,42 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const mode = PASTE_MODE_MAP[actionName]
if (mode && copyNodeCache.value) {
try {
if (copyNodeCache.value.tagName === '#text') {
const targetItem = editorStore.nodeMap.get(realId)
if (!targetItem) throw new Error('目标节点无效')
const textVal = copyNodeCache.value.textContent || ''
editorStore.saveSnapshot()
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 && parentItem.node.mixedContent) {
const insertIdx = mode === 'above' ? textIdx : textIdx + 1
parentItem.node.mixedContent.splice(insertIdx, 0, { type: 'text', text: textVal })
parentItem.node.textContent = parentItem.node.mixedContent
.map((i: any) => (i.type === 'text' ? i.text || '' : ''))
.join('')
}
} else {
if (mode === 'inside') {
if (!targetItem.node.mixedContent) targetItem.node.mixedContent = []
targetItem.node.mixedContent.push({ type: 'text', text: textVal })
} else if (targetItem.parent) {
const mixedIdx = targetItem.parent.mixedContent.findIndex((m: any) => m.nodeId === realId)
const insertIdx = mode === 'above' ? (mixedIdx !== -1 ? mixedIdx : 0) : (mixedIdx !== -1 ? mixedIdx + 1 : targetItem.parent.mixedContent.length)
targetItem.parent.mixedContent.splice(insertIdx, 0, { type: 'text', text: textVal })
}
targetItem.node.textContent = targetItem.node.mixedContent
.map((i: any) => (i.type === 'text' ? i.text || '' : ''))
.join('')
}
editorStore.rebuildNodeMap()
window.$message.success('文本粘贴成功')
return
}
let xml = ''
if (actionName.startsWith('insertFragment')) {
xml = serializeTreeToXml(copyNodeCache.value)
......@@ -617,34 +661,46 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const mcItem = parentItem.node.mixedContent[textIdx] as any
const text = (mcItem?.type === 'text' ? mcItem.text : '')?.trim() || ''
const suffix = text ? `"${text}"` : ''
const displayName = suffix ? `#text ${suffix}` : '#text'
return {
tagName: '#text',
suffix,
displayName: suffix ? `#text ${suffix}` : '#text'
displayName,
// tooltip 用完整原文,不截断
tooltip: displayName
}
}
}
if (!node) return { tagName: '未知', suffix: '', displayName: '未知' }
if (!node) return { tagName: '未知', suffix: '', displayName: '未知', tooltip: '未知' }
if (node.tagName === '#text') {
const text = node.textContent?.trim() || ''
const suffix = text ? `"${text}"` : ''
const displayName = suffix ? `#text ${suffix}` : '#text'
return {
tagName: '#text',
suffix,
displayName: suffix ? `#text ${suffix}` : '#text'
displayName,
tooltip: displayName
}
}
let suffix = ''
const attrParts: string[] = []
// tooltip 专用:属性部分与 displayName 完全相同(属性本身不截断)
const tooltipAttrParts: string[] = []
if (node.attributes?.ID) {
attrParts.push(node.attributes.ID)
tooltipAttrParts.push(node.attributes.ID)
}
if (node.attributes?.EFFRG) {
const eff = node.attributes.EFFRG.replace(/\s+/g, '')
if (eff !== '001999') attrParts.push(`A/C: ${eff}`)
if (eff !== '001999') {
attrParts.push(`A/C: ${eff}`)
tooltipAttrParts.push(`A/C: ${eff}`)
}
}
if (node.attributes?.REFID) {
attrParts.push(`REFID: ${node.attributes.REFID}`)
tooltipAttrParts.push(`REFID: ${node.attributes.REFID}`)
}
// 提取节点的直接文本内容 preview(当仅包含 1 个文本段时,将其合并至元素节点本行展示;包含多个时交由子 #text 节点拆分)
......@@ -659,16 +715,23 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
}
if (directText) {
// displayName / suffix:截断版(UI 展示)
const shortTxt = directText.length > MAX_TEXT_PREVIEW_LENGTH ? directText.substring(0, MAX_TEXT_PREVIEW_LENGTH) + '...' : directText
attrParts.push(`"${shortTxt}"`)
// tooltip:完整版(鼠标悬停)
tooltipAttrParts.push(`"${directText}"`)
}
suffix = attrParts.join(' ')
const tagName = `<${node.tagName}>`
const displayName = suffix ? `${tagName} ${suffix}` : tagName
const tooltipSuffix = tooltipAttrParts.join(' ')
const tooltip = tooltipSuffix ? `${tagName} ${tooltipSuffix}` : tagName
return {
tagName,
suffix,
displayName: suffix ? `${tagName} ${suffix}` : tagName
displayName,
tooltip
}
}
......@@ -713,6 +776,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
tagName: info.tagName,
suffix: info.suffix,
displayName: info.displayName,
// tooltip 携带完整未截断内容,供鼠标悬停时展示
tooltip: info.tooltip ?? info.displayName,
pathString: anc.pathString,
depth: anc.depth
})
......@@ -944,10 +1009,9 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
// 4. 复制与粘贴
options.push({
label: '复制节点',
label: isTextNode.value ? '复制文本' : '复制节点',
key: 'copyNode',
icon: icon(CopyOutline),
disabled: isTextNode.value
icon: icon(CopyOutline)
})
options.push({
......
......@@ -34,6 +34,12 @@ export function useEditorPanel() {
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
if (editorStore.selectedNodeId.includes('-txt-')) {
// 当父节点 mixedContent 中只有一段文本时(singleTextMode),
// 该文本被视为父节点自身内容,不在树中单独展示,面包屑也不追加 #text 层级。
// 只有多段文本(多个独立 #text 虚拟节点)时,才需要将当前 #text 加入路径。
const parentNode = editorStore.nodeMap.get(realId)?.node
const textItemCount = parentNode?.mixedContent?.filter((m) => m.type === 'text' && (m.text || '').trim()).length ?? 0
if (textItemCount > 1) {
path.push({
id: editorStore.selectedNodeId,
tagName: '#text',
......@@ -44,6 +50,7 @@ export function useEditorPanel() {
parentId: realId
})
}
}
return path
})
......
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