Commit f7d98cfa by pangchong

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

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