import { App, Plugin, PluginSettingTab, Setting, TFile, Notice } from 'obsidian'; interface VitePressExporterSettings { docsPath: string; assetsPath: string; } const DEFAULT_SETTINGS: VitePressExporterSettings = { docsPath: 'docs', assetsPath: 'docs/assets' } export default class VitePressExporterPlugin extends Plugin { settings: VitePressExporterSettings; async onload() { await this.loadSettings(); // 添加导出命令 this.addCommand({ id: 'export-to-vitepress', name: 'Export current note to VitePress', callback: () => { this.exportCurrentNote(); } }); // 添加功能区按钮 this.addRibbonIcon('upload', 'Export to VitePress', () => { this.exportCurrentNote(); }); // 添加设置面板 this.addSettingTab(new VitePressExporterSettingTab(this.app, this)); console.log('VitePress Exporter plugin loaded'); } onunload() { console.log('VitePress Exporter plugin unloaded'); } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } // 导出当前笔记 async exportCurrentNote() { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice('No active file found'); return; } if (activeFile.extension !== 'md') { new Notice('Current file is not a markdown file'); return; } new Notice(`Exporting ${activeFile.name} to VitePress...`); try { await this.exportNote(activeFile); new Notice(`Successfully exported ${activeFile.name} to VitePress`); } catch (error) { console.error('Export failed:', error); new Notice(`Export failed: ${error.message}`); } } // 导出单个笔记 async exportNote(file: TFile) { const { vault } = this.app; // 使用 binary 模式读取文件以保留原始格式(包括行尾空格) const contentBuffer = await vault.readBinary(file); const content = new TextDecoder().decode(contentBuffer); // 1. 扫描并复制所有资源文件(传入文件对象用于路径解析) const updatedContent = await this.processAssets(content, file); // 2. 处理 canvas 引用(暂时保留,后续实现) // const finalContent = await this.processCanvases(updatedContent); // 3. 写入到 docs 目录(使用 binary 模式保留原始格式) const docsPath = `${this.settings.docsPath}/${file.name}`; const updatedBuffer = new TextEncoder().encode(updatedContent); await vault.adapter.writeBinary(docsPath, updatedBuffer); console.log(`Exported ${file.name} to ${docsPath}`); } // 处理资源文件 async processAssets(content: string, sourceFile: TFile): Promise { const { vault, metadataCache } = this.app; console.log('Processing assets for file:', sourceFile.path); // 使用 Obsidian API 获取文件的所有嵌入和链接 const cache = metadataCache.getCache(sourceFile.path); console.log('Cache:', cache); console.log('Embeds:', cache?.embeds); console.log('Links:', cache?.links); if (!cache) { console.log('No cache found for file'); return content; } const assetsToCopy = new Set(); const canvasFiles = new Set(); // 收集嵌入文件(图片等) if (cache.embeds) { for (const embed of cache.embeds) { console.log('Processing embed:', embed); const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path); console.log('Resolved link for embed:', resolvedLink?.path); if (resolvedLink) { // 检查是否是 canvas 文件 if (resolvedLink.extension === 'canvas') { canvasFiles.add(resolvedLink.path); } else { assetsToCopy.add(resolvedLink.path); } } } } // 收集普通链接(可选,如果也想复制链接的文件) if (cache.links) { for (const link of cache.links) { const resolvedLink = metadataCache.getFirstLinkpathDest(link.link, sourceFile.path); // 只复制本地文件链接,不包括其他 md 文件 if (resolvedLink && resolvedLink.extension !== 'md') { assetsToCopy.add(resolvedLink.path); } } } console.log('Assets to copy:', Array.from(assetsToCopy)); console.log('Canvas files to render:', Array.from(canvasFiles)); // 处理 Canvas 文件:渲染为图片 for (const canvasPath of canvasFiles) { try { const canvasFile = vault.getAbstractFileByPath(canvasPath); if (canvasFile instanceof TFile) { const renderedImage = await this.renderCanvasToImage(canvasFile); if (renderedImage) { const imageFileName = canvasFile.name.replace('.canvas', '.png'); // 图片保存到 docs/assets/ const imagePath = `${this.settings.assetsPath}/${imageFileName}`; // 确保目标目录存在 await vault.adapter.mkdir(this.settings.assetsPath); // 写入渲染的图片 await vault.adapter.writeBinary(imagePath, renderedImage); // 更新内容中的 canvas 引用,使用相对路径 assets/xxx.png content = this.updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName); console.log(`Successfully rendered canvas: ${canvasPath} -> ${imagePath}`); } } } catch (error) { console.error(`Failed to render canvas ${canvasPath}:`, error); } } // 复制资源文件到 docs/assets for (const assetPath of assetsToCopy) { try { const sourceFile = vault.getAbstractFileByPath(assetPath); console.log('Source file for asset:', sourceFile); if (sourceFile instanceof TFile) { // 使用 binary 读取模式来保留文件原始内容 const assetContent = await vault.readBinary(sourceFile); const fileName = sourceFile.name; const destPath = `${this.settings.assetsPath}/${fileName}`; console.log('Copying asset:', assetPath, '->', destPath); // 确保目标目录存在 await vault.adapter.mkdir(this.settings.assetsPath); // 写入资源文件(使用 binary 模式) await vault.adapter.writeBinary(destPath, assetContent); // 更新内容中的资源路径(使用更智能的替换) content = this.updateLinksInContent(content, sourceFile, assetPath, fileName); console.log(`Successfully copied asset: ${assetPath} -> ${destPath}`); } } catch (error) { console.error(`Failed to copy asset ${assetPath}:`, error); } } return content; } // 获取原始链接文本 getOriginalLink(content: string, targetPath: string, sourcePath: string): string | null { const { metadataCache } = this.app; // 尝试多种可能的链接格式 const possibleFormats = [ // Wikilink 格式 new RegExp(`!\\[\\[${this.escapeRegExp(targetPath)}(\\|[^\\]]*)?\\]\\]`, 'g'), new RegExp(`\\[\\[${this.escapeRegExp(targetPath)}(\\|[^\\]]*)?\\]\\]`, 'g'), // Markdown 格式 new RegExp(`!\\[.*?\\]\\(${this.escapeRegExp(targetPath)}\\)`, 'g'), new RegExp(`\\[.*?\\]\\(${this.escapeRegExp(targetPath)}\\)`, 'g'), // 相对路径格式 new RegExp(`!\\[\\[${this.escapeRegExp(this.getRelativePath(targetPath, sourcePath))}(\\|[^\\]]*)?\\]\\]`, 'g'), ]; for (const regex of possibleFormats) { const match = content.match(regex); if (match) { return match[0]; } } return null; } // 更新内容中的链接 updateLinksInContent(content: string, sourceFile: TFile, assetPath: string, newFileName: string): string { // 获取源文件的目录路径 const sourceDir = sourceFile.path.substring(0, sourceFile.path.lastIndexOf('/')); if (!sourceDir) return content; // 尝试多种可能的路径格式 const possiblePaths = [ // 完整路径 assetPath, // 相对于源文件的路径 assetPath.substring(sourceDir.length + 1), // URL 编码版本 encodeURI(assetPath), encodeURI(assetPath.substring(sourceDir.length + 1)), // 文件名 sourceFile.name, encodeURI(sourceFile.name) ]; console.log('Trying to replace links for paths:', possiblePaths); // 替换 markdown 格式: ![](path) -> ![](assets/newfile.png) const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; content = content.replace(markdownRegex, (match, alt, path) => { // 检查这个路径是否匹配我们的目标文件 for (const testPath of possiblePaths) { if (path === testPath || path === testPath.replace(/^\//, '')) { console.log(`Found matching link: ${path} -> ${this.settings.assetsPath}/${newFileName}`); return `![](${this.settings.assetsPath}/${newFileName})`; } } return match; }); // 替换 wikilink 格式: ![[path]] -> ![[assets/newfile.png]] const wikilinkRegex = /!\[\[([^\]]+)\]\]/g; content = content.replace(wikilinkRegex, (match, link) => { // 移除可能的别名字段 const cleanLink = link.split('|')[0].trim(); for (const testPath of possiblePaths) { if (cleanLink === testPath || cleanLink === testPath.replace(/^\//, '')) { console.log(`Found matching wikilink: ${cleanLink} -> ${this.settings.assetsPath}/${newFileName}`); return `![[${this.settings.assetsPath}/${newFileName}]]`; } } return match; }); return content; } // 辅助函数:转义正则表达式特殊字符 escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // 辅助函数:获取相对路径 getRelativePath(targetPath: string, sourcePath: string): string { // 简化的相对路径计算 const targetParts = targetPath.split('/'); const sourceParts = sourcePath.split('/'); // 移除文件名 sourceParts.pop(); let commonIndex = 0; while (commonIndex < Math.min(targetParts.length - 1, sourceParts.length) && targetParts[commonIndex] === sourceParts[commonIndex]) { commonIndex++; } const relativeParts = []; for (let i = commonIndex; i < sourceParts.length; i++) { relativeParts.push('..'); } return [...relativeParts, ...targetParts.slice(commonIndex)].join('/'); } // 渲染 Canvas 为图片(使用 Obsidian 原生渲染) async renderCanvasToImage(canvasFile: TFile): Promise { try { console.log('🎯 Attempting to render canvas using Obsidian native rendering:', canvasFile.path); // 使用 Workspace API 打开 Canvas 并捕获原生渲染 const nativeImage = await this.tryNativeCanvasCapture(canvasFile); if (nativeImage) { console.log('✅ Successfully captured native Obsidian canvas rendering'); return nativeImage; } console.log('⚠️ Native capture failed, using placeholder'); // 降级方案:生成占位图 return this.generatePlaceholderImage(canvasFile); } catch (error) { console.error('❌ Error rendering canvas:', error); return this.generatePlaceholderImage(canvasFile); } } // 隐藏 Canvas UI 元素(用于导出时) hideCanvasUIElements(): HTMLStyleElement { const styleElement = document.createElement('style'); styleElement.id = 'canvas-export-hide-ui'; styleElement.textContent = ` /* 隐藏 Canvas 控制按钮 */ .canvas-control-menu, .canvas-card-menu, .canvas-node-menu-button, .canvas-node:hover .canvas-node-menu-button, .canvas-controls, .canvas-help-button { display: none !important; } /* 移除选中状态的高亮 */ .canvas-node.is-selected, .canvas-node.is-focused, .canvas-edge.is-selected { box-shadow: none !important; outline: none !important; } /* 隐藏工具提示和弹出菜单 */ .tooltip, .popover, .modal { display: none !important; } /* 隐藏滚动条但保留滚动功能 */ .canvas-view-content::-webkit-scrollbar { display: none !important; } /* 确保内容不被裁切 */ .canvas-view-content { overflow: visible !important; } /* Privacy Mode: 防止卡片内容因缩放被隐藏 */ .canvas-node-content-hidden { display: block !important; opacity: 1 !important; } /* 确保所有卡片内容始终可见 */ .canvas-node-content { display: block !important; opacity: 1 !important; } /* 防止卡片被折叠 */ .canvas-node .canvas-node-content { display: block !important; } `; document.head.appendChild(styleElement); console.log('✅ Canvas UI elements hidden for export (privacy mode enabled)'); return styleElement; } // 计算实际的 Canvas 内容尺寸和偏移 getCanvasContentSize(canvasDom: Element): { width: number; height: number; offsetX: number; offsetY: number } { const nodes = canvasDom.querySelectorAll('.canvas-node'); console.log(`📐 Found ${nodes.length} canvas nodes`); if (nodes.length === 0) { // 降级方案:使用容器尺寸 return { width: (canvasDom as any).offsetWidth || 800, height: (canvasDom as any).offsetHeight || 600, offsetX: 0, offsetY: 0 }; } // 计算所有节点的边界框 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; nodes.forEach(node => { const rect = node.getBoundingClientRect(); const canvasRect = canvasDom.getBoundingClientRect(); // 计算相对于 Canvas 的位置 const relativeX = rect.left - canvasRect.left; const relativeY = rect.top - canvasRect.top; minX = Math.min(minX, relativeX); minY = Math.min(minY, relativeY); maxX = Math.max(maxX, relativeX + rect.width); maxY = Math.max(maxY, relativeY + rect.height); }); // 添加 padding const padding = 50; const offsetX = Math.max(0, minX - padding); const offsetY = Math.max(0, minY - padding); const contentWidth = maxX - minX + padding * 2; const contentHeight = maxY - minY + padding * 2; console.log(`📐 Canvas: ${contentWidth}x${contentHeight}, offset: (${offsetX}, ${offsetY})`); return { width: contentWidth, height: contentHeight, offsetX, offsetY }; } // 通过滚动拼接捕获完整 Canvas async captureCanvasByScrolling( canvasDom: Element, contentSize: { width: number; height: number; offsetX: number; offsetY: number } ): Promise { try { // 检查 canvasDom 本身是否就是 wrapper,或者 wrapper 是其子元素 let canvasWrapper: HTMLElement | null = null; if ((canvasDom as HTMLElement).classList.contains('canvas-wrapper')) { console.log('📦 canvasDom itself is the wrapper'); canvasWrapper = canvasDom as HTMLElement; } else { canvasWrapper = canvasDom.querySelector('.canvas-wrapper') as HTMLElement; if (!canvasWrapper) { console.log('❌ No canvas wrapper found'); return null; } } // 使用 canvasWrapper 获取视口尺寸 const viewportWidth = canvasWrapper.offsetWidth; const viewportHeight = canvasWrapper.offsetHeight; const totalWidth = contentSize.width; const totalHeight = contentSize.height; console.log(`📐 Viewport: ${viewportWidth}x${viewportHeight}, Total: ${totalWidth}x${totalHeight}`); // 如果内容小于视口,直接捕获 if (totalWidth <= viewportWidth && totalHeight <= viewportHeight) { console.log('📸 Content fits in viewport, single capture'); return null; // 让降级处理 } // 计算需要分块的数量 const cols = Math.ceil(totalWidth / viewportWidth); const rows = Math.ceil(totalHeight / viewportHeight); console.log(`🧩 Will capture ${cols}x${rows} = ${cols * rows} tiles`); console.log(`📍 Content offset: (${contentSize.offsetX}, ${contentSize.offsetY})`); // 保存原始滚动位置 const originalScrollLeft = canvasWrapper.scrollLeft; const originalScrollTop = canvasWrapper.scrollTop; // 创建最终拼接的 Canvas const finalCanvas = document.createElement('canvas'); finalCanvas.width = totalWidth; finalCanvas.height = totalHeight; const ctx = finalCanvas.getContext('2d'); if (!ctx) { console.log('❌ Failed to get canvas context'); return null; } // 填充白色背景 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, totalWidth, totalHeight); // @ts-ignore const domToImage = require('dom-to-image-more'); // 分块捕获并拼接 for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const x = col * viewportWidth; const y = row * viewportHeight; // 滚动到对应位置 (确保从内容起始位置开始) const targetScrollLeft = Math.max(0, contentSize.offsetX + x); const targetScrollTop = Math.max(0, contentSize.offsetY + y); canvasWrapper.scrollLeft = targetScrollLeft; canvasWrapper.scrollTop = targetScrollTop; console.log(`🔄 Scrolled to (${targetScrollLeft}, ${targetScrollTop})`); // 等待渲染 await new Promise(resolve => setTimeout(resolve, 150)); // 捕获当前视口 // @ts-ignore const domToImage = require('dom-to-image-more'); const tileDataUrl = await domToImage.toPng(canvasWrapper, { backgroundColor: '#ffffff', quality: 1, width: viewportWidth, height: viewportHeight }); // 加载图片并绘制到最终 Canvas const img = await this.loadImage(tileDataUrl); if (img) { const drawWidth = Math.min(viewportWidth, totalWidth - x); const drawHeight = Math.min(viewportHeight, totalHeight - y); ctx.drawImage(img, 0, 0, drawWidth, drawHeight, x, y, drawWidth, drawHeight); console.log(`✅ Tile [${row},${col}] at (${x},${y}) size ${drawWidth}x${drawHeight}`); } if (img) { const drawWidth = Math.min(viewportWidth, totalWidth - x); const drawHeight = Math.min(viewportHeight, totalHeight - y); ctx.drawImage(img, 0, 0, drawWidth, drawHeight, x, y, drawWidth, drawHeight); console.log(`✅ Tile [${row},${col}] at (${x},${y}) size ${drawWidth}x${drawHeight}`); } } } // 恢复滚动位置 canvasWrapper.scrollLeft = originalScrollLeft; canvasWrapper.scrollTop = originalScrollTop; // 转换为 Uint8Array const finalDataUrl = finalCanvas.toDataURL('image/png'); const base64Data = finalDataUrl.split(',')[1]; const binaryString = atob(base64Data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } console.log('✅ Stitched canvas complete'); return bytes; } catch (error) { console.error('❌ Scroll and stitch failed:', error); return null; } } // 加载图片 async loadImage(dataUrl: string): Promise { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = dataUrl; }); } // 将 DataURL 转换为 Uint8Array dataUrlToUint8Array(dataUrl: string): Uint8Array { const base64Data = dataUrl.split(',')[1]; const binaryString = atob(base64Data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } // 尝试捕获 Obsidian 原生 Canvas 渲染(通过临时调整视口大小) async tryNativeCanvasCapture(canvasFile: TFile): Promise { let uiStyleElement: HTMLStyleElement | null = null; let sizeStyleElement: HTMLStyleElement | null = null; let activeLeaf: any = null; let canvasWrapper: HTMLElement | null = null; let originalScrollLeft = 0; let originalScrollTop = 0; try { console.log('🔍 Step 1: Getting active workspace leaf...'); // 获取当前活动的 leaf(不创建新窗口) activeLeaf = this.app.workspace.activeLeaf; if (!activeLeaf) { console.log('❌ No active leaf found'); return null; } console.log('🔍 Step 2: Opening canvas file...'); await activeLeaf.openFile(canvasFile); console.log('⏳ Step 3: Waiting for canvas render...'); await this.waitForCanvasRender(); console.log('🔍 Step 4: Finding canvas DOM...'); const canvasDom = this.findCanvasDomElement(); if (!canvasDom) { console.log('❌ Could not find canvas DOM element'); return null; } console.log('✅ Found canvas DOM:', canvasDom.className, 'Size:', (canvasDom as any).offsetWidth, 'x', (canvasDom as any).offsetHeight); console.log('🎨 Step 5: Hiding Canvas UI elements...'); uiStyleElement = this.hideCanvasUIElements(); console.log('📐 Step 6: Calculating canvas content size...'); const contentSize = this.getCanvasContentSize(canvasDom); // 获取 canvasWrapper if ((canvasDom as HTMLElement).classList.contains('canvas-wrapper')) { canvasWrapper = canvasDom as HTMLElement; } else { canvasWrapper = canvasDom.querySelector('.canvas-wrapper') as HTMLElement; } if (!canvasWrapper) { console.log('❌ Could not find canvas wrapper'); return null; } // 保存原始滚动位置 originalScrollLeft = canvasWrapper.scrollLeft; originalScrollTop = canvasWrapper.scrollTop; console.log(`📏 Original wrapper size: ${(canvasDom as any).offsetWidth}x${(canvasDom as any).offsetHeight}`); console.log(`📏 Content size: ${contentSize.width}x${contentSize.height}`); // 临时调整 canvas wrapper 大小以适应所有内容 console.log('🔧 Temporarily resizing canvas wrapper to fit all content...'); sizeStyleElement = document.createElement('style'); sizeStyleElement.id = 'canvas-export-resize'; sizeStyleElement.textContent = ` /* 临时调整 canvas wrapper 大小 */ .canvas-wrapper { width: ${contentSize.width + 100}px !important; height: ${contentSize.height + 100}px !important; max-width: ${contentSize.width + 100}px !important; max-height: ${contentSize.height + 100}px !important; overflow: visible !important; } /* 确保画布内容区域也扩展 */ .canvas-view-content { width: ${contentSize.width + 100}px !important; height: ${contentSize.height + 100}px !important; } `; document.head.appendChild(sizeStyleElement); // 等待样式应用 await new Promise(resolve => setTimeout(resolve, 100)); // 重置滚动位置 canvasWrapper.scrollLeft = 0; canvasWrapper.scrollTop = 0; // 等待布局更新 await new Promise(resolve => setTimeout(resolve, 200)); console.log('📸 Step 7: Capturing canvas...'); // @ts-ignore const domToImage = require('dom-to-image-more'); const dataUrl = await domToImage.toPng(canvasDom, { backgroundColor: '#ffffff', quality: 1, width: contentSize.width, height: contentSize.height, cacheBust: true }); const imageData = this.dataUrlToUint8Array(dataUrl); console.log('✅ Capture successful!'); return imageData; } catch (error) { console.error('❌ Native canvas capture failed:', error); return null; } finally { // 清理临时调整大小的样式 if (sizeStyleElement && sizeStyleElement.parentNode) { sizeStyleElement.parentNode.removeChild(sizeStyleElement); console.log('🧹 Removed temporary resize styles'); } if (uiStyleElement && uiStyleElement.parentNode) { uiStyleElement.parentNode.removeChild(uiStyleElement); console.log('🧹 Cleaned up UI hiding styles'); } // 恢复滚动位置 if (canvasWrapper) { canvasWrapper.scrollLeft = originalScrollLeft; canvasWrapper.scrollTop = originalScrollTop; console.log('🔄 Restored original scroll position'); } console.log('🧹 Cleanup complete'); } } // 在指定的 leaf 中查找 Canvas DOM 元素 async findCanvasDomInLeaf(leaf: any): Promise { console.log('🔍 Searching for canvas DOM in leaf...'); // 获取 leaf 的容器 const container = leaf.containerEl; if (!container) { console.log('❌ Leaf has no container'); return null; } console.log('📦 Leaf container:', container); // 在 leaf 容器中查找 Canvas(最多等待 20 秒) const maxWait = 20000; const startTime = Date.now(); return new Promise(resolve => { const checkCanvas = () => { const elapsed = Date.now() - startTime; if (elapsed > maxWait) { console.log('⏰ Canvas render timeout after', maxWait, 'ms'); resolve(null); return; } // 尝试多种可能的选择器(在 leaf 容器内搜索) const selectors = [ '.canvas-view-content', '.canvas-wrapper', '.canvas-container', '[data-type="canvas"]', '.canvas-view' ]; for (const selector of selectors) { const canvas = container.querySelector(selector); if (canvas) { const nodes = canvas.querySelectorAll('.canvas-node'); if (nodes.length > 0) { console.log(`✅ Found canvas with selector: ${selector} (${elapsed}ms) with ${nodes.length} nodes`); setTimeout(() => resolve(canvas), 500); return; } } } // 继续等待 setTimeout(checkCanvas, 100); }; console.log('⏳ Starting to wait for canvas render in leaf...'); checkCanvas(); }); } // 等待 Canvas 渲染完成 async waitForCanvasRender(): Promise { return new Promise(resolve => { const maxWait = 20000; // 增加到 20 秒(新窗口需要更多时间) const startTime = Date.now(); const checkCanvas = () => { const elapsed = Date.now() - startTime; if (elapsed > maxWait) { console.log('⏰ Canvas render timeout after', maxWait, 'ms'); resolve(); return; } // 尝试多种可能的选择器 const selectors = [ '.canvas-view-content', '.canvas-wrapper', '.canvas-container', '[data-type="canvas"]', '.workspace-leaf-content .canvas-view' ]; for (const selector of selectors) { const canvas = document.querySelector(selector); if (canvas) { // 检查是否有节点 const nodes = canvas.querySelectorAll('.canvas-node'); if (nodes.length > 0) { console.log(`✅ Canvas rendered using selector: ${selector} (${elapsed}ms) with ${nodes.length} nodes`); // 额外等待确保渲染完全完成 setTimeout(resolve, 1000); return; } } // 继续等待 } setTimeout(checkCanvas, 100); }; console.log('⏳ Starting to wait for canvas render...'); checkCanvas(); }); } // 查找 Canvas DOM 元素 findCanvasDomElement(): Element | null { console.log('🔍 Searching for canvas DOM elements...'); // 尝试多种可能的选择器 const selectors = [ '.canvas-view-content', '.canvas-wrapper', '.canvas-container', '[data-type="canvas"]', '.workspace-leaf-content .canvas-view', '.canvas-view' ]; for (const selector of selectors) { const element = document.querySelector(selector); if (element) { console.log('✅ Found canvas element with selector: "' + selector + '"'); console.log(' Element:', element.tagName, element.className); // @ts-ignore - HTMLElement 类型扩展 console.log(' Size:', element.offsetWidth, 'x', element.offsetHeight); console.log(' Children:', element.children.length); return element; } } console.log('❌ No canvas element found'); return null; } // 从 Canvas JSON 渲染图片 async renderCanvasFromJSON(canvasFile: TFile): Promise { try { console.log('📖 Reading canvas JSON...'); const { vault } = this.app; // 读取 Canvas JSON const content = await vault.read(canvasFile); const canvasData = JSON.parse(content); console.log('📊 Canvas data:', canvasData); // 解析节点和边 const nodes = canvasData.nodes || []; const edges = canvasData.edges || []; console.log(`📦 Found ${nodes.length} nodes and ${edges.length} edges`); if (nodes.length === 0) { console.log('⚠️ No nodes found in canvas'); return null; } // 计算画布边界 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const node of nodes) { const x = node.x || 0; const y = node.y || 0; const width = node.width || 200; const height = node.height || 100; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x + width); maxY = Math.max(maxY, y + height); } // 添加 padding const padding = 50; minX -= padding; minY -= padding; maxX += padding; maxY += padding; const canvasWidth = maxX - minX; const canvasHeight = maxY - minY; console.log(`📐 Canvas size: ${canvasWidth} x ${canvasHeight}`); // 创建 Canvas const canvas = document.createElement('canvas'); canvas.width = canvasWidth; canvas.height = canvasHeight; const ctx = canvas.getContext('2d'); if (!ctx) { console.log('❌ Failed to get canvas context'); return null; } // 填充背景 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); // 绘制边(连接线) console.log('🔗 Drawing edges...'); ctx.strokeStyle = '#999999'; ctx.lineWidth = 2; for (const edge of edges) { const fromNode = nodes.find((n: any) => n.id === edge.fromNode || n.id === edge.from); const toNode = nodes.find((n: any) => n.id === edge.toNode || n.id === edge.to); if (fromNode && toNode) { const fromX = fromNode.x + (fromNode.width || 200) / 2 - minX; const fromY = fromNode.y + (fromNode.height || 100) / 2 - minY; const toX = toNode.x + (toNode.width || 200) / 2 - minX; const toY = toNode.y + (toNode.height || 100) / 2 - minY; ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.stroke(); } } // 绘制节点 console.log('🎨 Drawing nodes...'); for (const node of nodes) { await this.drawCanvasNode(ctx, node, minX, minY); } console.log('💾 Converting to Uint8Array...'); // 转换为 Uint8Array const dataUrl = canvas.toDataURL('image/png'); const base64Data = dataUrl.split(',')[1]; const binaryString = atob(base64Data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } catch (error) { console.error('❌ Error rendering canvas from JSON:', error); return null; } } // 绘制单个 Canvas 节点 async drawCanvasNode(ctx: CanvasRenderingContext2D, node: any, offsetX: number, offsetY: number): Promise { const x = (node.x || 0) - offsetX; const y = (node.y || 0) - offsetY; const width = node.width || 200; const height = node.height || 100; console.log(` Drawing node "${node.id}" at (${x}, ${y})`); // 设置背景颜色 let bgColor = '#f0f0f0'; if (node.color) { const colorMap: Record = { '1': '#ff6b6b', '2': '#4ecdc4', '3': '#45b7d1', '4': '#96ceb4', '5': '#ffeaa7', '6': '#dfe6e9' }; bgColor = colorMap[node.color] || node.color; } // 绘制节点背景 ctx.fillStyle = bgColor; ctx.fillRect(x, y, width, height); // 绘制边框 ctx.strokeStyle = '#cccccc'; ctx.lineWidth = 2; ctx.strokeRect(x, y, width, height); // 绘制文本内容 if (node.type === 'text' && node.text) { ctx.fillStyle = '#333333'; ctx.font = '14px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; // 简单的文本换行处理 const padding = 10; const maxWidth = width - padding * 2; const lineHeight = 20; this.wrapText(ctx, node.text, x + padding, y + padding, maxWidth, lineHeight); } else if (node.type === 'file' && node.file) { ctx.fillStyle = '#333333'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // 显示文件名 const fileName = node.file.split('/').pop(); ctx.fillText(fileName || node.file, x + width / 2, y + height / 2); } else if (node.label) { ctx.fillStyle = '#333333'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.label, x + width / 2, y + height / 2); } } // 文本换行辅助函数 wrapText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number): void { const words = text.split(' '); let line = ''; let currentY = y; for (let i = 0; i < words.length; i++) { const testLine = line + words[i] + ' '; const metrics = ctx.measureText(testLine); const testWidth = metrics.width; if (testWidth > maxWidth && i > 0) { ctx.fillText(line, x, currentY); line = words[i] + ' '; currentY += lineHeight; } else { line = testLine; } } ctx.fillText(line, x, currentY); } // 生成占位图 generatePlaceholderImage(canvasFile: TFile): Uint8Array | null { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return null; // 设置占位图尺寸 canvas.width = 800; canvas.height = 600; // 绘制背景 ctx.fillStyle = '#f0f0f0'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 绘制边框 ctx.strokeStyle = '#ccc'; ctx.lineWidth = 2; ctx.strokeRect(10, 10, canvas.width - 20, canvas.height - 20); // 绘制文字 ctx.fillStyle = '#666'; ctx.font = '24px Arial'; ctx.textAlign = 'center'; ctx.fillText('Canvas 占位图', canvas.width / 2, canvas.height / 2 - 20); ctx.font = '16px Arial'; ctx.fillText(canvasFile.name, canvas.width / 2, canvas.height / 2 + 20); ctx.font = '14px Arial'; ctx.fillText('(需要手动导出或等待原生渲染支持)', canvas.width / 2, canvas.height / 2 + 50); // 转换为 Uint8Array const dataUrl = canvas.toDataURL('image/png'); const base64Data = dataUrl.split(',')[1]; const binaryString = atob(base64Data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } catch (error) { console.error('Error generating placeholder image:', error); return null; } } // 更新内容中的 Canvas 链接 updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string { // 获取源文件的目录路径 const sourceDir = canvasFile.path.substring(0, canvasFile.path.lastIndexOf('/')); if (!sourceDir) return content; // 尝试多种可能的路径格式 const possiblePaths = [ // 完整路径 canvasPath, // 相对于源文件的路径 canvasPath.substring(sourceDir.length + 1), // URL 编码版本 encodeURI(canvasPath), encodeURI(canvasPath.substring(sourceDir.length + 1)), // 文件名(包括和不包括 .canvas 扩展名) canvasFile.name, canvasFile.name.replace('.canvas', ''), encodeURI(canvasFile.name), encodeURI(canvasFile.name.replace('.canvas', '')) ]; console.log('Trying to replace canvas links for paths:', possiblePaths); // 替换 markdown 格式: !(canvas) -> const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; content = content.replace(markdownRegex, (match, alt, path) => { // 检查这个路径是否匹配我们的 canvas 文件 for (const testPath of possiblePaths) { if (path === testPath || path === testPath.replace(/^\//, '')) { const relativePath = this.settings.assetsPath.replace('docs/', ''); console.log(`Found matching canvas link: ${path} -> `); return ``; } } return match; }); // 替换 wikilink 格式: ![[canvas]] -> const wikilinkRegex = /!\[\[([^\]]+)\]\]/g; content = content.replace(wikilinkRegex, (match, link) => { // 移除可能的别名字段 const cleanLink = link.split('|')[0].trim(); for (const testPath of possiblePaths) { if (cleanLink === testPath || cleanLink === testPath.replace(/^\//, '')) { const relativePath = this.settings.assetsPath.replace('docs/', ''); console.log(`Found matching canvas wikilink: ${cleanLink} -> `); return ``; } } return match; }); return content; } } // 设置面板 class VitePressExporterSettingTab extends PluginSettingTab { plugin: VitePressExporterPlugin; constructor(app: App, plugin: VitePressExporterPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'VitePress Exporter Settings' }); new Setting(containerEl) .setName('Docs path') .setDesc('Path to the docs directory (relative to vault root)') .addText(text => text .setPlaceholder('docs') .setValue(this.plugin.settings.docsPath) .onChange(async (value) => { this.plugin.settings.docsPath = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName('Assets path') .setDesc('Path to the assets directory (relative to vault root)') .addText(text => text .setPlaceholder('docs/assets') .setValue(this.plugin.settings.assetsPath) .onChange(async (value) => { this.plugin.settings.assetsPath = value; await this.plugin.saveSettings(); })); } }