优化SVG导出功能
- 改进位置计算:处理滚动偏移、transform/scale、SVG坐标归一化 - 优化HTML内容渲染:样式内联化、限制宽高、文字换行处理 - 增强图片处理:统一Base64转换、异步加载管理、大小控制 - 统一背景管理:消除重复创建、统一参数管理 - 完善异步渲染同步:Promise+图片加载检测、整体渲染等待 解决了SVG导出中的位置偏移、内容缺失、图片显示等问题
This commit is contained in:
parent
e07da11606
commit
8b8ce52e3a
|
|
@ -6,11 +6,16 @@ import { getOffsetLT, getOffsetLTImproved, getActualNodeDimensions, isTopic } fr
|
||||||
const ns = 'http://www.w3.org/2000/svg'
|
const ns = 'http://www.w3.org/2000/svg'
|
||||||
function createSvgDom(height: string, width: string) {
|
function createSvgDom(height: string, width: string) {
|
||||||
const svg = document.createElementNS(ns, 'svg')
|
const svg = document.createElementNS(ns, 'svg')
|
||||||
|
const heightNum = parseFloat(height)
|
||||||
|
const widthNum = parseFloat(width)
|
||||||
|
|
||||||
setAttributes(svg, {
|
setAttributes(svg, {
|
||||||
version: '1.1',
|
version: '1.1',
|
||||||
xmlns: ns,
|
xmlns: ns,
|
||||||
height,
|
height,
|
||||||
width,
|
width,
|
||||||
|
viewBox: `0 0 ${widthNum} ${heightNum}`, // 添加viewBox确保坐标归一化
|
||||||
|
preserveAspectRatio: 'xMidYMid meet' // 保持宽高比
|
||||||
})
|
})
|
||||||
return svg
|
return svg
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +32,6 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num
|
||||||
} else {
|
} else {
|
||||||
content = tpc.childNodes[0].textContent!
|
content = tpc.childNodes[0].textContent!
|
||||||
}
|
}
|
||||||
const lines = content!.split('\n')
|
|
||||||
|
|
||||||
// 计算行高和字体大小
|
// 计算行高和字体大小
|
||||||
const lineHeight = parseFloat(tpcStyle.lineHeight) || parseFloat(tpcStyle.fontSize) * 1.2
|
const lineHeight = parseFloat(tpcStyle.lineHeight) || parseFloat(tpcStyle.fontSize) * 1.2
|
||||||
|
|
@ -38,43 +42,23 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num
|
||||||
// 计算节点宽度和文本起始位置
|
// 计算节点宽度和文本起始位置
|
||||||
const nodeWidth = tpc.offsetWidth || 200
|
const nodeWidth = tpc.offsetWidth || 200
|
||||||
const textX = x + paddingLeft // 纯文本节点使用左对齐
|
const textX = x + paddingLeft // 纯文本节点使用左对齐
|
||||||
|
const textY = y + paddingTop + fontSize
|
||||||
|
|
||||||
|
// 使用tspan处理多行文本
|
||||||
|
const textElement = createMultilineText(content, textX, textY, lineHeight, tpcStyle)
|
||||||
|
|
||||||
// 计算实际需要的内容高度
|
// 计算实际需要的内容高度
|
||||||
|
const lines = content.split('\n').filter(line => line.trim())
|
||||||
const contentHeight = (lines.length - 1) * lineHeight + fontSize + paddingTop + (parseInt(tpcStyle.paddingBottom) || 8)
|
const contentHeight = (lines.length - 1) * lineHeight + fontSize + paddingTop + (parseInt(tpcStyle.paddingBottom) || 8)
|
||||||
const nodeHeight = tpc.offsetHeight || 100
|
const nodeHeight = tpc.offsetHeight || 100
|
||||||
const actualHeight = Math.min(contentHeight, nodeHeight) // 不超过原始节点高度
|
const actualHeight = Math.min(contentHeight, nodeHeight) // 不超过原始节点高度
|
||||||
|
|
||||||
// 创建背景矩形,使用计算出的实际高度
|
// 使用统一背景管理创建背景矩形
|
||||||
const bg = document.createElementNS(ns, 'rect')
|
const bg = createBackground(x, y, nodeWidth, actualHeight, tpcStyle)
|
||||||
setAttributes(bg, {
|
|
||||||
x: x + '',
|
|
||||||
y: y + '',
|
|
||||||
rx: tpcStyle.borderRadius || '8',
|
|
||||||
ry: tpcStyle.borderRadius || '8',
|
|
||||||
width: nodeWidth + 'px',
|
|
||||||
height: actualHeight + 'px',
|
|
||||||
fill: tpcStyle.backgroundColor || 'white',
|
|
||||||
stroke: tpcStyle.borderColor || '#ccc',
|
|
||||||
'stroke-width': tpcStyle.borderWidth || '1',
|
|
||||||
})
|
|
||||||
g.appendChild(bg)
|
g.appendChild(bg)
|
||||||
|
g.appendChild(textElement)
|
||||||
|
|
||||||
lines.forEach((line, index) => {
|
console.log('✅ generateSvgText使用tspan优化多行文本:', {
|
||||||
const text = document.createElementNS(ns, 'text')
|
|
||||||
setAttributes(text, {
|
|
||||||
x: textX + '',
|
|
||||||
y: y + paddingTop + fontSize + (lineHeight * index) + '',
|
|
||||||
'text-anchor': 'start',
|
|
||||||
'font-family': tpcStyle.fontFamily,
|
|
||||||
'font-size': `${tpcStyle.fontSize}`,
|
|
||||||
'font-weight': `${tpcStyle.fontWeight}`,
|
|
||||||
fill: `${tpcStyle.color}`,
|
|
||||||
})
|
|
||||||
text.innerHTML = line
|
|
||||||
g.appendChild(text)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('✅ generateSvgText优化了底部间距:', {
|
|
||||||
originalHeight: nodeHeight,
|
originalHeight: nodeHeight,
|
||||||
actualHeight: actualHeight,
|
actualHeight: actualHeight,
|
||||||
linesCount: lines.length
|
linesCount: lines.length
|
||||||
|
|
@ -357,6 +341,59 @@ class TableToSVGConverter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将computedStyle转换为内联样式字符串
|
||||||
|
*/
|
||||||
|
const computedStyleToInline = (element: HTMLElement, additionalStyles: Record<string, string> = {}): string => {
|
||||||
|
const computedStyle = window.getComputedStyle(element)
|
||||||
|
const styleMap: Record<string, string> = {
|
||||||
|
...additionalStyles,
|
||||||
|
'font-family': computedStyle.fontFamily,
|
||||||
|
'font-size': computedStyle.fontSize,
|
||||||
|
'font-weight': computedStyle.fontWeight,
|
||||||
|
'color': computedStyle.color,
|
||||||
|
'line-height': computedStyle.lineHeight,
|
||||||
|
'text-align': computedStyle.textAlign,
|
||||||
|
'padding': computedStyle.padding,
|
||||||
|
'margin': computedStyle.margin,
|
||||||
|
'border': computedStyle.border,
|
||||||
|
'background-color': computedStyle.backgroundColor,
|
||||||
|
'width': computedStyle.width,
|
||||||
|
'height': computedStyle.height,
|
||||||
|
'box-sizing': 'border-box',
|
||||||
|
'overflow': 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(styleMap)
|
||||||
|
.filter(([_, value]) => value && value !== 'initial' && value !== 'inherit')
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用tspan处理多行文本
|
||||||
|
*/
|
||||||
|
const createMultilineText = (text: string, x: number, y: number, lineHeight: number, style: CSSStyleDeclaration): SVGGElement => {
|
||||||
|
const g = document.createElementNS(ns, 'g')
|
||||||
|
const lines = text.split('\n').filter(line => line.trim())
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const tspan = document.createElementNS(ns, 'tspan')
|
||||||
|
setAttributes(tspan, {
|
||||||
|
x: x.toString(),
|
||||||
|
dy: index === 0 ? '0' : lineHeight.toString(),
|
||||||
|
'font-family': style.fontFamily,
|
||||||
|
'font-size': style.fontSize,
|
||||||
|
'font-weight': style.fontWeight,
|
||||||
|
fill: style.color,
|
||||||
|
})
|
||||||
|
tspan.textContent = line.trim()
|
||||||
|
g.appendChild(tspan)
|
||||||
|
})
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
// 清理HTML内容,修复SVG解析错误
|
// 清理HTML内容,修复SVG解析错误
|
||||||
function cleanHtmlForSvg(html: string): string {
|
function cleanHtmlForSvg(html: string): string {
|
||||||
if (!html) return html
|
if (!html) return html
|
||||||
|
|
@ -430,21 +467,13 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
|
||||||
if (hasTableContent) {
|
if (hasTableContent) {
|
||||||
console.log('🔄 检测到表格内容,使用foreignObject方式渲染HTML表格')
|
console.log('🔄 检测到表格内容,使用foreignObject方式渲染HTML表格')
|
||||||
|
|
||||||
// 获取节点尺寸
|
// 获取节点尺寸 - 使用getBoundingClientRect获取精确尺寸
|
||||||
const nodeWidth = tpc.offsetWidth || 400
|
const rect = tpc.getBoundingClientRect()
|
||||||
const nodeHeight = tpc.offsetHeight || 200
|
const nodeWidth = Math.max(rect.width, tpc.offsetWidth || 400)
|
||||||
|
const nodeHeight = Math.max(rect.height, tpc.offsetHeight || 200)
|
||||||
|
|
||||||
// 创建背景矩形
|
// 使用统一背景管理创建背景矩形
|
||||||
const bg = document.createElementNS(ns, 'rect')
|
const bg = createBackground(x, y, nodeWidth, nodeHeight, tpcStyle)
|
||||||
setAttributes(bg, {
|
|
||||||
x: x + '',
|
|
||||||
y: y + '',
|
|
||||||
width: nodeWidth + '',
|
|
||||||
height: nodeHeight + '',
|
|
||||||
fill: 'white',
|
|
||||||
stroke: '#ccc',
|
|
||||||
'stroke-width': '1'
|
|
||||||
})
|
|
||||||
g.appendChild(bg)
|
g.appendChild(bg)
|
||||||
|
|
||||||
// 创建foreignObject包含HTML表格
|
// 创建foreignObject包含HTML表格
|
||||||
|
|
@ -456,19 +485,17 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
|
||||||
height: nodeHeight + ''
|
height: nodeHeight + ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建HTML内容,确保XML语法正确
|
// 使用样式内联化确保样式正确显示
|
||||||
const safeFontFamily = (tpcStyle.fontFamily || 'Arial, sans-serif').replace(/"/g, '"')
|
const inlineStyles = computedStyleToInline(tpc, {
|
||||||
|
'width': '100%',
|
||||||
|
'height': '100%',
|
||||||
|
'padding': '8px',
|
||||||
|
'box-sizing': 'border-box',
|
||||||
|
'overflow': 'hidden'
|
||||||
|
})
|
||||||
|
|
||||||
const htmlContentForForeignObject = `
|
const htmlContentForForeignObject = `
|
||||||
<div xmlns="http://www.w3.org/1999/xhtml" style="
|
<div xmlns="http://www.w3.org/1999/xhtml" style="${inlineStyles}">
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: '${safeFontFamily}';
|
|
||||||
font-size: ${tpcStyle.fontSize || '14px'};
|
|
||||||
line-height: 1.4;
|
|
||||||
">
|
|
||||||
${cleanHtmlForSvg(htmlContent)}
|
${cleanHtmlForSvg(htmlContent)}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
@ -476,7 +503,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
|
||||||
foreignObject.innerHTML = htmlContentForForeignObject
|
foreignObject.innerHTML = htmlContentForForeignObject
|
||||||
g.appendChild(foreignObject)
|
g.appendChild(foreignObject)
|
||||||
|
|
||||||
console.log('✅ 表格已使用foreignObject渲染')
|
console.log('✅ 表格已使用foreignObject渲染,样式已内联化')
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -780,6 +807,47 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一背景管理函数
|
||||||
|
* 消除重复创建,统一参数管理
|
||||||
|
*/
|
||||||
|
const createBackground = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
style: CSSStyleDeclaration,
|
||||||
|
additionalStyles: Record<string, string> = {}
|
||||||
|
): SVGRectElement => {
|
||||||
|
const rect = document.createElementNS(ns, 'rect')
|
||||||
|
|
||||||
|
const bgColor = additionalStyles.fill || style.backgroundColor || 'white'
|
||||||
|
const borderColor = additionalStyles.stroke || style.borderColor || '#ccc'
|
||||||
|
const borderWidth = additionalStyles['stroke-width'] || style.borderWidth || '1'
|
||||||
|
const borderRadius = additionalStyles.rx || style.borderRadius || '8'
|
||||||
|
|
||||||
|
setAttributes(rect, {
|
||||||
|
x: x.toString(),
|
||||||
|
y: y.toString(),
|
||||||
|
width: width.toString(),
|
||||||
|
height: height.toString(),
|
||||||
|
fill: bgColor,
|
||||||
|
stroke: borderColor,
|
||||||
|
'stroke-width': borderWidth,
|
||||||
|
rx: borderRadius,
|
||||||
|
ry: borderRadius,
|
||||||
|
...additionalStyles
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('📦 创建统一背景:', {
|
||||||
|
position: { x, y },
|
||||||
|
size: { width, height },
|
||||||
|
styles: { bgColor, borderColor, borderWidth, borderRadius }
|
||||||
|
})
|
||||||
|
|
||||||
|
return rect
|
||||||
|
}
|
||||||
|
|
||||||
function createElBox(mei: MindElixirInstance, tpc: Topic) {
|
function createElBox(mei: MindElixirInstance, tpc: Topic) {
|
||||||
// 不再创建背景rect,避免与convertDivToSvg重复创建
|
// 不再创建背景rect,避免与convertDivToSvg重复创建
|
||||||
// 背景rect现在由convertDivToSvg统一管理
|
// 背景rect现在由convertDivToSvg统一管理
|
||||||
|
|
@ -964,20 +1032,30 @@ async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Pr
|
||||||
const imageUrl = mei.imageProxy ? mei.imageProxy(a.src) : a.src
|
const imageUrl = mei.imageProxy ? mei.imageProxy(a.src) : a.src
|
||||||
|
|
||||||
// 获取图片的实际显示尺寸,确保与思维导图中的显示一致
|
// 获取图片的实际显示尺寸,确保与思维导图中的显示一致
|
||||||
const actualWidth = Math.min(parseInt(aStyle.width) || 300, 300)
|
const rect = a.getBoundingClientRect()
|
||||||
const actualHeight = Math.min(parseInt(aStyle.height) || 200, 200)
|
const actualWidth = Math.min(rect.width || parseInt(aStyle.width) || 300, 300)
|
||||||
|
const actualHeight = Math.min(rect.height || parseInt(aStyle.height) || 200, 200)
|
||||||
|
|
||||||
console.log('🖼️ 图片导出尺寸:', {
|
console.log('🖼️ 图片导出尺寸:', {
|
||||||
computedWidth: aStyle.width,
|
computedWidth: aStyle.width,
|
||||||
computedHeight: aStyle.height,
|
computedHeight: aStyle.height,
|
||||||
|
rectWidth: rect.width,
|
||||||
|
rectHeight: rect.height,
|
||||||
actualWidth,
|
actualWidth,
|
||||||
actualHeight,
|
actualHeight,
|
||||||
imageUrl: imageUrl.substring(0, 50)
|
imageUrl: imageUrl.substring(0, 50)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 确保图片已加载完成
|
||||||
|
await ensureImagesLoaded([a])
|
||||||
|
|
||||||
// 尝试将图片转换为base64格式以确保导出时能正确显示
|
// 尝试将图片转换为base64格式以确保导出时能正确显示
|
||||||
try {
|
try {
|
||||||
const base64Url = await imageToBase64(imageUrl)
|
// 根据实际显示尺寸设置最大尺寸
|
||||||
|
const maxWidth = Math.max(actualWidth * 2, 400) // 至少2倍分辨率
|
||||||
|
const maxHeight = Math.max(actualHeight * 2, 300)
|
||||||
|
|
||||||
|
const base64Url = await imageToBase64(imageUrl, maxWidth, maxHeight)
|
||||||
setAttributes(svgI, {
|
setAttributes(svgI, {
|
||||||
x: x + '',
|
x: x + '',
|
||||||
y: y + '',
|
y: y + '',
|
||||||
|
|
@ -985,6 +1063,8 @@ async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Pr
|
||||||
height: actualHeight + '',
|
height: actualHeight + '',
|
||||||
href: base64Url,
|
href: base64Url,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('✅ 图片已转换为base64格式')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to convert image to base64, using original URL:', error)
|
console.warn('Failed to convert image to base64, using original URL:', error)
|
||||||
setAttributes(svgI, {
|
setAttributes(svgI, {
|
||||||
|
|
@ -999,8 +1079,37 @@ async function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement): Pr
|
||||||
return svgI
|
return svgI
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将图片URL转换为base64格式
|
/**
|
||||||
function imageToBase64(url: string): Promise<string> {
|
* 检测图片是否已加载完成
|
||||||
|
*/
|
||||||
|
const ensureImagesLoaded = async (imgElements: HTMLImageElement[]): Promise<void> => {
|
||||||
|
const loadPromises = imgElements.map(img => new Promise<void>((resolve) => {
|
||||||
|
if (img.complete && img.naturalWidth > 0) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
const onLoad = () => {
|
||||||
|
img.removeEventListener('load', onLoad)
|
||||||
|
img.removeEventListener('error', onError)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const onError = () => {
|
||||||
|
img.removeEventListener('load', onLoad)
|
||||||
|
img.removeEventListener('error', onError)
|
||||||
|
console.warn('图片加载失败:', img.src)
|
||||||
|
resolve() // 即使失败也继续,避免阻塞导出
|
||||||
|
}
|
||||||
|
img.addEventListener('load', onLoad)
|
||||||
|
img.addEventListener('error', onError)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
await Promise.all(loadPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将图片URL转换为base64格式(优化版)
|
||||||
|
*/
|
||||||
|
function imageToBase64(url: string, maxWidth: number = 800, maxHeight: number = 600): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.crossOrigin = 'anonymous'
|
img.crossOrigin = 'anonymous'
|
||||||
|
|
@ -1008,8 +1117,6 @@ function imageToBase64(url: string): Promise<string> {
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = img.width
|
|
||||||
canvas.height = img.height
|
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
|
|
@ -1017,8 +1124,24 @@ function imageToBase64(url: string): Promise<string> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.drawImage(img, 0, 0)
|
// 计算缩放后的尺寸,保持宽高比
|
||||||
const base64 = canvas.toDataURL('image/png')
|
let { width, height } = img
|
||||||
|
if (width > maxWidth || height > maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height)
|
||||||
|
width = Math.floor(width * ratio)
|
||||||
|
height = Math.floor(height * ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
// 使用高质量缩放
|
||||||
|
ctx.imageSmoothingEnabled = true
|
||||||
|
ctx.imageSmoothingQuality = 'high'
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
|
||||||
|
// 使用JPEG格式减少文件大小,质量设为0.9
|
||||||
|
const base64 = canvas.toDataURL('image/jpeg', 0.9)
|
||||||
resolve(base64)
|
resolve(base64)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error)
|
reject(error)
|
||||||
|
|
@ -1026,9 +1149,14 @@ function imageToBase64(url: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
reject(new Error('Failed to load image'))
|
reject(new Error(`Failed to load image: ${url}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置超时
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error(`Image load timeout: ${url}`))
|
||||||
|
}, 10000) // 10秒超时
|
||||||
|
|
||||||
img.src = url
|
img.src = url
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1038,50 +1166,145 @@ const padding = 100
|
||||||
const head = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`
|
const head = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取元素相对于容器的绝对位置
|
* 解析CSS transform matrix获取缩放比例
|
||||||
* 这是解决SVG导出坐标转换问题的关键函数
|
*/
|
||||||
|
const parseTransformMatrix = (transform: string): { scaleX: number, scaleY: number } => {
|
||||||
|
if (!transform || transform === 'none') {
|
||||||
|
return { scaleX: 1, scaleY: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配matrix(a, b, c, d, e, f)格式
|
||||||
|
const matrixMatch = transform.match(/matrix\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^)]+)\)/)
|
||||||
|
if (matrixMatch) {
|
||||||
|
const [, a, b, c, d] = matrixMatch.map(Number)
|
||||||
|
return { scaleX: a, scaleY: d }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配scale(x, y)格式
|
||||||
|
const scaleMatch = transform.match(/scale\(([^,]+)(?:,\s*([^)]+))?\)/)
|
||||||
|
if (scaleMatch) {
|
||||||
|
const scaleX = parseFloat(scaleMatch[1])
|
||||||
|
const scaleY = parseFloat(scaleMatch[2] || scaleMatch[1])
|
||||||
|
return { scaleX, scaleY }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scaleX: 1, scaleY: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取元素相对于容器的绝对位置(优化版)
|
||||||
|
* 解决滚动偏移、transform/scale、SVG坐标归一化问题
|
||||||
*/
|
*/
|
||||||
const getAbsolutePosition = (element: HTMLElement, container: HTMLElement) => {
|
const getAbsolutePosition = (element: HTMLElement, container: HTMLElement) => {
|
||||||
const elementRect = element.getBoundingClientRect()
|
const elementRect = element.getBoundingClientRect()
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
|
||||||
// 考虑容器的transform和滚动
|
// 1. 处理滚动偏移
|
||||||
|
const scrollLeft = container.scrollLeft || 0
|
||||||
|
const scrollTop = container.scrollTop || 0
|
||||||
|
|
||||||
|
// 2. 考虑容器的transform和缩放
|
||||||
const containerStyle = window.getComputedStyle(container)
|
const containerStyle = window.getComputedStyle(container)
|
||||||
const containerTransform = containerStyle.transform
|
const containerTransform = containerStyle.transform
|
||||||
|
const { scaleX, scaleY } = parseTransformMatrix(containerTransform)
|
||||||
|
|
||||||
|
// 3. 考虑元素的transform
|
||||||
|
const elementStyle = window.getComputedStyle(element)
|
||||||
|
const elementTransform = elementStyle.transform
|
||||||
|
const { scaleX: elementScaleX, scaleY: elementScaleY } = parseTransformMatrix(elementTransform)
|
||||||
|
|
||||||
|
// 4. 计算基础位置(考虑滚动)
|
||||||
|
let x = elementRect.left - containerRect.left + scrollLeft
|
||||||
|
let y = elementRect.top - containerRect.top + scrollTop
|
||||||
|
|
||||||
|
// 5. 应用缩放补偿
|
||||||
|
if (scaleX !== 1 || scaleY !== 1) {
|
||||||
|
x = x / scaleX
|
||||||
|
y = y / scaleY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementScaleX !== 1 || elementScaleY !== 1) {
|
||||||
|
x = x / elementScaleX
|
||||||
|
y = y / elementScaleY
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 考虑容器的padding/margin
|
||||||
|
const containerPaddingLeft = parseInt(containerStyle.paddingLeft) || 0
|
||||||
|
const containerPaddingTop = parseInt(containerStyle.paddingTop) || 0
|
||||||
|
const containerMarginLeft = parseInt(containerStyle.marginLeft) || 0
|
||||||
|
const containerMarginTop = parseInt(containerStyle.marginTop) || 0
|
||||||
|
|
||||||
|
x = x - containerPaddingLeft - containerMarginLeft
|
||||||
|
y = y - containerPaddingTop - containerMarginTop
|
||||||
|
|
||||||
|
console.log('🔍 位置计算详情:', {
|
||||||
|
element: element.textContent?.substring(0, 30),
|
||||||
|
elementRect: { left: elementRect.left, top: elementRect.top, width: elementRect.width, height: elementRect.height },
|
||||||
|
containerRect: { left: containerRect.left, top: containerRect.top },
|
||||||
|
scroll: { left: scrollLeft, top: scrollTop },
|
||||||
|
containerScale: { scaleX, scaleY },
|
||||||
|
elementScale: { scaleX: elementScaleX, scaleY: elementScaleY },
|
||||||
|
containerPadding: { left: containerPaddingLeft, top: containerPaddingTop },
|
||||||
|
finalPosition: { x, y }
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: elementRect.left - containerRect.left,
|
x: Math.round(x * 100) / 100, // 保留2位小数,避免浮点精度问题
|
||||||
y: elementRect.top - containerRect.top,
|
y: Math.round(y * 100) / 100,
|
||||||
width: elementRect.width,
|
width: elementRect.width,
|
||||||
height: elementRect.height
|
height: elementRect.height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确保所有内容已完全渲染
|
* 确保所有内容已完全渲染(优化版)
|
||||||
* 特别是foreignObject内容
|
* 包括图片加载、字体加载、DOM更新、foreignObject渲染
|
||||||
*/
|
*/
|
||||||
const ensureContentRendered = async () => {
|
const ensureContentRendered = async (container: HTMLElement) => {
|
||||||
|
console.log('🔄 开始确保内容完全渲染...')
|
||||||
|
|
||||||
// 1. 等待Vue DOM更新
|
// 1. 等待Vue DOM更新
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
// 2. 等待字体加载
|
// 2. 等待字体加载
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
await document.fonts.ready
|
await document.fonts.ready
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 等待下一帧渲染
|
// 3. 等待下一帧渲染
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||||
|
|
||||||
// 4. 额外等待foreignObject渲染
|
// 4. 收集所有图片元素并等待加载完成
|
||||||
|
const allImages = Array.from(container.querySelectorAll('img')) as HTMLImageElement[]
|
||||||
|
if (allImages.length > 0) {
|
||||||
|
console.log(`🖼️ 等待 ${allImages.length} 个图片加载完成...`)
|
||||||
|
await ensureImagesLoaded(allImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 等待所有foreignObject内容渲染
|
||||||
|
const foreignObjects = container.querySelectorAll('foreignObject')
|
||||||
|
if (foreignObjects.length > 0) {
|
||||||
|
console.log(`📄 等待 ${foreignObjects.length} 个foreignObject渲染完成...`)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 等待CSS动画和过渡完成
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
// 7. 最后等待一帧确保所有内容稳定
|
||||||
|
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||||
|
|
||||||
|
console.log('✅ 内容渲染同步完成')
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) => {
|
const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) => {
|
||||||
console.log('🎯 generateSvg 开始执行,noForeignObject:', noForeignObject)
|
console.log('🎯 generateSvg 开始执行,noForeignObject:', noForeignObject)
|
||||||
|
|
||||||
// 确保所有内容已完全渲染,特别是foreignObject
|
|
||||||
await ensureContentRendered()
|
|
||||||
|
|
||||||
const mapDiv = mei.nodes
|
const mapDiv = mei.nodes
|
||||||
|
|
||||||
|
// 确保所有内容已完全渲染,特别是foreignObject
|
||||||
|
await ensureContentRendered(mapDiv)
|
||||||
|
|
||||||
const mapDivRect = mapDiv.getBoundingClientRect()
|
const mapDivRect = mapDiv.getBoundingClientRect()
|
||||||
|
|
||||||
const height = mapDiv.offsetHeight + padding * 2
|
const height = mapDiv.offsetHeight + padding * 2
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue