Commit 1815f01c by pangchong

refactor(editor): 优化编辑区上下文菜单结构与行为

- 将 EditAreaContextMenuModal 标签简化为单行,提高代码简洁性
- 在常量中定义节点树菜单控制的多项限值配置
- 统一粘贴与插入动作的模式映射,简化相关逻辑
- 重新设计粘贴节点时的 ID 生成,避免冲突并保证唯一性
- 合并节点添加相关模式处理,简化代码结构
- 优化移动节点逻辑,支持上移与下移统一处理与提示
- 使用常量替代硬编码数字,统一最大递归层级、文本预览长度等参数
- 调整节点及其子节点展开的递归深度与同级节点展示范围
- 修正节点文本预览截断长度逻辑,使其依赖配置常量
parent 047f00db
...@@ -5,3 +5,28 @@ export interface EditAreaContextMenuProps { ...@@ -5,3 +5,28 @@ export interface EditAreaContextMenuProps {
y?: number y?: number
} }
// ── 节点树菜单控制常量 ──
/** 最多回溯的祖先节点层级数 */
export const MAX_ANCESTOR_DEPTH = 4
/** 同级节点展示窗口偏移量 (±4) */
export const SIBLING_WINDOW_OFFSET = 4
/** 目标节点下层展开的递归最大深度 */
export const MAX_CHILD_DEPTH_LEVEL = 2
/** 无 mixedContent 时展开直系子节点的最大数量 */
export const MAX_DIRECT_CHILD_COUNT = 5
/** 节点文本预览的最大显示字符数 */
export const MAX_TEXT_PREVIEW_LENGTH = 40
// ── 粘贴与插入动作模式映射 ──
export const PASTE_MODE_MAP: Record<string, 'above' | 'below' | 'inside'> = {
insertFragmentAbove: 'above',
insertFragmentBelow: 'below',
insertFragmentInside: 'inside',
pasteNodeAbove: 'above',
pasteNodeBelow: 'below',
pasteNodeInside: 'inside'
}
...@@ -22,17 +22,13 @@ import { useEditorStore } from '@/store/editor' ...@@ -22,17 +22,13 @@ import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { EditAreaContextMenuProps } from '../constants' import type { EditAreaContextMenuProps } from '../constants'
import { import {
getElementRule, MAX_ANCESTOR_DEPTH,
canDeleteChild, SIBLING_WINDOW_OFFSET,
isMixedContentElement, MAX_CHILD_DEPTH_LEVEL,
isTextOnlyElement, MAX_DIRECT_CHILD_COUNT,
getInsertableChildren, MAX_TEXT_PREVIEW_LENGTH,
sortChildrenByDtd, PASTE_MODE_MAP
checkCanMoveNode, } from '../constants'
moveNodeInParent
} from '@/utils/dtdManager'
import { serializeTreeToXml } from '@/utils/xmlParser'
import { saveTemplate } from '@/utils/bridge'
// 导入共享弹窗状态 // 导入共享弹窗状态
import { import {
...@@ -50,7 +46,6 @@ import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags' ...@@ -50,7 +46,6 @@ import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (event: 'update:visible', val: boolean) => void) { export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (event: 'update:visible', val: boolean) => void) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const themeVars = useThemeVars()
const getEnglishSourceNode = (node: XmlNode, parent: XmlNode | null): XmlNode | null => { const getEnglishSourceNode = (node: XmlNode, parent: XmlNode | null): XmlNode | null => {
if (!parent || !node.tagName.endsWith('C')) return null if (!parent || !node.tagName.endsWith('C')) return null
...@@ -526,38 +521,16 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -526,38 +521,16 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
} }
} }
// 8. 粘贴为上方/下方/内部 XML 片段 // 8. 粘贴 XML 片段或节点 (上方/下方/内部)
else if (actionName.startsWith('insertFragment')) { else if (actionName.startsWith('insertFragment') || actionName.startsWith('pasteNode')) {
const modeMap: Record<string, 'above' | 'below' | 'inside'> = { const mode = PASTE_MODE_MAP[actionName]
insertFragmentAbove: 'above',
insertFragmentBelow: 'below',
insertFragmentInside: 'inside'
}
const mode = modeMap[actionName]
if (mode && copyNodeCache.value) {
try {
const xml = serializeTreeToXml(copyNodeCache.value)
editorStore.insertXmlFragment(xml, mode, realId)
window.$message.success('XML 片段粘贴成功')
} catch (err: any) {
window.$message.error(err.message || 'XML 片段粘贴失败')
}
}
}
// 9. 粘贴节点为上方/下方/内部
else if (actionName.startsWith('pasteNode')) {
const modeMap: Record<string, 'above' | 'below' | 'inside'> = {
pasteNodeAbove: 'above',
pasteNodeBelow: 'below',
pasteNodeInside: 'inside'
}
const mode = modeMap[actionName]
if (mode && copyNodeCache.value) { if (mode && copyNodeCache.value) {
try { try {
let xml = ''
if (actionName.startsWith('insertFragment')) {
xml = serializeTreeToXml(copyNodeCache.value)
} else {
const cloneNode = JSON.parse(JSON.stringify(copyNodeCache.value)) const cloneNode = JSON.parse(JSON.stringify(copyNodeCache.value))
// 递归生成全新 UUID
const idMap = new Map<string, string>() const idMap = new Map<string, string>()
const regenerateIds = (n: XmlNode, pid: string | null) => { const regenerateIds = (n: XmlNode, pid: string | null) => {
const newId = crypto.randomUUID() const newId = crypto.randomUUID()
...@@ -573,77 +546,40 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -573,77 +546,40 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
}) })
} }
regenerateIds(cloneNode, null) regenerateIds(cloneNode, null)
xml = serializeTreeToXml(cloneNode)
const xml = serializeTreeToXml(cloneNode) }
editorStore.insertXmlFragment(xml, mode, realId) editorStore.insertXmlFragment(xml, mode, realId)
window.$message.success('节点粘贴成功') window.$message.success(actionName.startsWith('insertFragment') ? 'XML 片段粘贴成功' : '节点粘贴成功')
} catch (err: any) { } catch (err: any) {
window.$message.error(err.message || '节点粘贴失败') window.$message.error(err.message || '粘贴失败')
} }
} }
} }
// 10. 编辑结构 // 9. 结构调整 (添加子节点 / 插入到上方 / 插入到下方)
else if (actionName === 'addChildNode') { else if (['addChildNode', 'insertBefore', 'insertAfter'].includes(actionName)) {
addNodeTargetId.value = activeNodeId.value || realId const modeMap: Record<string, 'child' | 'before' | 'after'> = {
addNodeMode.value = 'child' addChildNode: 'child',
addNodeAllowedTags.value = allowedChildrenList.value insertBefore: 'before',
addNodeVisible.value = true insertAfter: 'after'
} else if (actionName === 'insertBefore') { }
addNodeTargetId.value = activeNodeId.value || realId
addNodeMode.value = 'before'
addNodeAllowedTags.value = insertableParentList.value
addNodeVisible.value = true
} else if (actionName === 'insertAfter') {
addNodeTargetId.value = activeNodeId.value || realId addNodeTargetId.value = activeNodeId.value || realId
addNodeMode.value = 'after' addNodeMode.value = modeMap[actionName]
addNodeAllowedTags.value = insertableParentList.value addNodeAllowedTags.value = actionName === 'addChildNode' ? allowedChildrenList.value : insertableParentList.value
addNodeVisible.value = true addNodeVisible.value = true
} }
// 11. 上移节点 // 10. 顺序调整 (上移 / 下移节点)
else if (actionName === 'moveUp') { else if (actionName === 'moveUp' || actionName === 'moveDown') {
const mapped2 = editorStore.nodeMap.get(realId) const dir = actionName === 'moveUp' ? 'up' : 'down'
if (!mapped2 || !mapped2.parent) return const label = actionName === 'moveUp' ? '上移' : '下移'
const parent = isVirtual ? mapped2.node : mapped2.parent
const nodeIdToMove = activeNodeId.value || realId
const { canMoveUp } = checkCanMoveNode(parent, nodeIdToMove, isVirtual)
if (!canMoveUp) {
window.$message.warning('已在最顶部或 DTD 顺序不支持上移')
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) {
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('上移成功')
} else {
window.$message.warning('上移失败')
}
}
// 12. 下移节点
else if (actionName === 'moveDown') {
const mapped2 = editorStore.nodeMap.get(realId) const mapped2 = editorStore.nodeMap.get(realId)
if (!mapped2 || !mapped2.parent) return if (!mapped2 || !mapped2.parent) return
const parent = isVirtual ? mapped2.node : mapped2.parent const parent = isVirtual ? mapped2.node : mapped2.parent
const nodeIdToMove = activeNodeId.value || realId const nodeIdToMove = activeNodeId.value || realId
const { canMoveDown } = checkCanMoveNode(parent, nodeIdToMove, isVirtual) const check = checkCanMoveNode(parent, nodeIdToMove, isVirtual)
if (!canMoveDown) { if ((dir === 'up' && !check.canMoveUp) || (dir === 'down' && !check.canMoveDown)) {
window.$message.warning('已在最底部或 DTD 顺序不支持下移') window.$message.warning(`已在最${dir === 'up' ? '顶' : '底'}部或 DTD 顺序不支持${label}`)
return return
} }
editorStore.saveSnapshot() editorStore.saveSnapshot()
...@@ -651,21 +587,22 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -651,21 +587,22 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const oldMcIdx = isVirtual ? parseInt(nodeIdToMove.split('-txt-')[1], 10) : -1 const oldMcIdx = isVirtual ? parseInt(nodeIdToMove.split('-txt-')[1], 10) : -1
const movedTextVal = isVirtual && parent.mixedContent?.[oldMcIdx] ? parent.mixedContent[oldMcIdx].text || '' : '' const movedTextVal = isVirtual && parent.mixedContent?.[oldMcIdx] ? parent.mixedContent[oldMcIdx].text || '' : ''
const success = moveNodeInParent(parent, nodeIdToMove, 'down', isVirtual) const success = moveNodeInParent(parent, nodeIdToMove, dir, isVirtual)
if (success) { if (success) {
editorStore.rebuildNodeMap() editorStore.rebuildNodeMap()
if (isVirtual) { if (isVirtual) {
const newMcIdx = parent.mixedContent.findIndex((m) => m.type === 'text' && (m.text || '').trim() === movedTextVal.trim()) const newMcIdx = parent.mixedContent.findIndex((m) => m.type === 'text' && (m.text || '').trim() === movedTextVal.trim())
const targetIdx = newMcIdx !== -1 ? newMcIdx : oldMcIdx + 1 const fallbackIdx = dir === 'up' ? Math.max(0, oldMcIdx - 1) : oldMcIdx + 1
const targetIdx = newMcIdx !== -1 ? newMcIdx : fallbackIdx
const newVirtualId = `${realId}-txt-${targetIdx}` const newVirtualId = `${realId}-txt-${targetIdx}`
activeNodeId.value = newVirtualId activeNodeId.value = newVirtualId
editorStore.setSelectedNodeId(newVirtualId) editorStore.setSelectedNodeId(newVirtualId)
} else { } else {
editorStore.setSelectedNodeId(realId) editorStore.setSelectedNodeId(realId)
} }
window.$message.success('下移成功') window.$message.success(`${label}成功`)
} else { } else {
window.$message.warning('下移失败') window.$message.warning(`${label}失败`)
} }
} }
} }
...@@ -722,7 +659,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -722,7 +659,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
} }
if (directText) { if (directText) {
const shortTxt = directText.length > 40 ? directText.substring(0, 40) + '...' : directText const shortTxt = directText.length > MAX_TEXT_PREVIEW_LENGTH ? directText.substring(0, MAX_TEXT_PREVIEW_LENGTH) + '...' : directText
attrParts.push(`"${shortTxt}"`) attrParts.push(`"${shortTxt}"`)
} }
...@@ -782,8 +719,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -782,8 +719,8 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
} }
} }
// 递归按 DOM 真实顺序展开节点的下层 mixedContent / children (最多递归 2 层) // 递归按 DOM 真实顺序展开节点的下层 mixedContent / children (最多递归 MAX_CHILD_DEPTH_LEVEL 层)
const addNodeWithChildren = (node: XmlNode, currentDepthLevel: number = 0, maxDepthLevel: number = 2) => { const addNodeWithChildren = (node: XmlNode, currentDepthLevel: number = 0, maxDepthLevel: number = MAX_CHILD_DEPTH_LEVEL) => {
if (node.mixedContent && node.mixedContent.length > 0) { if (node.mixedContent && node.mixedContent.length > 0) {
const textItems = node.mixedContent.filter((m) => m.type === 'text' && (m.text || '').trim()) const textItems = node.mixedContent.filter((m) => m.type === 'text' && (m.text || '').trim())
const singleTextNodeMode = textItems.length === 1 const singleTextNodeMode = textItems.length === 1
...@@ -809,7 +746,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -809,7 +746,7 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
} }
}) })
} else if (node.children && node.children.length > 0) { } else if (node.children && node.children.length > 0) {
node.children.slice(0, 5).forEach((child) => { node.children.slice(0, MAX_DIRECT_CHILD_COUNT).forEach((child) => {
addItem(child.id, child) addItem(child.id, child)
if (currentDepthLevel < maxDepthLevel) { if (currentDepthLevel < maxDepthLevel) {
addNodeWithChildren(child, currentDepthLevel + 1, maxDepthLevel) addNodeWithChildren(child, currentDepthLevel + 1, maxDepthLevel)
...@@ -818,10 +755,10 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -818,10 +755,10 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
} }
} }
// A. 祖先节点(自 nodeIds 链取最多 4 层,自顶向下排列) // A. 祖先节点(自 nodeIds 链取最多 MAX_ANCESTOR_DEPTH 层,自顶向下排列)
const ancestorIds = props.nodeIds const ancestorIds = props.nodeIds
.filter((id) => !id.includes('-txt-')) .filter((id) => !id.includes('-txt-'))
.slice(1, 5) .slice(1, MAX_ANCESTOR_DEPTH + 1)
.reverse() .reverse()
ancestorIds.forEach((id) => { ancestorIds.forEach((id) => {
if (id !== baseRealId) { if (id !== baseRealId) {
...@@ -867,24 +804,24 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e ...@@ -867,24 +804,24 @@ export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (e
const parentItems = getParentItems(parentNode) const parentItems = getParentItems(parentNode)
const selfIndex = parentItems.findIndex((item) => item.id === baseRealId || item.id === leafId) const selfIndex = parentItems.findIndex((item) => item.id === baseRealId || item.id === leafId)
if (selfIndex !== -1) { if (selfIndex !== -1) {
const startIdx = Math.max(0, selfIndex - 4) const startIdx = Math.max(0, selfIndex - SIBLING_WINDOW_OFFSET)
const endIdx = Math.min(parentItems.length - 1, selfIndex + 4) const endIdx = Math.min(parentItems.length - 1, selfIndex + SIBLING_WINDOW_OFFSET)
for (let i = startIdx; i <= endIdx; i++) { for (let i = startIdx; i <= endIdx; i++) {
const item = parentItems[i] const item = parentItems[i]
addItem(item.id, item.node, item.rawId) addItem(item.id, item.node, item.rawId)
// 当遍历到基础目标节点时,紧接着按 DOM 真实顺序展开其直系 mixedContent / children // 当遍历到基础目标节点时,紧接着按 DOM 真实顺序展开其直系 mixedContent / children
if (item.id === baseRealId) { if (item.id === baseRealId) {
addNodeWithChildren(baseNode, 0, 2) addNodeWithChildren(baseNode, 0, MAX_CHILD_DEPTH_LEVEL)
} }
} }
} else { } else {
addItem(baseRealId, baseNode) addItem(baseRealId, baseNode)
addNodeWithChildren(baseNode, 0, 2) addNodeWithChildren(baseNode, 0, MAX_CHILD_DEPTH_LEVEL)
} }
} else { } else {
addItem(baseRealId, baseNode) addItem(baseRealId, baseNode)
addNodeWithChildren(baseNode, 0, 2) addNodeWithChildren(baseNode, 0, MAX_CHILD_DEPTH_LEVEL)
} }
// 计算相对 depth // 计算相对 depth
......
...@@ -68,13 +68,7 @@ ...@@ -68,13 +68,7 @@
<FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" /> <FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" />
<!-- 编辑区非 Table 元素右键上下文菜单浮动下拉 --> <!-- 编辑区非 Table 元素右键上下文菜单浮动下拉 -->
<EditAreaContextMenuModal <EditAreaContextMenuModal v-model="contextMenuVisible" :node-ids="contextMenuNodeIds" :x="contextMenuX" :y="contextMenuY" />
v-model="contextMenuVisible"
:node-ids="contextMenuNodeIds"
:x="contextMenuX"
:y="contextMenuY"
/>
<!-- 文本选择悬浮工具栏 --> <!-- 文本选择悬浮工具栏 -->
<SelectionToolbar /> <SelectionToolbar />
...@@ -212,7 +206,6 @@ const handleContextMenu = (e: MouseEvent) => { ...@@ -212,7 +206,6 @@ const handleContextMenu = (e: MouseEvent) => {
contextMenuVisible.value = true contextMenuVisible.value = true
} }
} }
</script> </script>
<style scoped> <style scoped>
......
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