// ── 另存为辅助函数 ──
// 使用浏览器原生 showSaveFilePicker 弹出保存对话框
// 让用户选择保存位置和修改文件名
async function saveFileAs(blob, suggestedName, fileTypes) {
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: suggestedName,
types: fileTypes || []
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return true;
} catch (e) {
if (e.name === 'AbortError') return false; // 用户取消保存
console.warn('showSaveFilePicker 不可用,回退到传统下载:', e);
}
}
// 回退:传统下载方式
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return true;
}
// 从编辑器内容提取文档标题作为默认文件名
function getSuggestedFileName(ext) {
try {
if (typeof extractDocTitle === 'function' && typeof editor !== 'undefined') {
const title = extractDocTitle(editor.value);
// 清理文件名中的非法字符
const safeName = (title || 'markmap').replace(/[<>:"/\\|?*]/g, '_');
return safeName + '.' + ext;
}
} catch {}
return 'markmap.' + ext;
}
// ── 等待 SVG 中所有图片加载完成 ──
async function waitForImages() {
const svg = document.getElementById('markmap');
if (!svg) return;
const imgs = svg.querySelectorAll('img');
const promises = [];
imgs.forEach(img => {
if (!img.complete && img.src) {
promises.push(new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
}));
}
});
if (promises.length > 0) {
await Promise.all(promises);
}
}
// ── SVG 内容获取 ──
// forCanvas: 为 Canvas 渲染准备时,将 foreignObject 中的
转为 SVG 元素
// 原因:SVG 作为 data URL 加载时,浏览器安全限制不渲染 foreignObject 中的
,
// 但原生 SVG 元素可以正常渲染。
function getSVGContent(forCanvas = false) {
const svg = document.getElementById('markmap');
if (!svg) {
console.error('未找到 SVG 元素');
return null;
}
const clonedSvg = svg.cloneNode(true);
// 移除 id 属性,防止 CSS 中的 #markmap { width:100%; height:100% } 覆盖显式宽高
// (SVG 作为 data URL 加载时,100% 无父元素参照会导致尺寸为 0)
clonedSvg.removeAttribute('id');
// 设置命名空间
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
// ── 获取完整内容尺寸(不限于当前视口,支持长图高清导出)──
// markmap 的 SVG:
// 第一个 是缩放容器,getBBox() 返回全部内容在本地坐标系中的包围盒
const originalMainG = svg.querySelector('g');
let contentX = 0, contentY = 0, contentWidth = 0, contentHeight = 0;
try {
if (originalMainG) {
const contentBBox = originalMainG.getBBox();
contentX = contentBBox.x;
contentY = contentBBox.y;
contentWidth = contentBBox.width;
contentHeight = contentBBox.height;
// 克隆中移除缩放 transform,让内容以原始坐标显示(1:1 无缩放)
// 这样导出的 SVG/PNG/PDF 包含全部内容,不受当前缩放/平移影响
const clonedMainG = clonedSvg.querySelector('g');
if (clonedMainG) {
clonedMainG.removeAttribute('transform');
}
}
} catch (e) {
console.warn('获取内容包围盒失败,回退到视口尺寸:', e.message);
}
if (contentWidth === 0 || contentHeight === 0) {
// 回退:使用视口尺寸
const rect = svg.getBoundingClientRect();
contentX = 0;
contentY = 0;
contentWidth = Math.ceil(rect.width);
contentHeight = Math.ceil(rect.height);
}
// 添加边距,确保内容不被裁切
const padding = 40;
contentX -= padding;
contentY -= padding;
contentWidth = Math.max(Math.ceil(contentWidth + padding * 2), 100);
contentHeight = Math.max(Math.ceil(contentHeight + padding * 2), 100);
// 设置 viewBox 覆盖完整内容(长图效果:内容多时图片自动变长)
clonedSvg.setAttribute('viewBox', `${contentX} ${contentY} ${contentWidth} ${contentHeight}`);
clonedSvg.setAttribute('width', contentWidth);
clonedSvg.setAttribute('height', contentHeight);
// 确保图片 src 属性正确序列化
// cloneNode(true) 可能不会正确复制 img.src(浏览器内部属性 vs HTML 属性)
const originalImgs = svg.querySelectorAll('img');
const clonedImgs = clonedSvg.querySelectorAll('img');
for (let i = 0; i < originalImgs.length && i < clonedImgs.length; i++) {
const src = originalImgs[i].src;
if (src) {
clonedImgs[i].setAttribute('src', src);
}
const style = originalImgs[i].getAttribute('style');
if (style) {
clonedImgs[i].setAttribute('style', style);
}
}
// 为 Canvas 渲染准备:将 foreignObject 中的
转为 SVG 元素
if (forCanvas) {
convertImagesToSvgImages(clonedSvg, svg);
}
// 收集页面中与 markmap 相关的样式表
const styleElement = document.createElementNS('http://www.w3.org/2000/svg', 'style');
let cssText = '';
for (const sheet of document.styleSheets) {
try {
const rules = sheet.cssRules || [];
for (const rule of rules) {
const selector = rule.selectorText || '';
// 跳过会影响 SVG 根元素尺寸的 CSS 规则(100% 在独立 SVG 中无意义)
if ((selector === 'svg' || selector === '#markmap') &&
(rule.cssText.includes('width') || rule.cssText.includes('height'))) {
continue;
}
if (selector.includes('markmap') ||
selector.includes('svg') ||
selector.includes('foreignObject') ||
selector.includes('img') ||
selector.includes(':root') ||
rule.cssText.includes('--markmap')) {
cssText += rule.cssText + '\n';
}
}
} catch {
// 跨域样式表无法访问 cssRules,跳过
}
}
// 添加内联样式中的 CSS 变量
const rootStyle = getComputedStyle(document.documentElement);
const markmapVars = [
'--markmap-circle-open-bg',
'--markmap-max-width',
];
let rootCss = ':root {';
for (const varName of markmapVars) {
const val = rootStyle.getPropertyValue(varName).trim();
if (val) rootCss += `${varName}: ${val};`;
}
rootCss += '}';
if (rootCss !== ':root {}') cssText = rootCss + '\n' + cssText;
styleElement.textContent = cssText;
clonedSvg.insertBefore(styleElement, clonedSvg.firstChild);
return new XMLSerializer().serializeToString(clonedSvg);
}
// ── 将 foreignObject 中的
转为 SVG 元素 ──
// SVG 作为 data URL 图片加载时,浏览器不渲染 foreignObject 中的 HTML
,
// 但原生 SVG 元素可以正常渲染,因此需要转换。
// 通过 getBoundingClientRect 计算图片在屏幕上的位置,再换算为 SVG 坐标。
function convertImagesToSvgImages(clonedSvg, originalSvg) {
const originalFos = originalSvg.querySelectorAll('foreignObject');
const clonedFos = clonedSvg.querySelectorAll('foreignObject');
for (let i = 0; i < originalFos.length && i < clonedFos.length; i++) {
const originalFo = originalFos[i];
const clonedFo = clonedFos[i];
const originalImgs = originalFo.querySelectorAll('img');
const clonedImgs = clonedFo.querySelectorAll('img');
for (let j = 0; j < originalImgs.length && j < clonedImgs.length; j++) {
const originalImg = originalImgs[j];
const clonedImg = clonedImgs[j];
const src = originalImg.src;
if (!src) continue;
// 获取图片在屏幕上的渲染尺寸和位置
const imgRect = originalImg.getBoundingClientRect();
const foRect = originalFo.getBoundingClientRect();
// 跳过未渲染或尺寸为 0 的图片
if (imgRect.width === 0 || imgRect.height === 0) continue;
// 图片相对于 foreignObject 的偏移(屏幕像素)
const offsetX = imgRect.left - foRect.left;
const offsetY = imgRect.top - foRect.top;
// 计算屏幕像素到 SVG 坐标单位的缩放比
// foreignObject 的 width 属性是 SVG 坐标单位,foRect.width 是屏幕像素
const foWidthAttr = parseFloat(clonedFo.getAttribute('width')) || 0;
const scaleFactor = foWidthAttr > 0 && foRect.width > 0
? foWidthAttr / foRect.width
: 1;
// foreignObject 在父级 SVG 坐标中的位置
const foX = parseFloat(clonedFo.getAttribute('x')) || 0;
const foY = parseFloat(clonedFo.getAttribute('y')) || 0;
// 图片在 SVG 坐标中的位置和尺寸
const imgX = foX + offsetX * scaleFactor;
const imgY = foY + offsetY * scaleFactor;
const imgW = imgRect.width * scaleFactor;
const imgH = imgRect.height * scaleFactor;
// 创建 SVG 元素
const svgImage = document.createElementNS('http://www.w3.org/2000/svg', 'image');
svgImage.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', src);
svgImage.setAttribute('href', src);
svgImage.setAttribute('x', imgX);
svgImage.setAttribute('y', imgY);
svgImage.setAttribute('width', imgW);
svgImage.setAttribute('height', imgH);
svgImage.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// 在 foreignObject 之前插入 SVG
clonedFo.parentNode.insertBefore(svgImage, clonedFo);
// 从克隆的 foreignObject 中移除
(避免重复显示)
clonedImg.remove();
}
}
}
// ── 导出 SVG ──
async function exportSVG() {
// 等待图片加载完成(getSVGContent(true) 需要用 getBoundingClientRect 计算坐标)
await waitForImages();
// 使用 forCanvas=true 将 foreignObject 中的
转为原生 SVG 元素
// 原因:许多 SVG 查看器不渲染 foreignObject 中的 HTML 内容,
// 原生 SVG 元素在所有 SVG 查看器中都能正常显示
const svgContent = getSVGContent(true);
if (!svgContent) {
alert('无法生成 SVG,请确保思维导图已渲染。');
return;
}
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
await saveFileAs(blob, getSuggestedFileName('svg'), [{
description: 'SVG 矢量图',
accept: { 'image/svg+xml': ['.svg'] }
}]);
}
// ── SVG 转 Canvas ──
// SVG 中的图片已转换为原生 SVG 元素,data URL 方式可正常渲染。
// 失败时回退到 html2canvas。
function svgToCanvasViaDataUrl(svgContent, scale = 2) {
return new Promise((resolve, reject) => {
// 使用 encodeURIComponent 编码,兼容 Unicode 字符
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgContent);
const img = new Image();
img.onload = () => {
const w = img.naturalWidth || img.width || 1;
const h = img.naturalHeight || img.height || 1;
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(w * scale);
canvas.height = Math.ceil(h * scale);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas);
};
img.onerror = () => {
reject(new Error('data URL 方式失败'));
};
img.src = dataUrl;
});
}
async function svgToCanvasViaHtml2canvas(scale = 4) {
if (typeof html2canvas === 'undefined') {
throw new Error('html2canvas 未加载');
}
const container = document.getElementById('markmap-container');
if (!container) throw new Error('未找到容器');
// 隐藏工具栏
const toolbars = container.querySelectorAll('.markmap-toolbar, .mm-toolbar');
const hidden = [];
toolbars.forEach(tb => {
hidden.push({ el: tb, vis: tb.style.visibility, disp: tb.style.display });
tb.style.visibility = 'hidden';
tb.style.display = 'none';
});
const origBg = container.style.background;
container.style.background = 'white';
try {
const canvas = await html2canvas(container, {
backgroundColor: '#ffffff',
scale: scale,
logging: false,
useCORS: true,
allowTaint: true
});
return canvas;
} finally {
container.style.background = origBg;
hidden.forEach(({ el, vis, disp }) => {
el.style.visibility = vis || '';
el.style.display = disp || '';
});
}
}
async function renderToCanvas(svgContent, scale = 2) {
// 优先尝试 SVG data URL 方式(图片已转换为 SVG ,可正常渲染)
try {
return await svgToCanvasViaDataUrl(svgContent, scale);
} catch (e) {
console.warn('SVG data URL 渲染失败,回退到 html2canvas:', e.message);
return await svgToCanvasViaHtml2canvas(scale);
}
}
// ── 高清导出辅助函数 ──
// 浏览器 Canvas 最大尺寸限制(大多数浏览器为 16384px)
const MAX_CANVAS_DIM = 16384;
// 根据内容尺寸计算最优缩放倍数,防止 Canvas 超出浏览器限制
function getOptimalScale(width, height, desiredScale = 3) {
const scaledW = width * desiredScale;
const scaledH = height * desiredScale;
if (scaledW <= MAX_CANVAS_DIM && scaledH <= MAX_CANVAS_DIM) {
return desiredScale;
}
// 降级:确保不超过浏览器限制,但至少保持 1 倍
return Math.max(1, Math.min(
MAX_CANVAS_DIM / width,
MAX_CANVAS_DIM / height,
desiredScale
));
}
// 获取思维导图完整内容尺寸(与 getSVGContent 中的计算一致)
function getContentDimensions() {
const svg = document.getElementById('markmap');
if (!svg) return { width: 1000, height: 800 };
const mainG = svg.querySelector('g');
try {
if (mainG) {
const bbox = mainG.getBBox();
const padding = 40;
return {
width: Math.max(Math.ceil(bbox.width + padding * 2), 100),
height: Math.max(Math.ceil(bbox.height + padding * 2), 100)
};
}
} catch (e) { /* 回退 */ }
const rect = svg.getBoundingClientRect();
return { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
}
// 在 Canvas 中查找最佳分页切割点(在内容间隙处切割,避免切穿节点文字)
// 在 [searchStart, searchEnd] 范围内寻找全白行,返回最接近 idealY 的白行位置
function findBestSplitPoint(canvas, idealY, searchStart, searchEnd) {
try {
const ctx = canvas.getContext('2d');
const regionH = searchEnd - searchStart;
if (regionH <= 0) return idealY;
// 获取搜索区域的像素数据
const imageData = ctx.getImageData(0, searchStart, canvas.width, regionH);
const data = imageData.data;
// 采样列以提高性能(约每 100 列采样一次)
const sampleStep = Math.max(1, Math.floor(canvas.width / 100));
// 查找范围内的全白行(像素值 ≥ 240 视为白色背景)
const whiteRows = [];
for (let y = 0; y < regionH; y++) {
let isWhite = true;
for (let x = 0; x < canvas.width; x += sampleStep) {
const idx = (y * canvas.width + x) * 4;
if (data[idx] < 240 || data[idx + 1] < 240 || data[idx + 2] < 240) {
isWhite = false;
break;
}
}
if (isWhite) whiteRows.push(searchStart + y);
}
if (whiteRows.length === 0) return idealY;
// 返回最接近 idealY 的白行位置
let bestRow = whiteRows[0];
let bestDist = Math.abs(bestRow - idealY);
for (const row of whiteRows) {
const dist = Math.abs(row - idealY);
if (dist < bestDist) {
bestDist = dist;
bestRow = row;
}
}
return bestRow;
} catch (e) {
// canvas 可能被污染(tainted),回退到理想位置
return idealY;
}
}
// ── 导出 PNG ──
async function exportPNG() {
// 等待所有图片加载完成(确保 getBoundingClientRect 返回正确尺寸)
await waitForImages();
const svgContent = getSVGContent(true);
if (!svgContent) {
alert('无法生成 PNG,请确保思维导图已渲染。');
return;
}
try {
// 高清导出:3 倍缩放,内容过大时自动降级防止 Canvas 超限
const { width, height } = getContentDimensions();
const scale = getOptimalScale(width, height, 3);
const canvas = await renderToCanvas(svgContent, scale);
const blob = await new Promise(resolve => canvas.toBlob(resolve));
if (!blob) {
console.error('生成 PNG Blob 失败');
return;
}
await saveFileAs(blob, getSuggestedFileName('png'), [{
description: 'PNG 图片',
accept: { 'image/png': ['.png'] }
}]);
} catch (error) {
console.error('导出 PNG 失败:', error);
alert('导出 PNG 失败,请重试。');
}
}
// ── 导出 PDF ──
// PDF 生成耗时较长,需在用户手势上下文中先弹出保存对话框,再生成内容
async function exportPDF(orientation = 'auto') {
if (!window.jspdf) {
alert('导出库未加载,无法导出 PDF。');
return;
}
// 先弹出原生保存对话框(必须在用户手势上下文中,耗时操作之前)
const suggestedName = getSuggestedFileName('pdf');
let fileHandle = null;
if (window.showSaveFilePicker) {
try {
fileHandle = await window.showSaveFilePicker({
suggestedName: suggestedName,
types: [{ description: 'PDF 文档', accept: { 'application/pdf': ['.pdf'] } }]
});
} catch (e) {
if (e.name === 'AbortError') return; // 用户取消
console.warn('showSaveFilePicker 不可用,回退到传统下载:', e);
}
}
// 等待所有图片加载完成
await waitForImages();
const svgContent = getSVGContent(true);
if (!svgContent) {
alert('无法生成 PDF,请确保思维导图已渲染。');
return;
}
try {
// 高清渲染完整内容
const { width, height } = getContentDimensions();
const scale = getOptimalScale(width, height, 3);
const canvas = await renderToCanvas(svgContent, scale);
// A4 尺寸(mm)
const A4_SHORT = 210;
const A4_LONG = 297;
// 根据用户选择或内容宽高比确定方向
// orientation: 'auto'(自动)| 'landscape'(横向)| 'portrait'(纵向)
const contentRatio = canvas.width / canvas.height;
let useLandscape;
if (orientation === 'landscape') {
useLandscape = true;
} else if (orientation === 'portrait') {
useLandscape = false;
} else {
// 自动:宽 ≥ 高 → 横向,否则 → 纵向
useLandscape = contentRatio >= 1;
}
// A4 纸张摆放宽度:竖向A4用短边(210mm),横向A4用长边(297mm)
// 内容按此宽度缩放,决定每像素对应的毫米数
const pageW = useLandscape ? A4_LONG : A4_SHORT; // 摆放宽度:竖向=短边,横向=长边
const pageH = useLandscape ? A4_SHORT : A4_LONG; // 页面高度:竖向=长边,横向=短边
// 将内容缩放至 A4 摆放宽度
const imgW = pageW;
const imgH = (canvas.height / canvas.width) * imgW;
// 计算总页数:内容高度超过一页时自动分页
const pagesNeeded = Math.max(1, Math.ceil(imgH / pageH));
const pdf = new window.jspdf.jsPDF({
orientation: useLandscape ? 'landscape' : 'portrait',
unit: 'mm',
format: 'a4'
});
if (pagesNeeded === 1) {
// 单页:垂直居中显示
const imgData = canvas.toDataURL('image/png');
const yOffset = (pageH - imgH) / 2;
pdf.addImage(imgData, 'PNG', 0, yOffset, imgW, imgH);
} else {
// 多页:智能分页,在内容间隙(白行)处切割,避免切穿节点文字
// 理想切割点:按照A4摆放宽度(竖向短边210mm / 横向长边297mm)缩放后,
// 一页A4高度对应的Canvas像素数 = (pageH / pageW) * canvas.width
// 竖向:(297 / 210) * canvas.width ≈ 1.414 × canvas.width
// 横向:(210 / 297) * canvas.width ≈ 0.707 × canvas.width
const srcSliceHeight = Math.round((pageH / pageW) * canvas.width);
// 搜索范围:在理想切割点向前 15% 页高内寻找白行
// 只向前搜索,确保每页切片不超过一页高度
const searchRange = Math.round(srcSliceHeight * 0.15);
// 逐页计算智能切割点
const splitPoints = [0];
let currentY = 0;
while (currentY < canvas.height - 1) {
const idealEnd = currentY + srcSliceHeight;
if (idealEnd >= canvas.height) {
// 剩余内容不足一页,作为最后一页
splitPoints.push(canvas.height);
break;
}
// 在 [idealEnd - searchRange, idealEnd] 范围搜索白行
const searchStart = Math.max(currentY + 1, idealEnd - searchRange);
const bestSplit = findBestSplitPoint(canvas, idealEnd, searchStart, idealEnd);
if (bestSplit <= currentY) {
// 未找到合适白行,在理想位置切割
splitPoints.push(idealEnd);
currentY = idealEnd;
} else {
splitPoints.push(bestSplit);
currentY = bestSplit;
}
}
// 按切割点逐页生成 PDF
for (let i = 0; i < splitPoints.length - 1; i++) {
if (i > 0) pdf.addPage();
const srcY = splitPoints[i];
const srcH = splitPoints[i + 1] - srcY;
const pageCanvas = document.createElement('canvas');
pageCanvas.width = canvas.width;
pageCanvas.height = srcH;
const ctx = pageCanvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height);
ctx.drawImage(canvas, 0, srcY, canvas.width, srcH,
0, 0, canvas.width, srcH);
const pageImgData = pageCanvas.toDataURL('image/png');
// 当前页显示高度(mm),不超过页面高度
const sliceDisplayH = Math.min((srcH / canvas.width) * pageW, pageH);
// 内容置于顶部,便于多页拼接连续观看
pdf.addImage(pageImgData, 'PNG', 0, 0, pageW, sliceDisplayH);
}
}
const pdfBlob = pdf.output('blob');
// 写入文件:优先使用原生句柄,回退到传统下载
if (fileHandle) {
const writable = await fileHandle.createWritable();
await writable.write(pdfBlob);
await writable.close();
} else {
const url = URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = url;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
} catch (error) {
console.error('导出 PDF 失败:', error);
alert('导出 PDF 失败,请重试。');
}
}