- Add Canvas rendering implementation in `notes/render-canvas.md` - Create VitePress exporter plugin in `src/main.ts` with commands for exporting notes - Implement settings management for the plugin in `src/settings.ts` - Add CSS file for potential styling needs - Create version bump script in `version-bump.mjs` for managing versioning - Initialize version tracking in `versions.json`
398 lines
12 KiB
Markdown
398 lines
12 KiB
Markdown
# Canvas 渲染系统实现
|
||
|
||
## 技术栈
|
||
|
||
- **渲染方式**: Obsidian Canvas 内部 API (`takeScreenshot`)
|
||
- **缩放控制**: `canvas.zoom` 属性(对数刻度)
|
||
- **语言**: TypeScript
|
||
|
||
## 核心实现
|
||
|
||
### 1. Canvas 捕获流程
|
||
|
||
```typescript
|
||
async renderCanvas(canvasFile: TFile): Promise<Uint8Array | null> {
|
||
// 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<Uint8Array | null> {
|
||
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 `<Viewer src="${relativePath}/${imageFileName}" />`;
|
||
}
|
||
return match;
|
||
});
|
||
|
||
// 替换 Wikilink 格式
|
||
const wikilinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||
content = content.replace(wikilinkRegex, (match, link) => {
|
||
if (link匹配) {
|
||
const relativePath = this.settings.assetsPath.replace('docs/', '');
|
||
return `<Viewer src="${relativePath}/${imageFileName}" />`;
|
||
}
|
||
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. 更新内容中的链接
|
||
└─> 转换为 <Viewer src="assets/xxx.png" />
|
||
```
|
||
|
||
## 在资源处理中的集成
|
||
|
||
Canvas 渲染是资源处理流程的一部分:
|
||
|
||
```typescript
|
||
async processAssets(content: string, sourceFile: TFile): Promise<string> {
|
||
const cache = metadataCache.getCache(sourceFile.path);
|
||
const canvasFiles = new Set<string>();
|
||
|
||
// 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]] # 相对路径
|
||
 # Markdown 格式
|
||
 # Markdown 相对路径
|
||
![[test.canvas|别名]] # 带别名
|
||
 # URL 编码
|
||
```
|
||
|
||
所有格式都应该被正确识别和转换。
|
||
|
||
## API 参考
|
||
|
||
### Canvas 内部方法
|
||
|
||
| 方法 | 参数 | 返回值 | 说明 |
|
||
|------|------|--------|------|
|
||
| `getViewportBBox()` | - | `{minX, minY, maxX, maxY}` | 获取视口边界框 |
|
||
| `takeScreenshot(path, privacy)` | `path: string`, `privacy: boolean` | `Promise<void>` | 截图并保存到文件 |
|
||
| `requestFrame()` | - | - | 请求重绘 |
|
||
| `zoom` 属性 | - | `number` | 对数刻度缩放(0 = 100%) |
|
||
|
||
### Workspace API
|
||
|
||
| 方法 | 参数 | 返回值 | 说明 |
|
||
|------|------|--------|------|
|
||
| `getLeaf()` | - | `WorkspaceLeaf` | 获取活动 leaf |
|
||
| `openFile(file)` | `TFile` | `Promise<void>` | 打开文件 |
|
||
|
||
### Vault API
|
||
|
||
| 方法 | 参数 | 返回值 | 说明 |
|
||
|------|------|--------|------|
|
||
| `adapter.readBinary(path)` | `string` | `Promise<ArrayBuffer>` | 读取二进制文件 |
|
||
| `adapter.writeBinary(path, data)` | `string, ArrayBuffer` | `Promise<void>` | 写入二进制文件 |
|
||
| `adapter.mkdir(path)` | `string` | `Promise<void>` | 创建目录 |
|
||
| `adapter.remove(path)` | `string` | `Promise<void>` | 删除文件 |
|
||
|
||
### 重要常量
|
||
|
||
- `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 等格式
|
||
- 可配置的图片质量
|
||
- 可配置的分辨率
|