import { Plugin, TFile, Notice, Menu, MenuItem } from 'obsidian'; import { DEFAULT_SETTINGS, vitepressExporterSettings, vitepressExporterSettingTab } from "./settings"; 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: () => { void this.exportCurrentNote(); } }); // Add file menu item this.registerEvent( // @ts-ignore this.app.workspace.on("file-menu", (menu: Menu, file: TFile) => { if (file.extension === "md" || file.extension === "canvas") { menu.addItem((item: MenuItem) => { item .setTitle("Export to vitepress") .setIcon("upload") .onClick(() => { void this.exportNote(file); }); }); } }) ); // Add editor menu item this.registerEvent( // @ts-ignore this.app.workspace.on("editor-menu", (menu: Menu) => { menu.addItem((item: MenuItem) => { item .setTitle("Export to vitepress") .setIcon("upload") .onClick(() => { void this.exportCurrentNote(); }); }); }) ); // 添加设置面板 this.addSettingTab(new vitepressExporterSettingTab(this.app, this)); console.warn('vitepress Exporter plugin loaded'); } onunload() { console.warn('vitepress Exporter plugin unloaded'); } async loadSettings() { // eslint-disable-next-line 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' && activeFile.extension !== 'canvas') { new Notice('Current file is not a Markdown or canvas file'); return; } await this.exportNote(activeFile); } // 导出单个笔记 async exportNote(file: TFile) { if (file.extension === 'canvas') { await this.exportCanvasFile(file); return; } 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. 计算输出路径 let docsPath: string; const fileNameWithoutExt = file.name.replace('.md', ''); const filePathWithoutExt = file.path.replace('.md', ''); if (this.settings.preserveFolderStructure && this.settings.nestNoteFolder) { // 保留文件夹 + 嵌套: notes/some-note.md > docs/notes/some-note/index.md docsPath = `${this.settings.docsPath}/${filePathWithoutExt}/index.md`; } else if (this.settings.preserveFolderStructure) { // 保留文件夹结构: notes/some-note.md > docs/notes/some-note.md docsPath = `${this.settings.docsPath}/${file.path}`; } else if (this.settings.nestNoteFolder) { // 嵌套文件夹: notes/some-note.md > docs/some-note/index.md docsPath = `${this.settings.docsPath}/${fileNameWithoutExt}/index.md`; } else { // 默认: notes/some-note.md > docs/some-note.md docsPath = `${this.settings.docsPath}/${file.name}`; } // 3. 确保目标目录存在 const targetDir = docsPath.substring(0, docsPath.lastIndexOf('/')); await vault.adapter.mkdir(targetDir); // 4. 写入到 docs 目录(使用 binary 模式保留原始格式) const updatedBuffer = new TextEncoder().encode(updatedContent); await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer); console.warn(`Exported ${file.name} to ${docsPath}`); } // 导出 canvas 文件 async exportCanvasFile(file: TFile) { const { vault } = this.app; // 1. 渲染 canvas 为图片 const renderedImage = await this.renderCanvas(file); if (!renderedImage) { new Notice(`Failed to render canvas: ${file.name}`); return; } // 2. 保存图片到 assetsPath (扁平化存储) const imageFileName = file.name + '.png'; const imagePath = `${this.settings.assetsPath}/${imageFileName}`; // 确保目标目录存在 await vault.adapter.mkdir(this.settings.assetsPath); // 写入渲染的图片 await vault.adapter.writeBinary(imagePath, renderedImage.buffer); console.debug(`Successfully rendered canvas: ${file.path} -> ${imagePath}`); // 3. 计算 .canvas.md 文件的输出路径(复用现有逻辑) const fileNameWithoutExt = file.name.replace('.canvas', ''); const filePathWithoutExt = file.path.replace('.canvas', ''); let docsPath: string; if (this.settings.preserveFolderStructure && this.settings.nestNoteFolder) { // 保留文件夹 + 嵌套: notes/some-note.canvas > docs/notes/some-note.canvas/index.md docsPath = `${this.settings.docsPath}/${filePathWithoutExt}.canvas/index.md`; } else if (this.settings.preserveFolderStructure) { // 保留文件夹结构: notes/some-note.canvas > docs/notes/some-note.canvas.md docsPath = `${this.settings.docsPath}/${filePathWithoutExt}.md`; } else if (this.settings.nestNoteFolder) { // 嵌套文件夹: notes/some-note.canvas > docs/some-note.canvas/index.md docsPath = `${this.settings.docsPath}/${fileNameWithoutExt}.canvas/index.md`; } else { // 默认: notes/some-note.canvas > docs/some-note.canvas.md docsPath = `${this.settings.docsPath}/${fileNameWithoutExt}.canvas.md`; } // 4. 确保 .canvas.md 目标目录存在 const targetDir = docsPath.substring(0, docsPath.lastIndexOf('/')); await vault.adapter.mkdir(targetDir); // 5. 生成 .canvas.md 内容 const content = ``; // 6. 写入 .canvas.md 文件 const contentBuffer = new TextEncoder().encode(content); await vault.adapter.writeBinary(docsPath, contentBuffer.buffer); console.warn(`Exported ${file.name} to ${docsPath}`); new Notice(`Exported ${file.name} to ${docsPath}`); } // 处理资源文件 async processAssets(content: string, sourceFile: TFile): Promise { const { vault, metadataCache } = this.app; console.debug('Processing assets for file:', sourceFile.path); // 使用 Obsidian API 获取文件的所有嵌入和链接 const cache = metadataCache.getCache(sourceFile.path); console.debug('Cache:', cache); console.debug('Embeds:', cache?.embeds); console.debug('Links:', cache?.links); if (!cache) { console.debug('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.debug('Processing embed:', embed); const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path); console.debug('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.debug('Assets to copy:', Array.from(assetsToCopy)); console.debug('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.renderCanvas(canvasFile); if (renderedImage) { const imageFileName = canvasFile.name + '.png'; // 图片保存到 docs/assets/ const imagePath = `${this.settings.assetsPath}/${imageFileName}`; // 确保目标目录存在 await vault.adapter.mkdir(this.settings.assetsPath); // 写入渲染的图片 await vault.adapter.writeBinary(imagePath, renderedImage.buffer); // 更新内容中的 canvas 引用,使用相对路径 assets/xxx.png content = this.updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName); console.debug(`Successfully rendered canvas: ${canvasPath} -> ${imagePath}`); } } } catch (error) { console.error(`Failed to render canvas ${canvasPath}:`, error); } } const activeLeaf = this.app.workspace.getLeaf(); await activeLeaf.openFile(sourceFile); // 复制资源文件到 docs/assets for (const assetPath of assetsToCopy) { try { const sourceFile = vault.getAbstractFileByPath(assetPath); console.debug('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.debug('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.debug(`Successfully copied asset: ${assetPath} -> ${destPath}`); } } catch (error) { console.error(`Failed to copy asset ${assetPath}:`, error); } } return content; } // 更新内容中的链接 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.debug('Trying to replace links for paths:', possiblePaths); // 使用根路径: /image.png const assetsRef = `/${newFileName}`; // 替换 markdown 格式: ![](path) -> ![](/filename.png) const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; content = content.replace(markdownRegex, (match, alt, path) => { // 检查这个路径是否匹配我们的目标文件 for (const testPath of possiblePaths) { if (path === testPath || path === testPath.replace(/^\//, '')) { console.debug(`Found matching link: ${path} -> ${assetsRef}`); return `![](${assetsRef})`; } } return match; }); // 替换 wikilink 格式: ![[path]] -> ![[assets/newfile.png]] const wikilinkRegex = /!\[\[([^\]]+)\]\]/g; content = content.replace(wikilinkRegex, (match, link: string) => { // 移除可能的别名字段 const cleanLink = link.split('|')[0]!.trim(); for (const testPath of possiblePaths) { if (cleanLink === testPath || cleanLink === testPath.replace(/^\//, '')) { console.debug(`Found matching wikilink: ${cleanLink} -> ${assetsRef}`); return `![[${assetsRef}]]`; } } return match; }); return content; } // Use Canvas internal API to export (基于 Obsidian export as image 实现) /* eslint-disable */ async renderCanvas( canvasFile: TFile ): Promise { const activeLeaf = this.app.workspace.getLeaf(); await activeLeaf.openFile(canvasFile); //@ts-ignore const canvas = activeLeaf.view.canvas; if (!canvas) { console.debug("Could not get canvas object from view"); return null; } // 选项设置(基于用户要求) const zoomScale = 1.0; // 缩放比例 1.0 (100%) const privacyMode = false; // 不启用隐私模式 try { // 获取系统参数 const win = window; //@ts-ignore const electron = win.electron?.remote; //@ts-ignore const webContents = electron?.getCurrentWebContents(); //@ts-ignore const zoomLevel = webContents?.getZoomLevel() || 0; const o = Math.pow(1.2, zoomLevel); // 系统缩放因子 const a = win.devicePixelRatio || 1; // 设备像素比 const s = Math.round(a / o * 1e5) / 1e5; // 缩放校正系数 // 获取 Canvas 边界 //@ts-ignore const nodes = Array.from(canvas.nodes.values()); const bbox = this.calculateBBox(nodes); const c = bbox.maxX - bbox.minX; // 宽度 const u = bbox.maxY - bbox.minY; // 高度 // 计算最大缩放限制(避免超过 16284px) const h = Math.min(2, 16284 / s / c, 16284 / s / u); const d = Math.min(zoomScale, h); // 实际使用的缩放比例 console.debug(`Canvas export: scale=${d}, size=${c}x${u}`); // Full canvas 模式 - 使用分块渲染 const e = canvas.wrapperEl; // 准备 Canvas 环境 this.deselectAll(); e.addClass("is-screenshotting"); if (privacyMode) { e.addClass("is-text-garbled"); } // @ts-ignore canvas.screenshotting = true; document.body.appendChild(e); try { // 设置 Canvas 位置 const x = 5 / o; e.setCssStyles({ top: `${x}px`, left: `${x}px`, bottom: `${x}px`, right: `${x}px`, width: "auto", height: "auto" }); // 计算视口 const P = e.getBoundingClientRect(); const L = Math.ceil(P.x * o + 1); const I = Math.ceil(P.y * o + 1); const O = Math.floor(P.width * o) - 2; const F = Math.floor(P.height * o) - 2; // 设置缩放 // @ts-ignore canvas.zoom = Math.log2(d / o); // @ts-ignore canvas.tZoom = canvas.zoom; // @ts-ignore canvas.viewportChanged = true; canvas.requestFrame(); // 等待渲染 await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => requestAnimationFrame(resolve)); // 计算分块数量 const tileWidth = O / d; const tileHeight = F / d; const cols = Math.ceil((bbox.maxX - bbox.minX) / tileWidth); const rows = Math.ceil((bbox.maxY - bbox.minY) / tileHeight); const startX = bbox.minX + tileWidth / 2; const startY = bbox.minY + tileHeight / 2; console.debug(`Rendering ${cols}x${rows} tiles (${cols * rows} total)`); // 创建最终 Canvas const finalCanvas = document.createElement("canvas"); finalCanvas.width = Math.ceil(c * d * s); finalCanvas.height = Math.ceil(u * d * s); const ctx = finalCanvas.getContext("2d"); if (!ctx) { throw new Error("Failed to get 2d context"); } let tileIndex = 0; const totalTiles = cols * rows; // 渲染每个分块 for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { // 设置视口位置 // @ts-ignore canvas.x = canvas.tx = startX + col * tileWidth; // @ts-ignore canvas.y = canvas.ty = startY + row * tileHeight; // @ts-ignore canvas.viewportChanged = true; canvas.requestFrame(); await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => requestAnimationFrame(resolve)); // 捕获当前视口 const capturedData = await webContents.capturePage({ x: L, y: I, width: O, height: F }); // 转换为图片并绘制到最终 Canvas const pngData = capturedData.toPNG(); const blob = new Blob([pngData], { type: "image/png" }); const imageUrl = URL.createObjectURL(blob); const img = document.createElement("img"); await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = reject; img.src = imageUrl; }); ctx.drawImage(img, col * O * s, row * F * s); URL.revokeObjectURL(imageUrl); tileIndex++; console.debug(`Rendered tile ${tileIndex}/${totalTiles}`); } } // 转换为 PNG const pngBlob = await new Promise((resolve) => finalCanvas.toBlob(resolve, "image/png")); if (!pngBlob) { throw new Error("Failed to create PNG blob"); } const arrayBuffer = await pngBlob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); return uint8Array; } finally { // 清理 // @ts-ignore this.app.workspace.getLeaf().view?.contentEl?.appendChild(e); e.removeClass("is-screenshotting"); e.removeClass("is-text-garbled"); e.setCssStyles({ top: "", left: "", bottom: "", right: "", width: "", height: "" }); // @ts-ignore canvas.screenshotting = false; await new Promise(resolve => requestAnimationFrame(resolve)); // @ts-ignore canvas.zoomToFitQueued = true; // @ts-ignore canvas.onResize(); } } catch (error) { console.error("Canvas render failed:", error); return null; } } /* eslint-disable */ // 计算 Canvas 的边界框 calculateBBox(nodes: unknown[]): { minX: number, minY: number, maxX: number, maxY: number } { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const node of nodes) { // @ts-ignore const x = node.x; // @ts-ignore const y = node.y; // @ts-ignore const width = node.width; // @ts-ignore const height = node.height; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x + width); maxY = Math.max(maxY, y + height); } return { minX, minY, maxX, maxY }; } // 取消选择所有节点 deselectAll(): void { // @ts-ignore const canvas = this.app.workspace.getLeaf().view?.canvas; if (canvas) { // @ts-ignore canvas.deselectAll(); } } // 更新内容中的 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.debug('Trying to replace canvas links for paths:', possiblePaths); // 使用根路径: /image.png const assetsRef = `/${imageFileName}`; // 替换 markdown 格式: !(canvas) -> const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; content = content.replace(markdownRegex, (match, _alt, path) => { // 检查这个路径是否匹配我们的 canvas 文件 for (const testPath of possiblePaths) { if (path === testPath || path === testPath.replace(/^\//, '')) { console.debug(`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(/^\//, '')) { console.debug(`Found matching canvas wikilink: ${cleanLink} -> `); return ``; } } return match; }); return content; } }