Compare commits
8 Commits
41db190fee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 26eb601d37 | |||
| e99f6b29a1 | |||
| 0e051b7293 | |||
| f0ca3776f0 | |||
| e5e7d724c6 | |||
| a5fceda7d0 | |||
| 113c3b668f | |||
| 3cf2cb9339 |
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -2,9 +2,9 @@ name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
85
README.md
85
README.md
@@ -1,71 +1,96 @@
|
||||
# VitePress Exporter - Obsidian Plugin
|
||||
# Odox - Obsidian Plugin
|
||||
|
||||
## 功能
|
||||
|
||||
这个 Obsidian 插件用于自动化发布 Obsidian 笔记到 VitePress 网站:
|
||||
这个 Obsidian 插件用于自动化发布 Obsidian 笔记到静态文档网站(如 VitePress、Docusaurus 等):
|
||||
|
||||
- ✅ 将当前 markdown 文件导出到 `docs/` 目录
|
||||
- ✅ 自动复制引用的资源文件到 `docs/assets/` 目录
|
||||
- ✅ 更新 markdown 中的资源路径
|
||||
- 🚧 Canvas 文件渲染(待实现)
|
||||
- ✅ 自动复制引用的资源文件到 `docs/public/` 目录
|
||||
- ✅ 更新 markdown 中的资源路径为根路径引用
|
||||
- ✅ Canvas 文件渲染为高清 PNG 图片
|
||||
- ✅ 支持 Canvas 透明背景导出
|
||||
- ✅ 灵活的目录结构和命名规则配置
|
||||
|
||||
## 安装
|
||||
|
||||
插件已经安装在这个项目的 `.obsidian/plugins/vitepress-exporter/` 目录下。
|
||||
插件已经安装在这个项目的 `.obsidian/plugins/odox/` 目录下。
|
||||
|
||||
1. 在 Obsidian 中打开这个项目
|
||||
2. 进入设置 → 第三方插件
|
||||
3. 启用 "VitePress Exporter" 插件
|
||||
3. 启用 "Odox" 插件
|
||||
|
||||
## 使用
|
||||
|
||||
### 方式 1:命令面板
|
||||
1. 打开一个 markdown 文件
|
||||
1. 打开一个 markdown 或 canvas 文件
|
||||
2. 按 `Ctrl+P` (或 `Cmd+P`) 打开命令面板
|
||||
3. 输入 "Export to VitePress"
|
||||
4. 选择 "Export current note to VitePress"
|
||||
3. 输入 "Export to docs"
|
||||
4. 选择 "Export current note to docs"
|
||||
|
||||
### 方式 2:功能区按钮
|
||||
- 点击左侧功能区栏的上传图标按钮
|
||||
### 方式 2:文件菜单
|
||||
- 在文件管理器中右键点击文件
|
||||
- 选择 "Export to docs" 选项
|
||||
|
||||
### 方式 3:快捷键
|
||||
### 方式 3:编辑器菜单
|
||||
- 在编辑器中右键点击
|
||||
- 选择 "Export to docs" 选项
|
||||
|
||||
### 方式 4:快捷键
|
||||
- 可以在 Obsidian 的快捷键设置中为导出命令设置快捷键
|
||||
|
||||
## 配置
|
||||
|
||||
在插件设置中可以配置:
|
||||
|
||||
- **Docs path**: 导出的 markdown 文件存放目录(默认:`docs`)
|
||||
- **Assets path**: 资源文件存放目录(默认:`docs/assets`)
|
||||
- **Wiki path**: 导出的 markdown 文件存放目录(默认:`docs`)
|
||||
- **Assets path**: 资源文件存放目录(默认:`docs/public`)
|
||||
- **Nest note folder**: 是否为每个笔记创建文件夹(默认:开启)
|
||||
- **Preserve folder structure**: 是否保留原始文件夹结构(默认:开启)
|
||||
- **Transparent canvas background**: 导出 Canvas 时使用透明背景(默认:开启)
|
||||
- **Canvas embed template**: Canvas 嵌入模板,使用 `{url}` 作为图片路径占位符
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 用户在 Obsidian 中编写笔记
|
||||
2. 笔记中引用的资源(图片、PDF 等)可以放在任意位置
|
||||
3. 点击导出按钮
|
||||
2. 笔记中引用的资源(图片、Canvas 等)可以放在任意位置
|
||||
3. 触发导出命令
|
||||
4. 插件自动:
|
||||
- 将 markdown 文件复制到 `docs/` 目录
|
||||
- 扫描并复制所有引用的资源到 `docs/assets/` 目录
|
||||
- 更新 markdown 中的资源路径
|
||||
- 扫描并复制所有引用的资源到 `docs/public/` 目录
|
||||
- 将 Canvas 文件渲染为高清 PNG 图片
|
||||
- 更新 markdown 中的资源路径为根路径引用(如 `/image.png`)
|
||||
|
||||
## Canvas 导出
|
||||
|
||||
Canvas 文件会通过以下方式处理:
|
||||
|
||||
1. 使用 Obsidian 内部 API 渲染 Canvas 为 PNG 图片
|
||||
2. 保存图片到 `docs/public/` 目录
|
||||
3. 生成对应的 `.canvas.md` 文件,使用配置的嵌入模板
|
||||
4. 支持透明背景导出(移除 Obsidian 的点状背景)
|
||||
|
||||
## 当前状态
|
||||
|
||||
- ✅ 基础插件框架
|
||||
- ✅ 文件导出功能
|
||||
- ✅ 资源文件自动复制
|
||||
- 🚧 Canvas 文件渲染(开发中)
|
||||
- ✅ Markdown 文件导出功能
|
||||
- ✅ 资源文件自动复制和路径更新
|
||||
- ✅ Canvas 文件渲染为 PNG 图片
|
||||
- ✅ Canvas 透明背景支持
|
||||
- ✅ 分块渲染大型 Canvas(避免尺寸限制)
|
||||
- ✅ 灵活的路径规则配置
|
||||
|
||||
## Canvas 渲染计划
|
||||
## 技术特点
|
||||
|
||||
对于 `.canvas` 文件,计划实现的方案:
|
||||
|
||||
1. **方案 A(优先)**:基于 Obsidian 自己的渲染(探索中)
|
||||
2. **方案 B(备选)**:使用 Headless Browser 手动渲染 JSON Canvas
|
||||
- **Binary 模式文件操作**:保留原始文件格式(包括行尾空格)
|
||||
- **智能链接解析**:支持多种链接格式(Markdown、Wikilink、相对路径、绝对路径)
|
||||
- **分块渲染**:对大型 Canvas 进行分块渲染,避免超过 16284px 的尺寸限制
|
||||
- **缩放校正**:考虑系统 DPI 和缩放设置,确保高清输出
|
||||
- **Electron 集成**:使用 Obsidian 内部的 Electron API 实现 Canvas 截图
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
cd .obsidian/plugins/vitepress-exporter
|
||||
cd .obsidian/plugins/odox
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
@@ -79,6 +104,8 @@ npm run build
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保项目中有 `docs/` 目录
|
||||
- 确保项目中有配置的目标目录
|
||||
- 资源文件会被复制,不会移动原文件
|
||||
- 如果目标文件已存在,会被覆盖
|
||||
- Canvas 渲染仅支持桌面版 Obsidian(需要 Electron)
|
||||
- 生成的 `<Viewer>` 组件需要在静态站点中配置对应组件
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "vitepress-exporter",
|
||||
"name": "VitePress Exporter",
|
||||
"id": "odox",
|
||||
"name": "Odox",
|
||||
"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",
|
||||
"description": "Export Obsidian notes to static docs tools with asset management",
|
||||
"author": "nan",
|
||||
"authorUrl": "https://github.com/icrdr",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
||||
@@ -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 图片
|
||||
- 替换为 `<Viewer src="assets/diagram.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
|
||||
之前: 
|
||||
之后: 
|
||||
```
|
||||
|
||||
**Wikilink 格式**:
|
||||
```markdown
|
||||
之前: ![[images/screenshot.png]]
|
||||
之后: ![[assets/screenshot.png]]
|
||||
```
|
||||
|
||||
**Canvas 特殊处理**:
|
||||
```markdown
|
||||
之前: ![[diagram.canvas]]
|
||||
之后: <Viewer src="assets/diagram.png" />
|
||||
```
|
||||
|
||||
### 路径解析策略
|
||||
|
||||
支持多种路径格式的智能匹配:
|
||||
|
||||
```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
|
||||
@@ -1,411 +0,0 @@
|
||||
# VitePress Exporter 执行流程
|
||||
|
||||
## 插件概述
|
||||
|
||||
VitePress Exporter 是一个 Obsidian 插件,用于将当前笔记导出到 VitePress 文档系统,包括资源文件管理和 Canvas 渲染功能。
|
||||
|
||||
## 完整执行流程图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([用户触发导出]) --> CheckActiveFile{检查当前文件}
|
||||
CheckActiveFile -->|没有活动文件| ShowError1[显示错误提示]
|
||||
CheckActiveFile -->|不是 md 文件| ShowError2[显示错误提示]
|
||||
CheckActiveFile -->|是 md 文件| ReadFile[读取文件内容<br/>使用 binary 模式]
|
||||
|
||||
ReadFile --> ProcessAssets[处理资源文件<br/>processAssets]
|
||||
ProcessAssets --> GetCache[获取文件元数据缓存<br/>metadataCache.getCache]
|
||||
|
||||
GetCache --> CheckCache{缓存是否存在}
|
||||
CheckCache -->|否| ReturnContent[返回原内容]
|
||||
CheckCache -->|是| InitializeSets[初始化集合<br/>assetsToCopy, canvasFiles]
|
||||
|
||||
InitializeSets --> ScanEmbeds[扫描嵌入文件<br/>cache.embeds]
|
||||
ScanEmbeds --> CheckEmbed{有嵌入文件?}
|
||||
CheckEmbed -->|否| ScanLinks
|
||||
CheckEmbed -->|是| ResolveEmbed[解析嵌入路径<br/>getFirstLinkpathDest]
|
||||
|
||||
ResolveEmbed --> CheckCanvas{是 canvas 文件?}
|
||||
CheckCanvas -->|是| AddToCanvasSet[添加到 canvasFiles]
|
||||
CheckCanvas -->|否| AddToAssetsSet[添加到 assetsToCopy]
|
||||
AddToCanvasSet --> ScanEmbeds
|
||||
AddToAssetsSet --> ScanEmbeds
|
||||
|
||||
ScanEmbeds --> ScanLinks[扫描链接文件<br/>cache.links]
|
||||
ScanLinks --> CheckLink{有链接文件?}
|
||||
CheckLink -->|否| ProcessCanvasFiles
|
||||
CheckLink -->|是| ResolveLink[解析链接路径<br/>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 文件<br/>activeLeaf.openFile]
|
||||
|
||||
OpenFile --> GetCanvas[获取 Canvas 对象<br/>activeLeaf.view.canvas]
|
||||
GetCanvas --> CheckCanvasObj{Canvas 对象存在?}
|
||||
|
||||
CheckCanvasObj -->|否| NextCanvas
|
||||
CheckCanvasObj -->|是| ExportInternalAPI[exportCanvasWithInternalAPI]
|
||||
|
||||
ExportInternalAPI --> GetBBox[获取视口边界框<br/>getViewportBBox]
|
||||
GetBBox --> SetZoom[设置 zoom = 0<br/>100% 缩放]
|
||||
|
||||
SetZoom --> RequestFrame[请求重绘<br/>requestFrame]
|
||||
RequestFrame --> WaitRender[等待渲染<br/>100ms + rAF]
|
||||
|
||||
WaitRender --> TakeScreenshot[调用 takeScreenshot<br/>使用绝对路径]
|
||||
TakeScreenshot --> WaitWrite[等待文件写入<br/>100ms]
|
||||
|
||||
WaitWrite --> ReadTemp[读取临时文件<br/>readBinary]
|
||||
ReadTemp --> CheckBuffer{读取成功?}
|
||||
|
||||
CheckBuffer -->|否| LogError1[记录错误]
|
||||
CheckBuffer -->|是| RemoveTemp[删除临时文件]
|
||||
|
||||
RemoveTemp --> WriteFinal[写入最终位置<br/>writeBinary]
|
||||
WriteFinal --> UpdateCanvasLinks[更新 Canvas 链接<br/>updateCanvasLinksInContent]
|
||||
|
||||
UpdateCanvasLinks --> NextCanvas{还有 Canvas?}
|
||||
LogError1 --> NextCanvas
|
||||
NextCanvas -->|是| GetCanvasFile
|
||||
NextCanvas -->|否| ProcessAssetFiles
|
||||
|
||||
subgraph 资源复制流程
|
||||
ProcessAssetFiles[处理资源文件] --> CheckAssets{有资源文件?}
|
||||
CheckAssets -->|否| WriteDocs
|
||||
CheckAssets -->|是| GetAssetFile[获取资源文件对象]
|
||||
|
||||
GetAssetFile --> ReadAsset[读取资源文件<br/>readBinary binary 模式]
|
||||
ReadAsset --> MkdirAssets[创建 assets 目录<br/>mkdir]
|
||||
|
||||
MkdirAssets --> WriteAsset[写入到 docs/assets<br/>writeBinary binary 模式]
|
||||
WriteAsset --> UpdateLinks[更新资源链接<br/>updateLinksInContent]
|
||||
|
||||
UpdateLinks --> NextAsset{还有资源?}
|
||||
NextAsset -->|是| GetAssetFile
|
||||
NextAsset -->|否| WriteDocs
|
||||
end
|
||||
|
||||
WriteDocs[写入到 docs 目录<br/>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
|
||||
之前: 
|
||||
之后: 
|
||||
```
|
||||
|
||||
**Wikilink 格式**:
|
||||
```markdown
|
||||
之前: ![[path/to/image.png]]
|
||||
之后: ![[assets/image.png]]
|
||||
```
|
||||
|
||||
**Canvas 特殊处理**:
|
||||
```markdown
|
||||
之前: ![[diagram.canvas]]
|
||||
之后: <Viewer src="assets/diagram.png" />
|
||||
```
|
||||
|
||||
**路径匹配**:
|
||||
- 支持多种格式(绝对、相对、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<string> {
|
||||
const cache = metadataCache.getCache(sourceFile.path);
|
||||
if (!cache) return content;
|
||||
|
||||
const assetsToCopy = new Set<string>();
|
||||
const canvasFiles = new Set<string>();
|
||||
|
||||
// 收集嵌入文件
|
||||
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<Uint8Array | null> {
|
||||
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
|
||||
- 增量导出(只处理修改的文件)
|
||||
400
notes/implementation.md
Normal file
400
notes/implementation.md
Normal file
@@ -0,0 +1,400 @@
|
||||
## 核心功能
|
||||
|
||||
1. **Markdown 文件导出**:将 `.md` 文件导出到静态文档站点目录,自动处理资源文件
|
||||
2. **Canvas 文件导出**:将 `.canvas` 文件渲染为高清 PNG 图片并生成对应的 `.canvas.md` 文件
|
||||
3. **资源文件管理**:自动复制和处理图片、Canvas 等资源文件到统一目录
|
||||
4. **路径规则配置**:支持多种目录结构和命名规则,灵活配置输出路径
|
||||
5. **透明背景支持**:Canvas 导出时可选择透明背景,移除 Obsidian 的点状背景图案
|
||||
|
||||
## 主要流程
|
||||
|
||||
### 整体架构流程
|
||||
|
||||
```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 --> CheckTransparent{透明背景?}
|
||||
CheckTransparent -->|是| AddTransparentClass[添加透明样式类]
|
||||
CheckTransparent -->|否| HideElements[隐藏页面元素]
|
||||
AddTransparentClass --> HideElements
|
||||
HideElements --> 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 --> CheckTransparentCleanup{透明背景?}
|
||||
CheckTransparentCleanup -->|是| RestoreHiddenElements[恢复隐藏元素]
|
||||
CheckTransparentCleanup -->|否| RestoreStyles[恢复样式]
|
||||
RestoreHiddenElements --> RemoveBodyClasses[移除 body 类]
|
||||
RemoveBodyClasses --> 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:渲染为图片并更新链接为 `<Viewer>` 组件
|
||||
4. 复制普通资源到 assetsPath
|
||||
5. 更新内容中的所有资源链接
|
||||
|
||||
**链接格式支持**:
|
||||
- Markdown 格式:``
|
||||
- Wikilink 格式:`![[path]]`
|
||||
|
||||
### 5. renderCanvas()
|
||||
**位置**:main.ts:368-556
|
||||
|
||||
**功能**:使用 Obsidian 内部 API 将 Canvas 渲染为 PNG 图片
|
||||
|
||||
**技术要点**:
|
||||
- 使用 Electron 的 `webContents.capturePage()` API
|
||||
- 分块渲染:处理大型 Canvas(避免超过 16284px 限制)
|
||||
- 缩放校正:考虑系统缩放和设备像素比
|
||||
- 像素级精确:使用 binary 模式保留原始格式
|
||||
- 透明背景:通过临时 CSS 类和 DOM 操作实现透明背景导出
|
||||
- 智能隐藏:导出时临时隐藏页面其他元素,确保只捕获 Canvas 内容
|
||||
|
||||
**分块渲染算法**:
|
||||
```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 链接转换为 `<Viewer>` 组件
|
||||
|
||||
**转换规则**:
|
||||
```markdown
|
||||
// 默认模板:
|
||||
 → 
|
||||
![[测试用.canvas]] → 
|
||||
|
||||
// 自定义模板:<Viewer src="{url}" />
|
||||
 → <Viewer src="/测试用.canvas.png" />
|
||||
![[测试用.canvas]] → <Viewer src="/测试用.canvas.png" />
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
### settings.ts
|
||||
|
||||
```typescript
|
||||
interface odoxSettings {
|
||||
docsPath: string; // 文档站点目录路径,默认 'docs'
|
||||
assetsPath: string; // 资源目录路径,默认 'docs/public'
|
||||
nestNoteFolder: boolean; // 是否为每个笔记创建文件夹,默认 true
|
||||
preserveFolderStructure: boolean; // 是否保留原始文件夹结构,默认 true
|
||||
transparentCanvas: boolean; // 是否使用透明背景导出 Canvas,默认 true
|
||||
canvasEmbedTemplate: string; // Canvas 嵌入模板,默认 ''
|
||||
}
|
||||
```
|
||||
|
||||
### 路径规则矩阵
|
||||
|
||||
| preserveFolderStructure | nestNoteFolder | Markdown 输入 | Markdown 输出 | Canvas 输入 | Canvas 输出 |
|
||||
|---------|---------|------|------|------|------|
|
||||
| true | true | notes/test.md | docs/notes/test/index.md | notes/test.canvas | docs/notes/test.canvas/index.md |
|
||||
| true | false | notes/test.md | docs/notes/test.md | notes/test.canvas | docs/notes/test.md |
|
||||
| false | true | notes/test.md | docs/test/index.md | notes/test.canvas | docs/test.canvas/index.md |
|
||||
| false | false | notes/test.md | docs/test.md | notes/test.canvas | docs/test.canvas.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 过大,超过 16284px 限制
|
||||
- **异步处理**:使用 `requestAnimationFrame` 确保渲染完成
|
||||
- **缩放校正**:考虑系统 DPI 设置,确保高清输出
|
||||
- **内存管理**:及时释放临时对象(URL.revokeObjectURL)
|
||||
- **透明背景**:通过 CSS 类和 DOM 操作实现透明背景导出
|
||||
- **智能隐藏**:导出时临时隐藏页面其他元素,确保只捕获 Canvas 内容
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
.obsidian/plugins/odox/
|
||||
├── src/
|
||||
│ ├── main.ts # 主插件逻辑(约 680 行)
|
||||
│ └── settings.ts # 设置定义和 UI
|
||||
├── notes/ # 文档和说明
|
||||
│ └── implementation.md # 实现细节文档
|
||||
├── manifest.json # 插件清单
|
||||
├── package.json # 依赖管理
|
||||
└── main.js # 编译后的插件文件
|
||||
```
|
||||
|
||||
## 依赖项
|
||||
|
||||
- **Obsidian API**:插件框架
|
||||
- **Electron**:Canvas 渲染(桌面版)
|
||||
- **TypeScript**:类型安全
|
||||
|
||||
## 使用场景
|
||||
|
||||
1. **从 Obsidian 迁移到静态站点**:将个人知识库转换为 VitePress、Docusaurus 等静态网站
|
||||
2. **技术文档发布**:在 Obsidian 中编写,发布到文档站点
|
||||
3. **Canvas 可视化**:将 Obsidian Canvas 导出为高清 PNG 图片
|
||||
4. **自动化工作流**:结合 CI/CD 实现自动部署文档站点
|
||||
|
||||
## 技术优势
|
||||
|
||||
1. **高质量渲染**:使用 Obsidian 原生渲染引擎,确保导出内容与编辑器中一致
|
||||
2. **智能资源管理**:自动识别、复制和更新资源文件引用
|
||||
3. **灵活配置**:支持多种目录结构和命名规则,适应不同的项目需求
|
||||
4. **透明背景支持**:Canvas 导出时可移除背景图案,适合嵌入各种主题的网站
|
||||
5. **分块渲染技术**:处理大型 Canvas 文件,避免尺寸限制
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. **增量导出**:只导出修改过的文件,提高大项目的导出效率
|
||||
2. **批量导出**:支持一次性导出整个文件夹或整个项目
|
||||
3. **配置预设**:保存常用的导出配置,快速切换不同项目的导出规则
|
||||
4. **错误恢复**:更完善的错误处理和回滚机制,导出失败时恢复原状态
|
||||
5. **性能优化**:并行处理多个文件,利用多核 CPU 加速导出过程
|
||||
6. **进度显示**:为批量导出操作提供进度条和详细的状态信息
|
||||
7. **导出历史**:记录导出历史,支持撤销和回滚操作
|
||||
@@ -1,397 +0,0 @@
|
||||
# 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 等格式
|
||||
- 可配置的图片质量
|
||||
- 可配置的分辨率
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vitepress-exporter",
|
||||
"name": "odox",
|
||||
"version": "0.1.0",
|
||||
"description": "Export Obsidian notes to VitePress",
|
||||
"description": "Export Obsidian notes to static docs tools",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
570
src/main.ts
570
src/main.ts
@@ -1,34 +1,36 @@
|
||||
import { Plugin, TFile, Notice } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, VitePressExporterSettings, VitePressExporterSettingTab } from "./settings";
|
||||
import { Plugin, TFile, Notice, Menu, MenuItem } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, odoxSettings, odoxSettingTab } from "./settings";
|
||||
|
||||
|
||||
|
||||
export default class VitePressExporterPlugin extends Plugin {
|
||||
settings!: VitePressExporterSettings;
|
||||
export default class odoxPlugin extends Plugin {
|
||||
settings!: odoxSettings;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// 添加导出命令
|
||||
this.addCommand({
|
||||
id: 'export-to-vitepress',
|
||||
name: 'Export current note to VitePress',
|
||||
id: 'export-to-docs',
|
||||
name: 'Export current note to docs',
|
||||
callback: () => {
|
||||
this.exportCurrentNote();
|
||||
void this.exportCurrentNote();
|
||||
}
|
||||
});
|
||||
|
||||
// Add file menu item
|
||||
|
||||
this.registerEvent(
|
||||
// @ts-ignore
|
||||
this.app.workspace.on("file-menu", (menu: any, file: TFile) => {
|
||||
if (file.extension === "md") {
|
||||
menu.addItem((item: any) => {
|
||||
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")
|
||||
.setTitle("Export to docs")
|
||||
.setIcon("upload")
|
||||
.onClick(() => {
|
||||
this.exportNote(file);
|
||||
void this.exportNote(file);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -37,29 +39,31 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
|
||||
// Add editor menu item
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("editor-menu", (menu: any) => {
|
||||
menu.addItem((item: any) => {
|
||||
// @ts-ignore
|
||||
this.app.workspace.on("editor-menu", (menu: Menu) => {
|
||||
menu.addItem((item: MenuItem) => {
|
||||
item
|
||||
.setTitle("Export to VitePress")
|
||||
.setTitle("Export to docs")
|
||||
.setIcon("upload")
|
||||
.onClick(() => {
|
||||
this.exportCurrentNote();
|
||||
void this.exportCurrentNote();
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// 添加设置面板
|
||||
this.addSettingTab(new VitePressExporterSettingTab(this.app, this));
|
||||
this.addSettingTab(new odoxSettingTab(this.app, this));
|
||||
|
||||
console.log('VitePress Exporter plugin loaded');
|
||||
console.warn('Odox plugin loaded');
|
||||
}
|
||||
|
||||
onunload() {
|
||||
console.log('VitePress Exporter plugin unloaded');
|
||||
console.warn('Odox plugin unloaded');
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
// eslint-disable-next-line
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
@@ -76,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;
|
||||
}
|
||||
|
||||
@@ -86,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 模式读取文件以保留原始格式(包括行尾空格)
|
||||
@@ -95,32 +104,99 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
// 1. 扫描并复制所有资源文件(传入文件对象用于路径解析)
|
||||
const updatedContent = await this.processAssets(content, file);
|
||||
|
||||
// 2. 处理 canvas 引用(暂时保留,后续实现)
|
||||
// const finalContent = await this.processCanvases(updatedContent);
|
||||
// 2. 计算输出路径
|
||||
let docsPath: string;
|
||||
const fileNameWithoutExt = file.name.replace('.md', '');
|
||||
const filePathWithoutExt = file.path.replace('.md', '');
|
||||
|
||||
// 3. 写入到 docs 目录(使用 binary 模式保留原始格式)
|
||||
const docsPath = `${this.settings.docsPath}/${file.name}`;
|
||||
if (this.settings.preserveFolderStructure && this.settings.nestInFolder) {
|
||||
// 保留文件夹 + 嵌套: 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.nestInFolder) {
|
||||
// 嵌套文件夹: 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.log(`Exported ${file.name} to ${docsPath}`);
|
||||
console.warn(`Exported ${file.name} to ${docsPath}`);
|
||||
}
|
||||
|
||||
// 导出 canvas 文件
|
||||
async exportCanvasFile(file: TFile) {
|
||||
const { vault } = this.app;
|
||||
|
||||
// 1. 渲染 canvas 为图片(传入当前文件以便渲染后返回)
|
||||
const renderedImage = await this.renderCanvas(file, 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);
|
||||
|
||||
|
||||
// 3. 计算 .canvas.md 文件的输出路径(复用现有逻辑)
|
||||
const fileNameWithoutExt = file.name.replace('.canvas', '');
|
||||
const filePathWithoutExt = file.path.replace('.canvas', '');
|
||||
|
||||
let docsPath: string;
|
||||
if (this.settings.preserveFolderStructure && this.settings.nestInFolder) {
|
||||
// 保留文件夹 + 嵌套: 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.nestInFolder) {
|
||||
// 嵌套文件夹: 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 = this.settings.canvasEmbedTemplate.replace('{url}', `/${imageFileName}`);
|
||||
|
||||
// 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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -130,9 +206,7 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
// 收集嵌入文件(图片等)
|
||||
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') {
|
||||
@@ -155,17 +229,16 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Assets to copy:', Array.from(assetsToCopy));
|
||||
console.log('Canvas files to render:', Array.from(canvasFiles));
|
||||
|
||||
// 处理 Canvas 文件:渲染为图片
|
||||
// 处理 Canvas 文件:渲染为图片,并收集映射关系
|
||||
const canvasImageMap = new Map<string, string>();
|
||||
for (const canvasPath of canvasFiles) {
|
||||
try {
|
||||
const canvasFile = vault.getAbstractFileByPath(canvasPath);
|
||||
if (canvasFile instanceof TFile) {
|
||||
const renderedImage = await this.renderCanvas(canvasFile);
|
||||
// 渲染 canvas(传入 sourceFile 以便渲染后返回)
|
||||
const renderedImage = await this.renderCanvas(canvasFile, sourceFile);
|
||||
if (renderedImage) {
|
||||
const imageFileName = canvasFile.name.replace('.canvas', '.png');
|
||||
const imageFileName = canvasFile.name + '.png';
|
||||
// 图片保存到 docs/assets/
|
||||
const imagePath = `${this.settings.assetsPath}/${imageFileName}`;
|
||||
|
||||
@@ -175,10 +248,9 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
// 写入渲染的图片
|
||||
await vault.adapter.writeBinary(imagePath, renderedImage.buffer);
|
||||
|
||||
// 更新内容中的 canvas 引用,使用相对路径 assets/xxx.png
|
||||
content = this.updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName);
|
||||
// 收集 canvas 文件到图片的映射关系
|
||||
canvasImageMap.set(canvasPath, imageFileName);
|
||||
|
||||
console.log(`Successfully rendered canvas: ${canvasPath} -> ${imagePath}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -186,11 +258,14 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 统一更新所有 canvas 引用
|
||||
content = this.updateCanvasInContent(content, canvasImageMap, sourceFile);
|
||||
|
||||
// 复制资源文件到 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 读取模式来保留文件原始内容
|
||||
@@ -198,7 +273,6 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
const fileName = sourceFile.name;
|
||||
const destPath = `${this.settings.assetsPath}/${fileName}`;
|
||||
|
||||
console.log('Copying asset:', assetPath, '->', destPath);
|
||||
|
||||
// 确保目标目录存在
|
||||
await vault.adapter.mkdir(this.settings.assetsPath);
|
||||
@@ -209,7 +283,6 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
// 更新内容中的资源路径(使用更智能的替换)
|
||||
content = this.updateLinksInContent(content, sourceFile, assetPath, fileName);
|
||||
|
||||
console.log(`Successfully copied asset: ${assetPath} -> ${destPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy asset ${assetPath}:`, error);
|
||||
@@ -239,31 +312,31 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
encodeURI(sourceFile.name)
|
||||
];
|
||||
|
||||
console.log('Trying to replace links for paths:', possiblePaths);
|
||||
|
||||
// 替换 markdown 格式:  -> 
|
||||
// 使用根路径: /image.png
|
||||
const assetsRef = `/${newFileName}`;
|
||||
|
||||
// 替换 markdown 格式:  -> 
|
||||
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 ``;
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// 替换 wikilink 格式: ![[path]] -> ![[assets/newfile.png]]
|
||||
const wikilinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||
content = content.replace(wikilinkRegex, (match, link) => {
|
||||
// 替换 docslink 格式: ![[path]] -> ![[assets/newfile.png]]
|
||||
const docslinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||
content = content.replace(docslinkRegex, (match, link: string) => {
|
||||
// 移除可能的别名字段
|
||||
const cleanLink = link.split('|')[0].trim();
|
||||
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 `![[${assetsRef}]]`;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
@@ -272,177 +345,294 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
return content;
|
||||
}
|
||||
|
||||
// 尝试捕获 Obsidian 原生 Canvas 渲染(通过临时调整视口大小)
|
||||
async renderCanvas(canvasFile: TFile): Promise<Uint8Array | null> {
|
||||
// Use Canvas internal API to export (基于 Obsidian export as image 实现)
|
||||
/* eslint-disable */
|
||||
async renderCanvas(
|
||||
canvasFile: TFile,
|
||||
returnToFile: TFile | null = null
|
||||
): Promise<Uint8Array | null> {
|
||||
|
||||
const activeLeaf = this.app.workspace.getLeaf();
|
||||
if (!activeLeaf) {
|
||||
console.log('❌ No active leaf found');
|
||||
return null;
|
||||
}
|
||||
|
||||
await activeLeaf.openFile(canvasFile);
|
||||
|
||||
|
||||
//@ts-ignore
|
||||
const canvas = activeLeaf.view.canvas;
|
||||
if (!canvas) {
|
||||
console.log("Could not get canvas object from view");
|
||||
return null;
|
||||
}
|
||||
// Use Canvas internal API
|
||||
const imageData = await this.exportCanvasWithInternalAPI(canvas, canvasFile);
|
||||
|
||||
if (!imageData) {
|
||||
console.log("Canvas internal API capture failed");
|
||||
return null;
|
||||
}
|
||||
console.log("Canvas internal API capture successful!");
|
||||
return imageData;
|
||||
// 选项设置
|
||||
const zoomScale = 1.0;
|
||||
|
||||
}
|
||||
|
||||
// Use Canvas internal API to export
|
||||
async exportCanvasWithInternalAPI(
|
||||
canvas: any,
|
||||
canvasFile: TFile
|
||||
): Promise<Uint8Array | null> {
|
||||
try {
|
||||
console.log("=== Using Canvas Internal API ===");
|
||||
// 获取系统参数
|
||||
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; // 缩放校正系数
|
||||
|
||||
const { vault } = this.app;
|
||||
// 获取 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; // 高度
|
||||
|
||||
// 1. Get viewport bbox
|
||||
// 计算最大缩放限制(避免超过 16284px)
|
||||
const h = Math.min(2, 16284 / s / c, 16284 / s / u);
|
||||
const d = Math.min(zoomScale, h); // 实际使用的缩放比例
|
||||
|
||||
|
||||
// Full canvas 模式 - 使用分块渲染
|
||||
const e = canvas.wrapperEl;
|
||||
|
||||
// 准备 Canvas 环境
|
||||
this.deselectAll();
|
||||
e.addClass("is-screenshotting");
|
||||
// 如果设置了透明背景,添加自定义 CSS 类
|
||||
if (this.settings.transparentCanvas) {
|
||||
e.addClass("canvas-transparent-export");
|
||||
}
|
||||
// @ts-ignore
|
||||
const bbox = canvas.getViewportBBox();
|
||||
console.log("Viewport bbox:", bbox);
|
||||
canvas.screenshotting = true;
|
||||
|
||||
// 2. Calculate content size
|
||||
const contentWidth = bbox.maxX - bbox.minX;
|
||||
const contentHeight = bbox.maxY - bbox.minY;
|
||||
console.log(`Content size: ${contentWidth}x${contentHeight}`);
|
||||
// 临时隐藏页面的其他元素,以便透明导出
|
||||
const hiddenElements: HTMLElement[] = [];
|
||||
if (this.settings.transparentCanvas) {
|
||||
// 隐藏 body 的所有直接子元素(除了当前 canvas)
|
||||
Array.from(document.body.children).forEach((child) => {
|
||||
if (child !== e && child instanceof HTMLElement) {
|
||||
child.classList.add('odox-hidden');
|
||||
hiddenElements.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Set zoom = 0 (100%)
|
||||
// @ts-ignore
|
||||
const oldZoom = canvas.zoom;
|
||||
// @ts-ignore
|
||||
canvas.zoom = 0;
|
||||
console.log(`Set zoom from ${oldZoom} to 0 (100%)`);
|
||||
|
||||
// 4. Request frame
|
||||
// @ts-ignore
|
||||
canvas.requestFrame();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
|
||||
// Generate temp filename (relative path)
|
||||
const tempFileName = `temp_canvas_${Date.now()}.png`;
|
||||
|
||||
// Ensure assets directory exists
|
||||
await vault.adapter.mkdir(this.settings.assetsPath);
|
||||
|
||||
// Generate final save path (relative path)
|
||||
const finalFileName = canvasFile.name.replace(".canvas", ".png");
|
||||
const finalPath = `${this.settings.assetsPath}/${finalFileName}`;
|
||||
|
||||
// Get vault full path
|
||||
// @ts-ignore
|
||||
const vaultBasePath = vault.adapter.basePath;
|
||||
console.log("Vault base path:", vaultBasePath);
|
||||
|
||||
// Build temp file absolute path
|
||||
const tempFullPath = `${vaultBasePath}/${tempFileName}`;
|
||||
console.log("Temp full path:", tempFullPath);
|
||||
|
||||
// Call takeScreenshot (using absolute path)
|
||||
// @ts-ignore
|
||||
await canvas.takeScreenshot(tempFullPath, false);
|
||||
console.log(`Screenshot saved to ${tempFullPath}`);
|
||||
|
||||
// Wait for file write to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Read temp file (using relative path, returns ArrayBuffer)
|
||||
const imageBuffer = await vault.adapter.readBinary(tempFileName);
|
||||
console.log(`Read imageBuffer, type: ${imageBuffer?.constructor?.name}, byteLength: ${imageBuffer?.byteLength}`);
|
||||
|
||||
// Check if read was successful
|
||||
if (!imageBuffer || imageBuffer.byteLength === 0) {
|
||||
console.error("Failed to read temporary file");
|
||||
return null;
|
||||
// 临时设置 body 样式
|
||||
document.body.classList.add('odox-capturing');
|
||||
}
|
||||
|
||||
// Delete temp file
|
||||
await vault.adapter.remove(tempFileName);
|
||||
console.log("Removed temporary file");
|
||||
document.body.appendChild(e);
|
||||
|
||||
// Write to final location (needs ArrayBuffer, not Uint8Array)
|
||||
await vault.adapter.writeBinary(finalPath, imageBuffer);
|
||||
console.log(`Image saved to ${finalPath}`);
|
||||
try {
|
||||
// 设置 Canvas 位置
|
||||
const x = 5 / o;
|
||||
e.setCssStyles({
|
||||
top: `${x}px`,
|
||||
left: `${x}px`,
|
||||
bottom: `${x}px`,
|
||||
right: `${x}px`,
|
||||
width: "auto",
|
||||
height: "auto"
|
||||
});
|
||||
|
||||
// Return Uint8Array
|
||||
return new Uint8Array(imageBuffer);
|
||||
// 计算视口
|
||||
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;
|
||||
|
||||
|
||||
// 创建最终 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<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = reject;
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
ctx.drawImage(img, col * O * s, row * F * s);
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
|
||||
tileIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 PNG
|
||||
const pngBlob = await new Promise<Blob | null>((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 {
|
||||
// 清理
|
||||
|
||||
// 恢复隐藏的页面元素
|
||||
if (this.settings.transparentCanvas) {
|
||||
document.body.classList.remove('odox-capturing');
|
||||
hiddenElements.forEach((el) => {
|
||||
el.classList.remove('odox-hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.app.workspace.getLeaf().view?.contentEl?.appendChild(e);
|
||||
e.removeClass("is-screenshotting");
|
||||
e.removeClass("canvas-transparent-export");
|
||||
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();
|
||||
|
||||
// 返回到原始文件(如果提供了)
|
||||
if (returnToFile) {
|
||||
await activeLeaf.openFile(returnToFile);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("exportCanvasWithInternalAPI failed:", error);
|
||||
console.error("Canvas render failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/* eslint-disable */
|
||||
|
||||
// 更新内容中的 Canvas 链接
|
||||
updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string {
|
||||
// 获取源文件的目录路径
|
||||
const sourceDir = canvasFile.path.substring(0, canvasFile.path.lastIndexOf('/'));
|
||||
if (!sourceDir) return content;
|
||||
// 计算 Canvas 的边界框
|
||||
calculateBBox(nodes: unknown[]): { minX: number, minY: number, maxX: number, maxY: number } {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
// 尝试多种可能的路径格式
|
||||
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', ''))
|
||||
];
|
||||
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;
|
||||
|
||||
console.log('Trying to replace canvas links for paths:', possiblePaths);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + width);
|
||||
maxY = Math.max(maxY, y + height);
|
||||
}
|
||||
|
||||
// 替换 markdown 格式: !(canvas) -> <Viewer src="assets/image.png" />
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
// 取消选择所有节点
|
||||
deselectAll(): void {
|
||||
// @ts-ignore
|
||||
const canvas = this.app.workspace.getLeaf().view?.canvas;
|
||||
if (canvas) {
|
||||
// @ts-ignore
|
||||
canvas.deselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
// 统一更新所有 Canvas 链接
|
||||
updateCanvasInContent(content: string, canvasImageMap: Map<string, string>, sourceFile: TFile): string {
|
||||
const { metadataCache } = this.app;
|
||||
|
||||
// 使用根路径: /filename.png
|
||||
const assetsRef = (imageFileName: string) => `/${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(/^\//, '')) {
|
||||
const relativePath = this.settings.assetsPath.replace('docs/', '');
|
||||
console.log(`Found matching canvas link: ${path} -> <Viewer src="${relativePath}/${imageFileName}" />`);
|
||||
return `<Viewer src="${relativePath}/${imageFileName}" />`;
|
||||
}
|
||||
content = content.replace(markdownRegex, (match, _alt, path) => {
|
||||
// 使用 Obsidian API 解析链接(正确处理相对路径)
|
||||
const resolvedLink = metadataCache.getFirstLinkpathDest(path, sourceFile.path);
|
||||
if (resolvedLink && canvasImageMap.has(resolvedLink.path)) {
|
||||
const imageFileName = canvasImageMap.get(resolvedLink.path)!;
|
||||
const replacement = this.settings.canvasEmbedTemplate.replace("{url}", assetsRef(imageFileName));
|
||||
return replacement;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// 替换 wikilink 格式: ![[canvas]] -> <Viewer src="assets/image.png" />
|
||||
const wikilinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||
content = content.replace(wikilinkRegex, (match, link) => {
|
||||
// 替换 docslink 格式: ![[canvas]] -> 模板
|
||||
const docslinkRegex = /!\[\[([^\]]+)\]\]/g;
|
||||
content = content.replace(docslinkRegex, (match, link) => {
|
||||
// 移除可能的别名字段
|
||||
const cleanLink = link.split('|')[0].trim();
|
||||
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} -> <Viewer src="${relativePath}/${imageFileName}" />`);
|
||||
return `<Viewer src="${relativePath}/${imageFileName}" />`;
|
||||
}
|
||||
// 使用 Obsidian API 解析链接(正确处理相对路径)
|
||||
const resolvedLink = metadataCache.getFirstLinkpathDest(cleanLink, sourceFile.path);
|
||||
if (resolvedLink && canvasImageMap.has(resolvedLink.path)) {
|
||||
const imageFileName = canvasImageMap.get(resolvedLink.path)!;
|
||||
const replacement = this.settings.canvasEmbedTemplate.replace("{url}", assetsRef(imageFileName));
|
||||
return replacement;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import {App, PluginSettingTab, Setting} from "obsidian";
|
||||
import VitePressExporterPlugin from "./main";
|
||||
import odoxPlugin from "./main";
|
||||
|
||||
export interface VitePressExporterSettings {
|
||||
export interface odoxSettings {
|
||||
docsPath: string;
|
||||
assetsPath: string;
|
||||
nestInFolder: boolean;
|
||||
preserveFolderStructure: boolean;
|
||||
transparentCanvas: boolean;
|
||||
canvasEmbedTemplate: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: VitePressExporterSettings = {
|
||||
export const DEFAULT_SETTINGS: odoxSettings = {
|
||||
docsPath: 'docs',
|
||||
assetsPath: 'docs/assets'
|
||||
assetsPath: 'docs/public',
|
||||
nestInFolder: false,
|
||||
preserveFolderStructure: false,
|
||||
transparentCanvas: true,
|
||||
canvasEmbedTemplate: ''
|
||||
}
|
||||
|
||||
// 设置面板
|
||||
export class VitePressExporterSettingTab extends PluginSettingTab {
|
||||
plugin: VitePressExporterPlugin;
|
||||
export class odoxSettingTab extends PluginSettingTab {
|
||||
plugin: odoxPlugin;
|
||||
|
||||
constructor(app: App, plugin: VitePressExporterPlugin) {
|
||||
constructor(app: App, plugin: odoxPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
@@ -25,12 +33,11 @@ export class VitePressExporterSettingTab extends PluginSettingTab {
|
||||
|
||||
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
|
||||
// eslint-disable-next-line
|
||||
.setPlaceholder('docs')
|
||||
.setValue(this.plugin.settings.docsPath)
|
||||
.onChange(async (value) => {
|
||||
@@ -42,11 +49,53 @@ export class VitePressExporterSettingTab extends PluginSettingTab {
|
||||
.setName('Assets path')
|
||||
.setDesc('Path to the assets directory (relative to vault root)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('docs/assets')
|
||||
// eslint-disable-next-line
|
||||
.setPlaceholder('docs/public')
|
||||
.setValue(this.plugin.settings.assetsPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.assetsPath = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Nest in folder')
|
||||
.setDesc('Create a folder for each note (e.g., notes/some-note.md > docs/some-note/index.md)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.nestInFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.nestInFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Preserve folder structure')
|
||||
.setDesc('Keep original folder structure (e.g., notes/some-note.md > docs/notes/some-note.md)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.preserveFolderStructure)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.preserveFolderStructure = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Transparent canvas background')
|
||||
.setDesc('Export canvas with transparent background (hide dots pattern)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.transparentCanvas)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.transparentCanvas = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Canvas embed template')
|
||||
.setDesc('Template for embedding canvas images. Use {url} as placeholder for the image path. Examples:  or <Viewer src="{url}" />')
|
||||
.addText(text => text
|
||||
.setPlaceholder('')
|
||||
.setValue(this.plugin.settings.canvasEmbedTemplate)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.canvasEmbedTemplate = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
styles.css
22
styles.css
@@ -6,3 +6,25 @@ available in the app when your plugin is enabled.
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
|
||||
/* 透明导出模式:隐藏洞洞板背景 */
|
||||
.canvas-transparent-export .canvas-background {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 透明导出模式:确保 canvas 容器背景透明 */
|
||||
.canvas-transparent-export {
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* 截图时隐藏页面其他元素 */
|
||||
.odox-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 截图时的 body 样式 */
|
||||
.odox-capturing {
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user