From f0ca3776f01f65b6cbe877a2191cd208521a5a46 Mon Sep 17 00:00:00 2001 From: nan Date: Tue, 21 Apr 2026 22:06:37 +0800 Subject: [PATCH] feat: Implement Canvas file export functionality and enhance Markdown export process - Added support for exporting `.canvas` files by rendering them to images and generating corresponding `.canvas.md` files. - Updated the export logic to handle both Markdown and Canvas files, including file type checks and notifications. - Improved resource management by ensuring assets are copied correctly and links are updated in the exported content. - Refactored settings to change default export paths from 'docs' to 'wiki' for better organization. - Created detailed implementation notes and flowcharts to document the export processes and core functionalities. --- manifest.json | 4 +- notes/architecture.md | 424 ---------------------------------------- notes/flowchart.md | 411 -------------------------------------- notes/implementation.md | 375 +++++++++++++++++++++++++++++++++++ notes/render-canvas.md | 397 ------------------------------------- src/main.ts | 70 ++++++- src/settings.ts | 4 +- 7 files changed, 445 insertions(+), 1240 deletions(-) delete mode 100644 notes/architecture.md delete mode 100644 notes/flowchart.md create mode 100644 notes/implementation.md delete mode 100644 notes/render-canvas.md diff --git a/manifest.json b/manifest.json index 57db413..18b397f 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "version": "0.1.0", "minAppVersion": "0.15.0", "description": "Export Obsidian notes to VitePress with asset management", - "author": "Your Name", - "authorUrl": "https://github.com/yourusername", + "author": "nan", + "authorUrl": "https://github.com/icrdr", "isDesktopOnly": false } diff --git a/notes/architecture.md b/notes/architecture.md deleted file mode 100644 index f2d9837..0000000 --- a/notes/architecture.md +++ /dev/null @@ -1,424 +0,0 @@ -# VitePress Exporter 架构设计 - -## 概述 - -VitePress Exporter 是一个 Obsidian 插件,用于将笔记和资源导出到 VitePress 格式。核心功能包括: -- 导出 Markdown 文件到 `docs/` 目录 -- 复制图片等资源到 `docs/assets/` 目录 -- 将 Obsidian Canvas 渲染为图片 - -## 技术栈 - -- **语言**: TypeScript -- **构建工具**: esbuild -- **包管理器**: pnpm -- **Obsidian API**: Plugin, Workspace, TFile, Vault, MetadataCache - -## 核心架构 - -### 1. 导出流程 - -``` -用户触发导出 - ↓ -exportCurrentNote() - ↓ -exportNote(file) - ↓ -processAssets(content, file) - ├─→ 扫描嵌入和链接(使用 MetadataCache) - ├─→ 处理 Canvas 文件 (captureCanvas) - │ ├─→ 打开 Canvas 文件 - │ ├─→ 获取 Canvas 对象 - │ └─→ exportCanvasWithInternalAPI - │ ├─→ 设置 zoom = 0 (100%) - │ ├─→ 调用 takeScreenshot - │ └─→ 保存图片 - └─→ 复制资源文件 - ├─→ 使用 binary 模式读取 - └─→ 写入到 docs/assets/ - ↓ -updateLinksInContent() / updateCanvasLinksInContent() - ↓ -写入 docs/ 目录(使用 binary 模式) -``` - -### 2. 文件结构 - -``` -vitepress-exporter/ -├── src/ -│ ├── main.ts # 主插件源代码 -│ └── settings.ts # 设置相关代码 -├── manifest.json # 插件清单 -├── package.json # 项目配置 -├── esbuild.config.mjs # 构建配置 -├── tsconfig.json # TypeScript 配置 -└── notes/ # 开发文档 - ├── architecture.md # 本文档 - ├── canvas-rendering.md # Canvas 渲染详细说明 - └── flowchart.md # 流程图 -``` - -### 3. 关键类和方法 - -#### VitePressExporterPlugin - -主插件类,继承自 `Plugin`: - -```typescript -class VitePressExporterPlugin extends Plugin { - settings: VitePressExporterSettings; - - // 生命周期 - async onload() // 插件加载,注册命令和菜单 - onunload() // 插件卸载 - - // 设置管理 - async loadSettings() // 加载设置 - async saveSettings() // 保存设置 - - // 导出功能 - async exportCurrentNote() // 导出当前笔记 - async exportNote(file) // 导出单个笔记 - - // 资源处理 - async processAssets(content, file) // 处理资源文件和 Canvas - - // Canvas 渲染 - async captureCanvas(canvasFile) // 捕获 Canvas 为图片 - async exportCanvasWithInternalAPI(canvas, canvasFile) // 使用内部 API - - // 链接更新 - updateLinksInContent(content, sourceFile, assetPath, newFileName) // 更新资源链接 - updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName) // 更新 Canvas 链接 -} -``` - -## Canvas 渲染架构 - -### 核心决策 - -**使用 Obsidian Canvas 内部 API**,而不是自己实现渲染逻辑: - -- ✅ `canvas.takeScreenshot()` - 直接截图 -- ✅ `canvas.getViewportBBox()` - 获取边界框 -- ✅ `canvas.zoom` - 控制缩放(对数刻度) -- ✅ `canvas.requestFrame()` - 触发重绘 - -**为什么不使用第三方库**: -- ❌ `html-to-image` - 需要滑动窗口,代码复杂 -- ❌ `dom-to-image-more` - 维护不活跃 -- ✅ Obsidian 内部 API - 兼容性好,代码简洁 - -### Zoom 对数刻度 - -**关键发现**:`canvas.zoom` 使用对数刻度(base 2) - -```typescript -zoom = 0 → scale = 2^0 = 1.0 (100%) -zoom = 1 → scale = 2^1 = 2.0 (200%) -zoom = -1 → scale = 2^-1 = 0.5 (50%) -``` - -**重要**:设置 `zoom = 0` 获得准确的 100% 缩放,不要设置 `zoom = 1.0`! - -### 文件路径处理 - -`takeScreenshot` 需要绝对文件系统路径: - -```typescript -// 1. 获取 vault 的完整路径 -const vaultBasePath = vault.adapter.basePath; - -// 2. 构建临时文件的绝对路径 -const tempFullPath = `${vaultBasePath}/temp_canvas_${Date.now()}.png`; - -// 3. 调用 takeScreenshot(需要绝对路径) -await canvas.takeScreenshot(tempFullPath, false); - -// 4. 使用相对路径读取(vault API) -const imageBuffer = await vault.adapter.readBinary(tempFileName); - -// 5. 删除临时文件 -await vault.adapter.remove(tempFileName); - -// 6. 写入到正确位置(相对路径) -await vault.adapter.writeBinary(finalPath, imageBuffer); -``` - -## 资源处理 - -### 资源类型 - -1. **嵌入资源** (`![[image.png]]`) - - 图片、PDF 等非 Markdown 文件 - - 自动复制到 `docs/assets/` - - 使用 binary 模式保留原始内容 - -2. **Canvas 文件** (`![[diagram.canvas]]`) - - 使用内部 API 渲染为 PNG 图片 - - 替换为 `` - -3. **普通链接** (`[[other-file]]`) - - Markdown 文件不复制 - - 其他资源文件会被复制 - -### 资源扫描机制 - -使用 Obsidian 的 `MetadataCache` API: - -```typescript -const cache = metadataCache.getCache(sourceFile.path); - -// 嵌入文件 -if (cache.embeds) { - for (const embed of cache.embeds) { - const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path); - // 检查是否是 Canvas 或其他资源 - } -} - -// 普通链接 -if (cache.links) { - for (const link of cache.links) { - const resolvedLink = metadataCache.getFirstLinkpathDest(link.link, sourceFile.path); - // 只复制非 Markdown 文件 - } -} -``` - -### 路径更新 - -资源链接会自动更新为相对于 `docs/` 的路径: - -**Markdown 格式**: -```markdown -之前: ![](images/screenshot.png) -之后: ![](assets/screenshot.png) -``` - -**Wikilink 格式**: -```markdown -之前: ![[images/screenshot.png]] -之后: ![[assets/screenshot.png]] -``` - -**Canvas 特殊处理**: -```markdown -之前: ![[diagram.canvas]] -之后: -``` - -### 路径解析策略 - -支持多种路径格式的智能匹配: - -```typescript -const possiblePaths = [ - assetPath, // 完整路径 - assetPath.substring(sourceDir.length + 1), // 相对路径 - encodeURI(assetPath), // URL 编码 - encodeURI(assetPath.substring(sourceDir.length + 1)), - sourceFile.name, // 文件名 - encodeURI(sourceFile.name) -]; -``` - -## 配置 - -### 插件设置 - -```typescript -interface VitePressExporterSettings { - docsPath: string; // 默认: 'docs' - assetsPath: string; // 默认: 'docs/assets' -} -``` - -### Obsidian 配置 - -插件通过 Obsidian 的设置界面配置: - -1. 设置 → 社区插件 → VitePress Exporter → 选项 -2. 配置 `Docs path` 和 `Assets path` -3. 保存设置 - -## 文件读写 - -### Binary 模式 - -所有文件操作都使用 binary 模式,以保留原始格式: - -```typescript -// 读取文件 -const contentBuffer = await vault.readBinary(file); -const content = new TextDecoder().decode(contentBuffer); - -// 写入文件 -const updatedBuffer = new TextEncoder().encode(updatedContent); -await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer); -``` - -**优势**: -- 保留行尾空格 -- 保留特殊字符 -- 不修改原始格式 - -## 错误处理 - -### Canvas 渲染失败 - -当 Canvas 渲染失败时,会在控制台输出错误信息,但不会中断整个导出流程: - -```typescript -try { - const renderedImage = await this.captureCanvas(canvasFile); - if (renderedImage) { - // 成功处理 - } -} catch (error) { - console.error(`Failed to render canvas ${canvasPath}:`, error); -} -// 继续处理其他文件 -``` - -### 资源复制失败 - -单个资源文件复制失败不会影响其他文件: - -```typescript -try { - // 复制资源 -} catch (error) { - console.error(`Failed to copy asset ${assetPath}:`, error); -} -// 继续处理下一个资源 -``` - -## 性能考虑 - -### 优化策略 - -1. **使用 MetadataCache** - - 快速获取文件的嵌入和链接信息 - - 避免手动解析 Markdown - -2. **批量处理** - - 一次性收集所有需要处理的文件 - - 批量复制和更新 - -3. **异步操作** - - 所有文件操作都是异步的 - - 不阻塞 UI 线程 - -4. **Binary 模式** - - 减少字符串转换开销 - - 直接操作二进制数据 - -### 预期性能 - -- 小笔记(< 10 个资源):< 1 秒 -- 中等笔记(10-50 个资源):< 3 秒 -- 大笔记(> 50 个资源或多个 Canvas):< 10 秒 - -## 触发方式 - -插件提供三种导出触发方式: - -1. **命令面板** - - `Export current note to VitePress` - -2. **文件菜单** - - 右键点击文件 → `Export to VitePress` - -3. **编辑器菜单** - - 右键点击编辑器 → `Export to VitePress` - -## 未来扩展 - -### 可能的功能 - -1. **批量导出** - - 导出整个文件夹 - - 导出所有笔记 - -2. **高级配置** - - 自定义图片格式(PNG/JPEG/WebP) - - 图片质量控制 - - 自定义链接替换规则 - -3. **VitePress 集成** - - 自动生成 frontmatter - - 自动创建索引页面 - - 集成 VitePress 构建流程 - -4. **更多资源类型** - - 支持视频嵌入 - - 支持音频文件 - - 支持其他特殊格式 - -## 维护指南 - -### 修改导出逻辑 - -主要导出逻辑在 `exportNote()` 方法中: - -```typescript -async exportNote(file: TFile) { - // 1. 读取文件(binary 模式) - const contentBuffer = await vault.readBinary(file); - const content = new TextDecoder().decode(contentBuffer); - - // 2. 处理资源 - const updatedContent = await this.processAssets(content, file); - - // 3. 写入文件(binary 模式) - const updatedBuffer = new TextEncoder().encode(updatedContent); - await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer); -} -``` - -### 添加新的资源类型 - -在 `processAssets()` 方法中添加: - -```typescript -if (cache.embeds) { - for (const embed of cache.embeds) { - const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path); - if (resolvedLink) { - if (resolvedLink.extension === 'canvas') { - // Canvas 处理 - } else if (resolvedLink.extension === 'newtype') { - // 处理新类型 - } else { - // 普通资源 - } - } - } -} -``` - -### 调试技巧 - -1. **启用详细日志** - ```typescript - console.log('Processing assets for file:', sourceFile.path); - console.log('Cache:', cache); - console.log('Assets to copy:', Array.from(assetsToCopy)); - ``` - -2. **使用 Obsidian 开发者工具** - - `Ctrl+Shift+I` 打开控制台 - - 查看日志输出 - -3. **测试单个功能** - - 创建最小测试用例 - - 逐步验证每个步骤 - -## 相关文档 - -- [Canvas 渲染详细说明](./canvas-rendering.md) - Canvas 渲染的实现细节 -- [执行流程图](./flowchart.md) - 完整的导出流程图 -- [Obsidian API 文档](https://github.com/obsidianmd/obsidian-api) - 官方 API diff --git a/notes/flowchart.md b/notes/flowchart.md deleted file mode 100644 index 7dffd0c..0000000 --- a/notes/flowchart.md +++ /dev/null @@ -1,411 +0,0 @@ -# VitePress Exporter 执行流程 - -## 插件概述 - -VitePress Exporter 是一个 Obsidian 插件,用于将当前笔记导出到 VitePress 文档系统,包括资源文件管理和 Canvas 渲染功能。 - -## 完整执行流程图 - -```mermaid -flowchart TD - Start([用户触发导出]) --> CheckActiveFile{检查当前文件} - CheckActiveFile -->|没有活动文件| ShowError1[显示错误提示] - CheckActiveFile -->|不是 md 文件| ShowError2[显示错误提示] - CheckActiveFile -->|是 md 文件| ReadFile[读取文件内容
使用 binary 模式] - - ReadFile --> ProcessAssets[处理资源文件
processAssets] - ProcessAssets --> GetCache[获取文件元数据缓存
metadataCache.getCache] - - GetCache --> CheckCache{缓存是否存在} - CheckCache -->|否| ReturnContent[返回原内容] - CheckCache -->|是| InitializeSets[初始化集合
assetsToCopy, canvasFiles] - - InitializeSets --> ScanEmbeds[扫描嵌入文件
cache.embeds] - ScanEmbeds --> CheckEmbed{有嵌入文件?} - CheckEmbed -->|否| ScanLinks - CheckEmbed -->|是| ResolveEmbed[解析嵌入路径
getFirstLinkpathDest] - - ResolveEmbed --> CheckCanvas{是 canvas 文件?} - CheckCanvas -->|是| AddToCanvasSet[添加到 canvasFiles] - CheckCanvas -->|否| AddToAssetsSet[添加到 assetsToCopy] - AddToCanvasSet --> ScanEmbeds - AddToAssetsSet --> ScanEmbeds - - ScanEmbeds --> ScanLinks[扫描链接文件
cache.links] - ScanLinks --> CheckLink{有链接文件?} - CheckLink -->|否| ProcessCanvasFiles - CheckLink -->|是| ResolveLink[解析链接路径
getFirstLinkpathDest] - - ResolveLink --> CheckMD{是 md 文件?} - CheckMD -->|是| SkipLink[跳过] - CheckMD -->|否| AddToAssetsSet - SkipLink --> ScanLinks - AddToAssetsSet --> ScanLinks - - ScanLinks --> ProcessCanvasFiles[处理 Canvas 文件] - ProcessCanvasFiles --> CheckCanvasEmpty{有 Canvas 文件?} - - CheckCanvasEmpty -->|否| ProcessAssetFiles - CheckCanvasEmpty -->|是| GetCanvasFile[获取 Canvas 文件对象] - - GetCanvasFile --> CaptureCanvas[captureCanvas] - CaptureCanvas --> OpenFile[打开 Canvas 文件
activeLeaf.openFile] - - OpenFile --> GetCanvas[获取 Canvas 对象
activeLeaf.view.canvas] - GetCanvas --> CheckCanvasObj{Canvas 对象存在?} - - CheckCanvasObj -->|否| NextCanvas - CheckCanvasObj -->|是| ExportInternalAPI[exportCanvasWithInternalAPI] - - ExportInternalAPI --> GetBBox[获取视口边界框
getViewportBBox] - GetBBox --> SetZoom[设置 zoom = 0
100% 缩放] - - SetZoom --> RequestFrame[请求重绘
requestFrame] - RequestFrame --> WaitRender[等待渲染
100ms + rAF] - - WaitRender --> TakeScreenshot[调用 takeScreenshot
使用绝对路径] - TakeScreenshot --> WaitWrite[等待文件写入
100ms] - - WaitWrite --> ReadTemp[读取临时文件
readBinary] - ReadTemp --> CheckBuffer{读取成功?} - - CheckBuffer -->|否| LogError1[记录错误] - CheckBuffer -->|是| RemoveTemp[删除临时文件] - - RemoveTemp --> WriteFinal[写入最终位置
writeBinary] - WriteFinal --> UpdateCanvasLinks[更新 Canvas 链接
updateCanvasLinksInContent] - - UpdateCanvasLinks --> NextCanvas{还有 Canvas?} - LogError1 --> NextCanvas - NextCanvas -->|是| GetCanvasFile - NextCanvas -->|否| ProcessAssetFiles - - subgraph 资源复制流程 - ProcessAssetFiles[处理资源文件] --> CheckAssets{有资源文件?} - CheckAssets -->|否| WriteDocs - CheckAssets -->|是| GetAssetFile[获取资源文件对象] - - GetAssetFile --> ReadAsset[读取资源文件
readBinary binary 模式] - ReadAsset --> MkdirAssets[创建 assets 目录
mkdir] - - MkdirAssets --> WriteAsset[写入到 docs/assets
writeBinary binary 模式] - WriteAsset --> UpdateLinks[更新资源链接
updateLinksInContent] - - UpdateLinks --> NextAsset{还有资源?} - NextAsset -->|是| GetAssetFile - NextAsset -->|否| WriteDocs - end - - WriteDocs[写入到 docs 目录
writeBinary binary 模式] --> Success[显示成功提示] - Success --> End([结束]) - - ShowError1 --> End - ShowError2 --> End - ReturnContent --> WriteDocs - - style Start fill:#e1f5e1 - style End fill:#ffe1e1 - style Success fill:#e1f5e1 - style ShowError1 fill:#ffe1e1 - style ShowError2 fill:#ffe1e1 - style ProcessCanvasFiles fill:#fff4e1 - style ProcessAssetFiles fill:#e1f0ff - style ExportInternalAPI fill:#ffe1ff -``` - -## 核心功能模块 - -### 1. 导出流程 - -**触发方式**: -- 命令面板:`Export current note to VitePress` -- 文件右键菜单:`Export to VitePress` -- 编辑器右键菜单:`Export to VitePress` - -**文件验证**: -- 检查活动文件是否存在 -- 验证文件扩展名是否为 `md` - -**内容读取**: -- 使用 `vault.readBinary()` 读取文件 -- 保留原始格式(包括行尾空格) - -### 2. 资源处理 - -**扫描机制**: -- 使用 `metadataCache.getCache()` 获取文件元数据 -- 扫描 `cache.embeds` 获取嵌入文件 -- 扫描 `cache.links` 获取链接文件 - -**文件分类**: -- Canvas 文件:扩展名为 `.canvas` -- 普通资源:非 Markdown 文件 -- Markdown 文件:不复制 - -**路径解析**: -- 使用 `metadataCache.getFirstLinkpathDest()` 解析路径 -- 支持相对路径和绝对路径 - -### 3. Canvas 渲染 - -**打开文件**: -- 使用 `workspace.getLeaf()` 获取 leaf -- 调用 `activeLeaf.openFile()` 打开 Canvas -- 从 `activeLeaf.view.canvas` 获取 Canvas 对象 - -**渲染过程**: -- 获取视口边界框 `getViewportBBox()` -- 设置 `zoom = 0`(100% 缩放) -- 请求重绘 `requestFrame()` -- 等待渲染完成(100ms + requestAnimationFrame) -- 调用 `takeScreenshot(绝对路径)` 截图 - -**文件处理**: -- 保存到临时文件(绝对路径) -- 读取临时文件(相对路径) -- 删除临时文件 -- 写入到最终位置 - -### 4. 链接更新 - -**Markdown 格式**: -```markdown -之前: ![](path/to/image.png) -之后: ![](assets/image.png) -``` - -**Wikilink 格式**: -```markdown -之前: ![[path/to/image.png]] -之后: ![[assets/image.png]] -``` - -**Canvas 特殊处理**: -```markdown -之前: ![[diagram.canvas]] -之后: -``` - -**路径匹配**: -- 支持多种格式(绝对、相对、URL 编码) -- 使用正则表达式匹配和替换 -- 转换为相对路径 - -### 5. 文件写入 - -**Binary 模式**: -- 使用 `TextEncoder.encode()` 转换内容 -- 调用 `vault.adapter.writeBinary()` 写入 -- 保留原始格式 - -## 关键方法说明 - -### exportCurrentNote() -导出当前活动笔记 - -```typescript -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; - } - await this.exportNote(activeFile); -} -``` - -### exportNote(file) -执行导出逻辑 - -```typescript -async exportNote(file: TFile) { - // 1. 读取文件(binary 模式) - const contentBuffer = await vault.readBinary(file); - const content = new TextDecoder().decode(contentBuffer); - - // 2. 处理资源 - const updatedContent = await this.processAssets(content, file); - - // 3. 写入文件(binary 模式) - const docsPath = `${this.settings.docsPath}/${file.name}`; - const updatedBuffer = new TextEncoder().encode(updatedContent); - await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer); -} -``` - -### processAssets(content, file) -处理资源文件 - -```typescript -async processAssets(content: string, sourceFile: TFile): Promise { - const cache = metadataCache.getCache(sourceFile.path); - if (!cache) return content; - - const assetsToCopy = new Set(); - const canvasFiles = new Set(); - - // 收集嵌入文件 - if (cache.embeds) { - for (const embed of cache.embeds) { - const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path); - if (resolvedLink) { - 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); - if (resolvedLink && resolvedLink.extension !== 'md') { - assetsToCopy.add(resolvedLink.path); - } - } - } - - // 处理 Canvas 文件 - for (const canvasPath of canvasFiles) { - const renderedImage = await this.captureCanvas(canvasFile); - if (renderedImage) { - // 写入并更新链接 - } - } - - // 复制资源文件 - for (const assetPath of assetsToCopy) { - const assetContent = await vault.readBinary(sourceFile); - await vault.adapter.writeBinary(destPath, assetContent); - content = this.updateLinksInContent(content, ...); - } - - return content; -} -``` - -### captureCanvas(canvasFile) -捕获 Canvas 为图片 - -```typescript -async captureCanvas(canvasFile: TFile): Promise { - const activeLeaf = this.app.workspace.getLeaf(); - await activeLeaf.openFile(canvasFile); - - const canvas = activeLeaf.view.canvas; - if (!canvas) return null; - - return await this.exportCanvasWithInternalAPI(canvas, canvasFile); -} -``` - -## 数据流图 - -``` -用户触发 - ↓ -exportCurrentNote() - ↓ -exportNote(file) - ↓ -readBinary(file) → content - ↓ -processAssets(content, file) - ├─→ metadataCache.getCache() → cache - ├─→ 收集文件 → canvasFiles, assetsToCopy - ├─→ 处理 Canvas - │ ├─→ captureCanvas() - │ ├─→ takeScreenshot() - │ └─→ updateCanvasLinksInContent() - └─→ 复制资源 - ├─→ readBinary() - ├─→ writeBinary() - └─→ updateLinksInContent() - ↓ -updatedContent - ↓ -TextEncoder.encode() → buffer - ↓ -writeBinary(docsPath, buffer) - ↓ -完成 -``` - -## 错误处理 - -### 文件验证失败 -- 没有活动文件 → 显示 Notice -- 文件不是 Markdown → 显示 Notice - -### Canvas 渲染失败 -- 捕获异常并记录日志 -- 继续处理其他文件 -- 不中断整个导出流程 - -### 资源复制失败 -- 捕获异常并记录日志 -- 继续处理下一个资源 -- 不影响其他文件 - -## 性能特点 - -### 优化策略 - -1. **使用 MetadataCache** - - 快速获取文件关系 - - 避免手动解析 - -2. **Binary 模式** - - 减少字符串转换 - - 保留原始格式 - -3. **异步操作** - - 所有文件操作异步 - - 不阻塞 UI - -4. **错误隔离** - - 单个文件失败不影响其他 - - 继续处理后续文件 - -### 时间复杂度 - -- 文件读取:O(n) -- 资源扫描:O(e + l) 其中 e=嵌入数,l=链接数 -- Canvas 渲染:O(c) 其中 c=Canvas 数量 -- 资源复制:O(a) 其中 a=资源数量 - -总体:O(n + e + l + c + a) - -## 配置选项 - -用户可以在插件设置中配置: - -- **docsPath**: 导出文档目录(默认: `docs`) -- **assetsPath**: 资源文件目录(默认: `docs/assets`) - -## 未来改进方向 - -1. **批量导出** - - 导出整个文件夹 - - 导出多个选中的文件 - -2. **进度显示** - - 显示导出进度条 - - 显示当前处理的文件 - -3. **更多配置** - - 自定义图片格式 - - 自定义链接转换规则 - - 排除特定文件类型 - -4. **性能优化** - - 并行处理资源 - - 缓存已渲染的 Canvas - - 增量导出(只处理修改的文件) diff --git a/notes/implementation.md b/notes/implementation.md new file mode 100644 index 0000000..e72c1dd --- /dev/null +++ b/notes/implementation.md @@ -0,0 +1,375 @@ +## 核心功能 + +1. **Markdown 文件导出**:将 `.md` 文件导出到 VitePress 的 docs 目录,自动处理资源文件 +2. **Canvas 文件导出**:将 `.canvas` 文件渲染为图片并生成对应的 `.canvas.md` 文件 +3. **资源文件管理**:自动复制和处理图片、Canvas 等资源文件 +4. **路径规则配置**:支持多种目录结构和命名规则 + +## 主要流程 + +### 整体架构流程 + +```mermaid +graph TB + Start[用户触发导出] --> CheckType{检查文件类型} + CheckType -->|Canvas 文件| CanvasFlow[Canvas 导出流程] + CheckType -->|Markdown 文件| MarkdownFlow[Markdown 导出流程] + + CanvasFlow --> RenderCanvas[渲染 Canvas 为图片] + RenderCanvas --> SaveImage[保存图片到 assetsPath] + SaveImage --> GenerateCanvasMD[生成 .canvas.md 文件] + GenerateCanvasMD --> WriteCanvas[写入文件] + WriteCanvas --> End[完成] + + MarkdownFlow --> ReadContent[读取文件内容] + ReadContent --> ProcessAssets[扫描并处理资源] + ProcessAssets --> CollectLinks[收集链接和嵌入] + CollectLinks --> Separate{分类资源} + + Separate -->|Canvas 文件| RenderCanvas + Separate -->|普通资源| CopyAssets[复制资源到 assetsPath] + + CopyAssets --> UpdateLinks[更新内容中的链接] + RenderCanvas --> UpdateCanvasLinks[更新 Canvas 链接为 Viewer 组件] + + UpdateCanvasLinks --> CalculatePath[计算输出路径] + UpdateLinks --> CalculatePath + + CalculatePath --> ApplyRules{应用路径规则} + ApplyRules --> PreserveFolder[preserveFolderStructure?] + PreserveFolder -->|是| KeepStructure[保留文件夹结构] + PreserveFolder -->|否| Flatten[扁平化] + + KeepStructure --> NestNote{nestNoteFolder?} + Flatten --> NestNote + + NestNote -->|是| CreateIndex[创建 index.md] + NestNote -->|否| UseFilename[使用原文件名] + + CreateIndex --> WriteMD[写入 Markdown 文件] + UseFilename --> WriteMD + + WriteMD --> End +``` + +### 资源处理流程 + +```mermaid +graph TB + Start[processAssets 开始] --> GetCache[获取文件缓存] + GetCache --> CheckCache{缓存存在?} + CheckCache -->|否| ReturnContent[返回原内容] + CheckCache -->|是| CollectStart[收集资源] + + CollectStart --> ScanEmbeds[扫描 embeds] + ScanEmbeds --> CheckEmbed{是 Canvas?} + CheckEmbed -->|是| AddCanvas[添加到 canvasFiles] + CheckEmbed -->|否| AddAsset[添加到 assetsToCopy] + + AddCanvas --> ScanLinks[扫描 links] + AddAsset --> ScanLinks + + ScanLinks --> CheckLink{是本地资源?} + CheckLink -->|是| AddAsset + CheckLink -->|否| ProcessCanvas + + ProcessCanvas[处理 Canvas 文件] --> RenderEach[遍历每个 Canvas] + RenderEach --> CallRender[调用 renderCanvas] + CallRender --> SavePNG[保存为 PNG] + SavePNG --> UpdateCanvasLink[更新为 Viewer 组件] + + UpdateCanvasLink --> CopyAssets[复制普通资源] + CopyAssets --> CopyEach[遍历每个资源] + CopyEach --> ReadBinary[Binary 模式读取] + ReadBinary --> WriteAsset[写入 assetsPath] + WriteAsset --> UpdateAssetLink[更新资源链接] + + UpdateAssetLink --> ReturnContent[返回更新后的内容] +``` + +### Canvas 渲染流程 + +```mermaid +graph TB + Start[renderCanvas 开始] --> OpenFile[打开 Canvas 文件] + OpenFile --> GetCanvas[获取 Canvas 对象] + GetCanvas --> CheckCanvas{Canvas 存在?} + CheckCanvas -->|否| ReturnNull[返回 null] + CheckCanvas -->|是| CalcSystem[计算系统参数] + + CalcSystem --> GetZoom[获取缩放级别] + GetZoom --> CalcPixel[计算设备像素比] + CalcPixel --> CalcScale[计算缩放校正系数] + + CalcScale --> GetNodes[获取所有节点] + GetNodes --> CalcBBox[计算边界框] + CalcBBox --> CalcSize[计算宽度和高度] + + CalcSize --> CalcLimit{计算缩放限制} + CalcLimit --> CheckLimit{超过 16284px?} + CheckLimit -->|是| UseLimit[使用限制值] + CheckLimit -->|否| UseZoom[使用原始缩放] + + UseLimit --> PrepareCanvas[准备 Canvas 环境] + UseZoom --> PrepareCanvas + + PrepareCanvas --> DeselectAll[取消选择] + DeselectAll --> AddScreenshotting[添加截图样式] + AddScreenshotting --> AppendToBody[附加到 body] + + AppendToBody --> SetPosition[设置位置] + SetPosition --> CalcViewport[计算视口] + CalcViewport --> SetCanvasZoom[设置 Canvas 缩放] + SetCanvasZoom --> RequestFrame[请求帧] + + RequestFrame --> WaitRender[等待渲染完成] + WaitRender --> CalcTiles[计算分块数量] + CalcTiles --> CreateFinalCanvas[创建最终 Canvas] + + CreateFinalCanvas --> RenderLoop[渲染循环] + RenderLoop --> SetViewport[设置视口位置] + SetViewport --> RequestTileFrame[请求分块帧] + RequestTileFrame --> WaitTile[等待渲染] + WaitTile --> CapturePage[捕获页面] + CapturePage --> ConvertToPNG[转换为 PNG] + ConvertToPNG --> DrawToFinal[绘制到最终 Canvas] + DrawToFinal --> CheckNext{还有分块?} + CheckNext -->|是| SetViewport + CheckNext -->|否| ConvertFinal[转换最终 Canvas] + + ConvertFinal --> ToBlob[转换为 Blob] + ToBlob --> ToArrayBuffer[转换为 ArrayBuffer] + ToArrayBuffer -> ToUint8Array[转换为 Uint8Array] + ToUint8Array --> ReturnResult[返回结果] + + ReturnResult --> Cleanup[清理环境] + Cleanup --> RestoreStyles[恢复样式] + RestoreStyles --> ResetCanvas[重置 Canvas] + ResetCanvas --> End[结束] + + ReturnNull --> End +``` + +## 核心方法说明 + +### 1. exportCurrentNote() +**位置**:main.ts:75-89 + +**功能**:导出当前活动的文件 +- 获取活动文件 +- 验证文件类型(md 或 canvas) +- 调用 exportNote() + +### 2. exportNote() +**位置**:main.ts:92-135 + +**功能**:导出单个文件的主入口 +- 判断文件类型并分发到不同的处理逻辑 +- Markdown 文件:读取内容 → 处理资源 → 计算路径 → 写入文件 +- Canvas 文件:调用 exportCanvasFile() + +### 3. exportCanvasFile() +**位置**:main.ts:138-192 + +**功能**:专门处理 Canvas 文件导出 +1. 渲染 Canvas 为 PNG 图片 +2. 保存图片到 assetsPath(扁平化) +3. 计算 .canvas.md 文件路径 +4. 生成包含 `` 组件的 Markdown 内容 +5. 写入文件 + +**路径规则示例**: +```typescript +// 保留文件夹 + 嵌套 +notes/测试用.canvas → docs/notes/测试用.canvas/index.md + +// 仅嵌套 +notes/测试用.canvas → docs/测试用.canvas/index.md + +// 保留文件夹 +notes/测试用.canvas → docs/notes/测试用.canvas.md + +// 默认 +notes/测试用.canvas → docs/测试用.canvas.md +``` + +### 4. processAssets() +**位置**:main.ts:195-308 + +**功能**:扫描并处理 Markdown 文件中的资源 + +**处理流程**: +1. 使用 metadataCache 获取文件的 embeds 和 links +2. 分类资源:Canvas 文件 vs 普通资源 +3. 处理 Canvas:渲染为图片并更新链接为 `` 组件 +4. 复制普通资源到 assetsPath +5. 更新内容中的所有资源链接 + +**链接格式支持**: +- Markdown 格式:`![](path)` +- Wikilink 格式:`![[path]]` + +### 5. renderCanvas() +**位置**:main.ts:368-556 + +**功能**:使用 Obsidian 内部 API 将 Canvas 渲染为 PNG 图片 + +**技术要点**: +- 使用 Electron 的 `webContents.capturePage()` API +- 分块渲染:处理大型 Canvas(避免超过 16284px 限制) +- 缩放校正:考虑系统缩放和设备像素比 +- 像素级精确:使用 binary 模式保留原始格式 + +**分块渲染算法**: +```typescript +// 计算分块数量 +const cols = Math.ceil((bbox.maxX - bbox.minX) / tileWidth); +const rows = Math.ceil((bbox.maxY - bbox.minY) / tileHeight); + +// 逐个渲染分块并拼接 +for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + // 设置视口位置 + canvas.x = canvas.tx = startX + col * tileWidth; + canvas.y = canvas.ty = startY + row * tileHeight; + + // 捕获并绘制 + const capturedData = await webContents.capturePage(...); + ctx.drawImage(img, col * O * s, row * F * s); + } +} +``` + +### 6. updateLinksInContent() +**位置**:main.ts:311-364 + +**功能**:更新内容中的资源链接 + +**智能匹配**: +尝试多种可能的路径格式: +- 完整路径:`notes/image.png` +- 相对路径:`image.png` +- URL 编码:`notes%2Fimage.png` +- 文件名:`image.png` + +**统一输出**: +所有资源链接转换为根路径:`/filename.png` + +### 7. updateCanvasLinksInContent() +**位置**:main.ts:593-648 + +**功能**:将 Canvas 链接转换为 `` 组件 + +**转换规则**: +```markdown +![](测试用.canvas) → +![[测试用.canvas]] → +``` + +## 配置选项 + +### settings.ts + +```typescript +interface vitepressExporterSettings { + docsPath: string; // VitePress docs 目录路径,默认 'docs' + assetsPath: string; // 资源目录路径,默认 'docs/public' + nestNoteFolder: boolean; // 是否为每个笔记创建文件夹,默认 true + preserveFolderStructure: boolean; // 是否保留原始文件夹结构,默认 true +} +``` + +### 路径规则矩阵 + +| preserveFolderStructure | nestNoteFolder | 输入 | 输出 | +|---------|---------|------|------| +| true | true | notes/test.md | docs/notes/test/index.md | +| true | false | notes/test.md | docs/notes/test.md | +| false | true | notes/test.md | docs/test/index.md | +| false | false | notes/test.md | docs/test.md | + +## 技术实现要点 + +### 1. Binary 模式文件操作 + +使用 binary 模式保留文件原始格式: +```typescript +// 读取 +const contentBuffer = await vault.readBinary(file); +const content = new TextDecoder().decode(contentBuffer); + +// 写入 +const buffer = new TextEncoder().encode(content); +await vault.adapter.writeBinary(path, buffer.buffer); +``` + +### 2. Obsidian 元数据缓存 + +使用 `metadataCache` 解析链接: +```typescript +const cache = metadataCache.getCache(sourceFile.path); +const resolvedLink = metadataCache.getFirstLinkpathDest(link, sourceFile.path); +``` + +### 3. Electron API 集成 + +使用 Obsidian 内部的 Electron API: +```typescript +const electron = window.electron?.remote; +const webContents = electron?.getCurrentWebContents(); +const capturedData = await webContents.capturePage({ x, y, width, height }); +``` + +### 4. 链接解析兼容性 + +支持多种链接格式: +- 绝对路径和相对路径 +- URL 编码和未编码 +- Markdown 标准格式和 Obsidian Wikilink + +### 5. Canvas 渲染优化 + +- **分块渲染**:避免单个 Canvas 过大 +- **异步处理**:使用 `requestAnimationFrame` 确保渲染完成 +- **缩放校正**:考虑系统 DPI 设置 +- **内存管理**:及时释放临时对象(URL.revokeObjectURL) + +## 文件结构 + +``` +.obsidian/plugins/vitepress-exporter/ +├── src/ +│ ├── main.ts # 主插件逻辑(650 行) +│ └── settings.ts # 设置定义和 UI +├── notes/ # 文档和说明 +├── manifest.json # 插件清单 +└── package.json # 依赖管理 +``` + +## 依赖项 + +- **Obsidian API**:插件框架 +- **Electron**:Canvas 渲染(桌面版) +- **TypeScript**:类型安全 + +## 使用场景 + +1. **从 Obsidian 迁移到 VitePress**:将个人知识库转换为静态网站 +2. **技术文档发布**:在 Obsidian 中编写,发布到 VitePress +3. **Canvas 可视化**:将 Obsidian Canvas 导出为交互式图片 +4. **自动化工作流**:结合 CI/CD 实现自动部署 + +## 局限性 + +1. **平台依赖**:Canvas 渲染仅支持桌面版 Obsidian(需要 Electron) +2. **VitePress 特定**:生成的 `` 组件需要在 VitePress 中配置 +3. **链接转换**:复杂的链接语法可能需要手动调整 +4. **大型 Canvas**:分块渲染可能耗时较长 + +## 未来改进方向 + +1. **增量导出**:只导出修改过的文件 +2. **批量导出**:支持一次性导出整个文件夹 +3. **配置预设**:保存常用的导出配置 +4. **错误恢复**:更完善的错误处理和回滚机制 +5. **性能优化**:并行处理多个文件 diff --git a/notes/render-canvas.md b/notes/render-canvas.md deleted file mode 100644 index c188b09..0000000 --- a/notes/render-canvas.md +++ /dev/null @@ -1,397 +0,0 @@ -# Canvas 渲染系统实现 - -## 技术栈 - -- **渲染方式**: Obsidian Canvas 内部 API (`takeScreenshot`) -- **缩放控制**: `canvas.zoom` 属性(对数刻度) -- **语言**: TypeScript - -## 核心实现 - -### 1. Canvas 捕获流程 - -```typescript -async renderCanvas(canvasFile: TFile): Promise { - // 1. 获取活动 leaf - const activeLeaf = this.app.workspace.getLeaf(); - if (!activeLeaf) return null; - - // 2. 打开 Canvas 文件 - await activeLeaf.openFile(canvasFile); - - // 3. 获取 Canvas 对象 - const canvas = activeLeaf.view.canvas; - if (!canvas) return null; - - // 4. 使用内部 API 导出 - return await this.exportCanvasWithInternalAPI(canvas, canvasFile); -} -``` - -### 2. 使用 Canvas 内部 API - -```typescript -async exportCanvasWithInternalAPI( - canvas: any, - canvasFile: TFile -): Promise { - try { - // 1. 获取边界框 - const bbox = canvas.getViewportBBox(); - const contentWidth = bbox.maxX - bbox.minX; - const contentHeight = bbox.maxY - bbox.minY; - - // 2. 设置 zoom = 0 (100% 缩放) - const oldZoom = canvas.zoom; - canvas.zoom = 0; // 对数刻度:2^0 = 1.0 (100%) - - // 3. 请求重绘 - canvas.requestFrame(); - await new Promise(resolve => setTimeout(resolve, 100)); - await new Promise(resolve => requestAnimationFrame(resolve)); - - // 4. 生成文件路径 - const tempFileName = `temp_canvas_${Date.now()}.png`; - const finalFileName = canvasFile.name.replace(".canvas", ".png"); - const finalPath = `${this.settings.assetsPath}/${finalFileName}`; - - // 5. 获取 vault 绝对路径 - const vaultBasePath = vault.adapter.basePath; - const tempFullPath = `${vaultBasePath}/${tempFileName}`; - - // 6. 调用 takeScreenshot(需要绝对路径) - await canvas.takeScreenshot(tempFullPath, false); - - // 7. 等待文件写入完成 - await new Promise(resolve => setTimeout(resolve, 100)); - - // 8. 读取临时文件(使用相对路径) - const imageBuffer = await vault.adapter.readBinary(tempFileName); - - if (!imageBuffer || imageBuffer.byteLength === 0) { - console.error("Failed to read temporary file"); - return null; - } - - // 9. 删除临时文件 - await vault.adapter.remove(tempFileName); - - // 10. 写入到最终位置 - await vault.adapter.writeBinary(finalPath, imageBuffer); - - // 11. 返回 Uint8Array - return new Uint8Array(imageBuffer); - - } catch (error) { - console.error("exportCanvasWithInternalAPI failed:", error); - return null; - } -} -``` - -### 3. Canvas Zoom 对数刻度 - -**关键发现**:`canvas.zoom` 使用对数刻度(base 2) - -| zoom 值 | 缩放比例 | 说明 | -|---------|----------|------| -| 0 | 100% (2^0) | 原始尺寸,我们要设置的值 | -| 1 | 200% (2^1) | 放大 2 倍 | -| -1 | 50% (2^-1) | 缩小一半 | -| -1.76 | ~30% | Obsidian 默认缩放 | - -**重要**:不要设置 `zoom = 1.0`,这会让 Canvas 放大到 200%!应该设置 `zoom = 0`。 - -### 4. 链接更新 - -Canvas 引用会被转换为 Viewer 组件: - -```typescript -updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string { - // 支持多种路径格式 - const possiblePaths = [ - canvasPath, // 完整路径 - canvasPath.substring(sourceDir.length + 1), // 相对路径 - encodeURI(canvasPath), // URL 编码 - canvasFile.name, // 文件名 - canvasFile.name.replace('.canvas', ''), // 不带扩展名 - ]; - - // 替换 Markdown 格式 - const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; - content = content.replace(markdownRegex, (match, alt, path) => { - if (path匹配) { - const relativePath = this.settings.assetsPath.replace('docs/', ''); - return ``; - } - return match; - }); - - // 替换 Wikilink 格式 - const wikilinkRegex = /!\[\[([^\]]+)\]\]/g; - content = content.replace(wikilinkRegex, (match, link) => { - if (link匹配) { - const relativePath = this.settings.assetsPath.replace('docs/', ''); - return ``; - } - return match; - }); - - return content; -} -``` - -## 渲染流程 - -``` -1. 打开 Canvas 文件 - └─> activeLeaf.openFile(canvasFile) - -2. 获取 Canvas 对象 - └─> activeLeaf.view.canvas - -3. 调用 exportCanvasWithInternalAPI - ├─> 获取视口边界框 - ├─> 设置 zoom = 0 (100%) - ├─> 请求重绘 - ├─> 等待 100ms + requestAnimationFrame - ├─> 调用 takeScreenshot(绝对路径) - ├─> 读取临时文件 - ├─> 删除临时文件 - └─> 写入到 docs/assets/ - -4. 更新内容中的链接 - └─> 转换为 -``` - -## 在资源处理中的集成 - -Canvas 渲染是资源处理流程的一部分: - -```typescript -async processAssets(content: string, sourceFile: TFile): Promise { - const cache = metadataCache.getCache(sourceFile.path); - const canvasFiles = new Set(); - - // 1. 收集 Canvas 文件 - if (cache.embeds) { - for (const embed of cache.embeds) { - const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path); - if (resolvedLink?.extension === 'canvas') { - canvasFiles.add(resolvedLink.path); - } - } - } - - // 2. 渲染每个 Canvas - for (const canvasPath of canvasFiles) { - try { - const canvasFile = vault.getAbstractFileByPath(canvasPath); - if (canvasFile instanceof TFile) { - const renderedImage = await this.captureCanvas(canvasFile); - if (renderedImage) { - const imageFileName = canvasFile.name.replace('.canvas', '.png'); - const imagePath = `${this.settings.assetsPath}/${imageFileName}`; - - // 确保 assets 目录存在 - await vault.adapter.mkdir(this.settings.assetsPath); - - // 写入渲染的图片 - await vault.adapter.writeBinary(imagePath, renderedImage.buffer); - - // 更新内容中的 canvas 引用 - content = this.updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName); - } - } - } catch (error) { - console.error(`Failed to render canvas ${canvasPath}:`, error); - } - } - - return content; -} -``` - -## 优势 - -1. **完全使用 Obsidian 内部 API** - - 不依赖第三方库(html-to-image) - - 兼容性好,Obsidian 升级时应该能正常工作 - - 代码简洁,维护方便 - -2. **正确的缩放控制** - - 使用对数刻度的 `zoom` 属性 - - 设置 `zoom = 0` 获得准确的 100% 缩放 - - 每个元素都是原始尺寸 - -3. **路径处理灵活** - - 支持多种路径格式(绝对、相对、URL 编码) - - 自动转换为正确的相对路径 - - 支持 Markdown 和 Wikilink 两种格式 - -4. **错误隔离** - - 单个 Canvas 渲染失败不影响其他文件 - - 在控制台输出详细错误信息 - - 继续处理后续资源 - -## 测试方法 - -### 1. 基本功能测试 - -创建测试 Canvas 文件: -- 小尺寸: 800x600 -- 大尺寸: 4000x3000 -- 包含多个节点和连接 - -### 2. 验证步骤 - -1. **创建测试笔记**,包含 Canvas 引用 - ```markdown - # 测试笔记 - - 这是一个 Canvas: - ![[test.canvas]] - ``` - -2. **执行导出命令** - -3. **检查控制台输出**: - ``` - Processing assets for file: test.md - Canvas files to render: ['test.canvas'] - Successfully rendered canvas: test.canvas -> docs/assets/test.png - ``` - -4. **检查输出文件**: - - 图片位置:`docs/assets/test.png` - - 图片尺寸应该等于 Canvas 的实际内容尺寸 - - 文字清晰,没有缩水 - - 导出后的笔记内容被更新 - -### 3. 路径格式测试 - -测试各种路径格式的 Canvas 引用: - -```markdown -![[test.canvas]] # 文件名 -![[folder/test.canvas]] # 相对路径 -![](test.canvas) # Markdown 格式 -![](folder/test.canvas) # Markdown 相对路径 -![[test.canvas|别名]] # 带别名 -![](%E6%B5%8B%E8%AF%95.canvas) # URL 编码 -``` - -所有格式都应该被正确识别和转换。 - -## API 参考 - -### Canvas 内部方法 - -| 方法 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `getViewportBBox()` | - | `{minX, minY, maxX, maxY}` | 获取视口边界框 | -| `takeScreenshot(path, privacy)` | `path: string`, `privacy: boolean` | `Promise` | 截图并保存到文件 | -| `requestFrame()` | - | - | 请求重绘 | -| `zoom` 属性 | - | `number` | 对数刻度缩放(0 = 100%) | - -### Workspace API - -| 方法 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `getLeaf()` | - | `WorkspaceLeaf` | 获取活动 leaf | -| `openFile(file)` | `TFile` | `Promise` | 打开文件 | - -### Vault API - -| 方法 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `adapter.readBinary(path)` | `string` | `Promise` | 读取二进制文件 | -| `adapter.writeBinary(path, data)` | `string, ArrayBuffer` | `Promise` | 写入二进制文件 | -| `adapter.mkdir(path)` | `string` | `Promise` | 创建目录 | -| `adapter.remove(path)` | `string` | `Promise` | 删除文件 | - -### 重要常量 - -- `zoom = 0` → 100% 缩放 -- `zoom = 1` → 200% 缩放 -- `zoom = -1` → 50% 缩放 -- `zoom = Math.log2(scale)` → zoom 和缩放的转换公式 - -## 已知问题 - -1. **临时文件处理** - - `takeScreenshot` 需要**绝对文件系统路径** - - 必须先保存到临时文件,然后读取并移动 - - 临时文件会自动清理 - -2. **路径获取** - - 使用 `vault.adapter.basePath` 获取 vault 的完整路径 - - 这是 Obsidian 的内部 API,可能在未来版本中变化 - -3. **同步问题** - - 需要等待 `takeScreenshot` 完成 - - 使用 `setTimeout` 和 `requestAnimationFrame` 确保渲染完成 - - 时间可能需要根据实际情况调整 - -## 故障排除 - -### Canvas 渲染失败 - -**问题**:Canvas 没有被渲染成图片 - -**可能原因**: -1. Canvas 文件路径不正确 -2. Canvas 对象获取失败 -3. `takeScreenshot` 调用失败 -4. 临时文件读写权限问题 - -**解决方法**: -- 检查控制台日志 -- 确认 Canvas 文件存在且可打开 -- 检查文件系统权限 - -### 图片质量差 - -**问题**:渲染的图片模糊或尺寸不正确 - -**可能原因**: -- zoom 值设置不正确 -- Canvas 节点被折叠 - -**解决方法**: -- 确认 `zoom = 0`(100%) -- 检查 Canvas 是否有折叠的节点 -- 增加等待时间确保完全渲染 - -### 链接没有更新 - -**问题**:导出后 Canvas 引用没有被替换 - -**可能原因**: -- 路径格式不支持 -- 正则表达式不匹配 - -**解决方法**: -- 检查链接格式(Markdown vs Wikilink) -- 查看控制台日志确认路径匹配 -- 添加更多路径格式支持 - -## 未来改进 - -1. **进度反馈** - - 添加渲染进度提示 - - 显示 Canvas 尺寸和预计时间 - -2. **错误处理** - - 更详细的错误信息 - - 降级方案(如果 takeScreenshot 不可用) - -3. **性能优化** - - 缓存已渲染的 Canvas - - 避免重复截图 - - 并行处理多个 Canvas - -4. **更多格式** - - 支持 JPEG、WebP 等格式 - - 可配置的图片质量 - - 可配置的分辨率 diff --git a/src/main.ts b/src/main.ts index f7f9fa4..2fd2062 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,7 @@ export default class vitepressExporterPlugin extends Plugin { this.registerEvent( // @ts-ignore this.app.workspace.on("file-menu", (menu: Menu, file: TFile) => { - if (file.extension === "md") { + if (file.extension === "md" || file.extension === "canvas") { menu.addItem((item: MenuItem) => { item @@ -80,8 +80,8 @@ export default class vitepressExporterPlugin extends Plugin { return; } - if (activeFile.extension !== 'md') { - new Notice('Current file is not a Markdown file'); + if (activeFile.extension !== 'md' && activeFile.extension !== 'canvas') { + new Notice('Current file is not a Markdown or canvas file'); return; } @@ -90,6 +90,11 @@ export default class vitepressExporterPlugin extends Plugin { // 导出单个笔记 async exportNote(file: TFile) { + if (file.extension === 'canvas') { + await this.exportCanvasFile(file); + return; + } + const { vault } = this.app; // 使用 binary 模式读取文件以保留原始格式(包括行尾空格) @@ -129,6 +134,63 @@ export default class vitepressExporterPlugin extends Plugin { 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; @@ -188,7 +250,7 @@ export default class vitepressExporterPlugin extends Plugin { if (canvasFile instanceof TFile) { const renderedImage = await this.renderCanvas(canvasFile); if (renderedImage) { - const imageFileName = canvasFile.name.replace('.canvas', '.png'); + const imageFileName = canvasFile.name + '.png'; // 图片保存到 docs/assets/ const imagePath = `${this.settings.assetsPath}/${imageFileName}`; diff --git a/src/settings.ts b/src/settings.ts index 3183b7d..4a0622b 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,8 +9,8 @@ export interface vitepressExporterSettings { } export const DEFAULT_SETTINGS: vitepressExporterSettings = { - docsPath: 'docs', - assetsPath: 'docs/public', + docsPath: 'wiki', + assetsPath: 'wiki/public', nestNoteFolder: true, preserveFolderStructure: true }