// ── 另存为辅助函数 ── // 使用浏览器原生 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 失败,请重试。'); } }