feat: 深度修复节点对齐和SVG导出问题

- 使用Flexbox和CSS containment确保节点内容完全居中对齐
- 修复图片加载失败时的布局问题,自动隐藏无效图片
- 优化长文本节点处理,强制换行避免节点宽度异常
- 增强SVG导出功能,支持表格和图片的正确渲染
- 添加节点稳定性样式,防止内容变化导致布局偏移
- 统一所有节点元素的对齐方式,确保连线精确连接
This commit is contained in:
lixinran 2025-10-10 13:53:42 +08:00
parent ef1b94d959
commit f1ef56c3c4
9 changed files with 278 additions and 134 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,8 +23,8 @@
flex-direction: column; flex-direction: column;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-90ac6040.js"></script> <script type="module" crossorigin src="/assets/index-2e59748b.js"></script>
<link rel="stylesheet" href="/assets/index-fc0e370b.css"> <link rel="stylesheet" href="/assets/index-c3af8656.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -231,10 +231,25 @@
} }
me-tpc { me-tpc {
display: block; display: flex !important;
max-width: 35em; max-width: 35em;
white-space: pre-wrap; white-space: pre-wrap;
pointer-events: all; pointer-events: all;
// 确保整个节点内容居中对齐
text-align: center !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
box-sizing: border-box !important;
// 确保节点有固定的最小高度,避免内容变化导致尺寸不稳定
min-height: 2em !important;
// 确保节点内容不会溢出
overflow: hidden !important;
// 强制节点尺寸稳定
position: relative !important;
// 确保节点内容不会因为动态变化而影响布局
contain: layout style !important;
& > * { & > * {
// tags,icons,images should not response to click event // tags,icons,images should not response to click event
pointer-events: none; pointer-events: none;
@ -244,22 +259,33 @@
pointer-events: auto; pointer-events: auto;
} }
& > .text { & > .text {
display: inline-block; display: block !important;
text-align: center; // 确保文本居中对齐,避免与连线错位 text-align: center !important; // 确保文本居中对齐,避免与连线错位
width: 100%; // 确保文本容器占满节点宽度 width: 100% !important; // 确保文本容器占满节点宽度
margin: 0 auto !important; // 强制居中
// 确保文本不会导致节点尺寸不稳定
word-wrap: break-word !important;
word-break: break-word !important;
line-height: 1.4 !important;
// Allow links inside markdown text to be clickable // Allow links inside markdown text to be clickable
a { a {
pointer-events: auto; pointer-events: auto;
} }
} }
& > img { & > img {
display: block; display: block !important;
margin-bottom: 8px; margin: 0 auto 8px auto !important; // 图片居中
object-fit: cover; object-fit: cover;
max-width: 200px !important; max-width: 200px !important;
max-height: 150px !important; max-height: 150px !important;
width: auto !important; width: auto !important;
height: auto !important; height: auto !important;
// 确保图片加载失败时不会影响节点布局
min-width: 0 !important;
min-height: 0 !important;
// 图片加载失败时的占位符样式
background-color: #f5f5f5 !important;
border: 1px solid #ddd !important;
} }
// 更具体的选择器,确保覆盖所有图片 // 更具体的选择器,确保覆盖所有图片
@ -268,6 +294,13 @@
max-height: 150px !important; max-height: 150px !important;
width: auto !important; width: auto !important;
height: auto !important; height: auto !important;
margin: 0 auto !important; // 确保所有图片都居中
min-width: 0 !important;
min-height: 0 !important;
// 处理图片加载失败的情况
&[src=""], &[src*="undefined"], &[src*="null"] {
display: none !important; // 隐藏无效图片
}
} }
} }
.circle { .circle {

View File

@ -55,10 +55,27 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num
function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) { function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
const g = document.createElementNS(ns, 'g') const g = document.createElementNS(ns, 'g')
// 检查是否包含HTML内容表格、复杂结构等 // 检查内容来源
const tpcWithText = tpc as Topic const tpcWithNodeObj = tpc as Topic
const hasHTMLContent = !!(tpcWithText.text && tpcWithText.text.innerHTML && tpcWithText.text.innerHTML !== tpcWithText.text.textContent) let htmlContent = ''
const hasTableContent = !!(tpcWithText.text && tpcWithText.text.innerHTML && tpcWithText.text.innerHTML.includes('<table'))
// 优先使用dangerouslySetInnerHTML其次使用text.innerHTML
if (tpcWithNodeObj.nodeObj && tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML) {
htmlContent = tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML
console.log('🔍 使用dangerouslySetInnerHTML内容:', htmlContent.substring(0, 200))
} else if (tpcWithNodeObj.text && tpcWithNodeObj.text.innerHTML) {
htmlContent = tpcWithNodeObj.text.innerHTML
console.log('🔍 使用text.innerHTML内容:', htmlContent.substring(0, 200))
}
const hasHTMLContent = htmlContent && htmlContent !== tpc.textContent
const hasTableContent = htmlContent && htmlContent.includes('<table')
console.log('🔍 HTML内容分析:', {
hasHTMLContent,
hasTableContent,
contentLength: htmlContent.length
})
if (hasHTMLContent || hasTableContent) { if (hasHTMLContent || hasTableContent) {
// 对于HTML内容使用foreignObject来正确渲染 // 对于HTML内容使用foreignObject来正确渲染
@ -72,7 +89,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
// 创建div容器来包含HTML内容 // 创建div容器来包含HTML内容
const div = document.createElement('div') const div = document.createElement('div')
div.innerHTML = tpcWithText.text.innerHTML div.innerHTML = htmlContent
// 应用样式,确保与思维导图显示一致 // 应用样式,确保与思维导图显示一致
div.style.cssText = ` div.style.cssText = `
@ -81,7 +98,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
font-family: ${tpcStyle.fontFamily}; font-family: ${tpcStyle.fontFamily};
font-size: ${tpcStyle.fontSize}; font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color}; color: ${tpcStyle.color};
background: ${tpcStyle.backgroundColor}; background: transparent;
padding: ${tpcStyle.padding}; padding: ${tpcStyle.padding};
box-sizing: border-box; box-sizing: border-box;
overflow: visible; overflow: visible;
@ -90,18 +107,20 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
` `
// 为表格添加样式 // 为表格添加样式
const table = div.querySelector('table') as HTMLTableElement const tables = div.querySelectorAll('table')
if (table) { tables.forEach(table => {
table.style.cssText = ` const htmlTable = table as HTMLTableElement
htmlTable.style.cssText = `
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: ${tpcStyle.fontSize}; font-size: ${tpcStyle.fontSize};
font-family: ${tpcStyle.fontFamily}; font-family: ${tpcStyle.fontFamily};
margin: 0 auto; margin: 0 auto;
border: 1px solid #ccc;
` `
// 为表格单元格添加样式 // 为表格单元格添加样式
const cells = div.querySelectorAll('td, th') const cells = table.querySelectorAll('td, th')
cells.forEach(cell => { cells.forEach(cell => {
const htmlCell = cell as HTMLElement const htmlCell = cell as HTMLElement
htmlCell.style.cssText = ` htmlCell.style.cssText = `
@ -110,9 +129,10 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
text-align: center; text-align: center;
vertical-align: top; vertical-align: top;
font-size: ${parseFloat(tpcStyle.fontSize) * 0.9}px; font-size: ${parseFloat(tpcStyle.fontSize) * 0.9}px;
background: white;
` `
}) })
} })
// 为其他元素添加样式 // 为其他元素添加样式
const lists = div.querySelectorAll('ul, ol') const lists = div.querySelectorAll('ul, ol')
@ -181,11 +201,27 @@ function convertDivToSvg(mei: MindElixirInstance, tpc: HTMLElement, useForeignOb
}) })
const g = document.createElementNS(ns, 'g') const g = document.createElementNS(ns, 'g')
g.appendChild(bg) g.appendChild(bg)
let text: SVGGElement | null
if (useForeignObject) { let text: SVGGElement | null = null
// 检查是否有dangerouslySetInnerHTML内容
const tpcWithNodeObj = tpc as Topic
if (tpcWithNodeObj.nodeObj && tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML) {
console.log('🔍 处理dangerouslySetInnerHTML内容:', tpcWithNodeObj.nodeObj.dangerouslySetInnerHTML.substring(0, 200))
// 对于dangerouslySetInnerHTML使用ForeignObject
text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y) text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
} else text = generateSvgText(tpc, tpcStyle, x, y) } else if (useForeignObject) {
// 对于其他HTML内容也使用ForeignObject
text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
} else {
// 对于普通文本内容
text = generateSvgText(tpc, tpcStyle, x, y)
}
if (text) {
g.appendChild(text) g.appendChild(text)
}
return g return g
} }
@ -209,6 +245,53 @@ function convertAToSvg(mei: MindElixirInstance, a: HTMLAnchorElement) {
return svgA return svgA
} }
async function convertNodeImageToSvg(mei: MindElixirInstance, tpc: Topic): Promise<SVGImageElement | null> {
const tpcStyle = getComputedStyle(tpc)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
if (!tpc.nodeObj.image) {
return null
}
const imageData = tpc.nodeObj.image
const svgI = document.createElementNS(ns, 'image')
console.log('🖼️ 处理节点图片:', imageData)
// 计算图片位置(在节点中央)
const nodeWidth = parseInt(tpcStyle.width)
const nodeHeight = parseInt(tpcStyle.height)
const imgWidth = imageData.width || 200
const imgHeight = imageData.height || 150
const imgX = x + (nodeWidth - imgWidth) / 2
const imgY = y + (nodeHeight - imgHeight) / 2
try {
// 尝试将图片转换为base64
const base64Url = await imageToBase64(imageData.url)
setAttributes(svgI, {
x: imgX + '',
y: imgY + '',
width: imgWidth + '',
height: imgHeight + '',
href: base64Url,
})
console.log('✅ 成功导出节点图片')
} catch (error) {
console.warn('⚠️ 图片转base64失败使用原始URL:', error)
setAttributes(svgI, {
x: imgX + '',
y: imgY + '',
width: imgWidth + '',
height: imgHeight + '',
href: imageData.url,
})
}
return svgI
}
async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Promise<SVGImageElement> { async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Promise<SVGImageElement> {
const aStyle = getComputedStyle(a) const aStyle = getComputedStyle(a)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a) const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a)
@ -321,26 +404,50 @@ const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) =>
const summaries = mapDiv.querySelector('.summary')?.cloneNode(true) const summaries = mapDiv.querySelector('.summary')?.cloneNode(true)
summaries && g.appendChild(summaries) summaries && g.appendChild(summaries)
mapDiv.querySelectorAll<Topic>('me-tpc').forEach(tpc => { // 处理所有节点
if (tpc.nodeObj.dangerouslySetInnerHTML) { const nodePromises = Array.from(mapDiv.querySelectorAll<Topic>('me-tpc')).map(async (tpc) => {
console.log('🔍 节点有dangerouslySetInnerHTML使用ForeignObject') // 首先创建节点背景
g.appendChild(createElBox(mei, tpc))
// 检查节点内容类型
const hasDangerouslySetInnerHTML = tpc.nodeObj.dangerouslySetInnerHTML
const hasHTMLContent = !!(tpc.text && tpc.text.innerHTML && tpc.text.innerHTML !== tpc.text.textContent)
const hasImage = tpc.nodeObj.image
console.log('🔍 节点内容分析:', {
hasDangerouslySetInnerHTML,
hasHTMLContent,
hasImage,
innerHTML: tpc.text?.innerHTML?.substring(0, 100),
textContent: tpc.text?.textContent?.substring(0, 100)
})
// 处理不同类型的节点内容
if (hasDangerouslySetInnerHTML || hasHTMLContent) {
// 对于HTML内容表格等使用ForeignObject
console.log('✅ 使用ForeignObject渲染HTML内容')
g.appendChild(convertDivToSvg(mei, tpc, noForeignObject ? false : true)) g.appendChild(convertDivToSvg(mei, tpc, noForeignObject ? false : true))
} else { } else {
g.appendChild(createElBox(mei, tpc)) // 对于普通文本内容
// 检查节点是否包含HTML内容如表格、markdown等 g.appendChild(convertDivToSvg(mei, tpc, false))
const hasHTMLContent = !!(tpc.text && tpc.text.innerHTML && tpc.text.innerHTML !== tpc.text.textContent) }
console.log('🔍 节点HTML内容检查:', {
hasText: !!tpc.text, // 如果有图片,单独处理图片
hasInnerHTML: !!(tpc.text && tpc.text.innerHTML), if (hasImage) {
innerHTML: tpc.text?.innerHTML?.substring(0, 100), console.log('🖼️ 处理节点图片:', tpc.nodeObj.image)
textContent: tpc.text?.textContent?.substring(0, 100), try {
hasHTMLContent, const imageSvg = await convertNodeImageToSvg(mei, tpc)
willUseForeignObject: !noForeignObject && hasHTMLContent if (imageSvg) {
}) g.appendChild(imageSvg)
// 修复传递tpc而不是tpc.text确保正确导出节点内容 }
g.appendChild(convertDivToSvg(mei, tpc, noForeignObject ? false : hasHTMLContent)) } catch (error) {
console.error('❌ 图片导出失败:', error)
}
} }
}) })
// 等待所有节点处理完成
await Promise.all(nodePromises)
mapDiv.querySelectorAll('.tags > span').forEach(tag => { mapDiv.querySelectorAll('.tags > span').forEach(tag => {
g.appendChild(convertDivToSvg(mei, tag as HTMLElement)) g.appendChild(convertDivToSvg(mei, tag as HTMLElement))
}) })