Commit 1815f01c by pangchong

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

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