feat: Implement Canvas rendering system and VitePress exporter plugin
Some checks failed
Node.js build / build (20.x) (push) Failing after 18m32s
Node.js build / build (22.x) (push) Has been cancelled

- 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`
This commit is contained in:
2026-04-20 22:23:23 +08:00
parent e54a8a3b05
commit 41db190fee
27 changed files with 5613 additions and 4912 deletions

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

28
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Node.js build
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build --if-present
- run: npm run lint

24
.gitignore vendored
View File

@@ -1,6 +1,22 @@
node_modules/
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
main.js.map
*.log
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
versions.json

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
tag-version-prefix=""

View File

@@ -1,134 +0,0 @@
// 尝试捕获 Obsidian 原生 Canvas 渲染(通过临时调整视口大小)
async tryNativeCanvasCapture(canvasFile: TFile): Promise<Uint8Array | null> {
let uiStyleElement: HTMLStyleElement | null = null;
let sizeStyleElement: HTMLStyleElement | null = null;
let activeLeaf: any = null;
let canvasWrapper: HTMLElement | null = null;
let originalScrollLeft = 0;
let originalScrollTop = 0;
try {
console.log('🔍 Step 1: Getting active workspace leaf...');
// 获取当前活动的 leaf(不创建新窗口)
activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf) {
console.log('❌ No active leaf found');
return null;
}
console.log('🔍 Step 2: Opening canvas file...');
await activeLeaf.openFile(canvasFile);
console.log('⏳ Step 3: Waiting for canvas render...');
await this.waitForCanvasRender();
console.log('🔍 Step 4: Finding canvas DOM...');
const canvasDom = this.findCanvasDomElement();
if (!canvasDom) {
console.log('❌ Could not find canvas DOM element');
return null;
}
console.log('✅ Found canvas DOM:', canvasDom.className, 'Size:', (canvasDom as any).offsetWidth, 'x', (canvasDom as any).offsetHeight);
console.log('🎨 Step 5: Hiding Canvas UI elements...');
uiStyleElement = this.hideCanvasUIElements();
console.log('📐 Step 6: Calculating canvas content size...');
const contentSize = this.getCanvasContentSize(canvasDom);
// 获取 canvasWrapper
if ((canvasDom as HTMLElement).classList.contains('canvas-wrapper')) {
canvasWrapper = canvasDom as HTMLElement;
} else {
canvasWrapper = canvasDom.querySelector('.canvas-wrapper') as HTMLElement;
}
if (!canvasWrapper) {
console.log('❌ Could not find canvas wrapper');
return null;
}
// 保存原始滚动位置
originalScrollLeft = canvasWrapper.scrollLeft;
originalScrollTop = canvasWrapper.scrollTop;
console.log(`📏 Original wrapper size: ${(canvasDom as any).offsetWidth}x${(canvasDom as any).offsetHeight}`);
console.log(`📏 Content size: ${contentSize.width}x${contentSize.height}`);
// 临时调整 canvas wrapper 大小以适应所有内容
console.log('🔧 Temporarily resizing canvas wrapper to fit all content...');
sizeStyleElement = document.createElement('style');
sizeStyleElement.id = 'canvas-export-resize';
sizeStyleElement.textContent = `
/* 临时调整 canvas wrapper 大小 */
.canvas-wrapper {
width: ${contentSize.width + 100}px !important;
height: ${contentSize.height + 100}px !important;
max-width: ${contentSize.width + 100}px !important;
max-height: ${contentSize.height + 100}px !important;
overflow: visible !important;
}
/* 确保画布内容区域也扩展 */
.canvas-view-content {
width: ${contentSize.width + 100}px !important;
height: ${contentSize.height + 100}px !important;
}
`;
document.head.appendChild(sizeStyleElement);
// 等待样式应用
await new Promise(resolve => setTimeout(resolve, 100));
// 重置滚动位置
canvasWrapper.scrollLeft = 0;
canvasWrapper.scrollTop = 0;
// 等待布局更新
await new Promise(resolve => setTimeout(resolve, 200));
console.log('📸 Step 7: Capturing canvas...');
// @ts-ignore
const domToImage = require('dom-to-image-more');
const dataUrl = await domToImage.toPng(canvasDom, {
backgroundColor: '#ffffff',
quality: 1,
width: contentSize.width,
height: contentSize.height,
cacheBust: true
});
const imageData = this.dataUrlToUint8Array(dataUrl);
console.log('✅ Capture successful!');
return imageData;
} catch (error) {
console.error('❌ Native canvas capture failed:', error);
return null;
} finally {
// 清理临时调整大小的样式
if (sizeStyleElement && sizeStyleElement.parentNode) {
sizeStyleElement.parentNode.removeChild(sizeStyleElement);
console.log('🧹 Removed temporary resize styles');
}
if (uiStyleElement && uiStyleElement.parentNode) {
uiStyleElement.parentNode.removeChild(uiStyleElement);
console.log('🧹 Cleaned up UI hiding styles');
}
// 恢复滚动位置
if (canvasWrapper) {
canvasWrapper.scrollLeft = originalScrollLeft;
canvasWrapper.scrollTop = originalScrollTop;
console.log('🔄 Restored original scroll position');
}
console.log('🧹 Cleanup complete');
}
}

251
AGENTS.md Normal file
View File

@@ -0,0 +1,251 @@
# Obsidian community plugin
## Project overview
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
## Environment & tooling
- Node.js: use current LTS (Node 18+ recommended).
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
- Types: `obsidian` type definitions.
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
### Install
```bash
npm install
```
### Dev (watch)
```bash
npm run dev
```
### Production build
```bash
npm run build
```
## Linting
- To use eslint install eslint from terminal: `npm install -g eslint`
- To use eslint to analyze this project use this command: `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
## File & folder conventions
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
- **Example file structure**:
```
src/
main.ts # Plugin entry point, lifecycle management
settings.ts # Settings interface and defaults
commands/ # Command implementations
command1.ts
command2.ts
ui/ # UI components, modals, views
modal.ts
view.ts
utils/ # Utility functions, helpers
helpers.ts
constants.ts
types.ts # TypeScript interfaces and types
```
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
## Manifest rules (`manifest.json`)
- Must include (non-exhaustive):
- `id` (plugin ID; for local dev it should match the folder name)
- `name`
- `version` (Semantic Versioning `x.y.z`)
- `minAppVersion`
- `description`
- `isDesktopOnly` (boolean)
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
- Never change `id` after release. Treat it as stable API.
- Keep `minAppVersion` accurate when using newer APIs.
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
## Testing
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
```
<Vault>/.obsidian/plugins/<plugin-id>/
```
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
## Commands & settings
- Any user-facing commands should be added via `this.addCommand(...)`.
- If the plugin has configuration, provide a settings tab and sensible defaults.
- Persist settings using `this.loadData()` / `this.saveData()`.
- Use stable command IDs; avoid renaming once released.
## Versioning & releases
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
## Security, privacy, and compliance
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
- Default to local/offline operation. Only make network requests when essential to the feature.
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
- Clearly disclose any external services used, data sent, and risks.
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
- Avoid deceptive patterns, ads, or spammy notifications.
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
## UX & copy guidelines (for UI text, commands, settings)
- Prefer sentence case for headings, buttons, and titles.
- Use clear, action-oriented imperatives in step-by-step copy.
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
- Use arrow notation for navigation: **Settings → Community plugins**.
- Keep in-app strings short, consistent, and free of jargon.
## Performance
- Keep startup light. Defer heavy work until needed.
- Avoid long-running tasks during `onload`; use lazy initialization.
- Batch disk access and avoid excessive vault scans.
- Debounce/throttle expensive operations in response to file system events.
## Coding conventions
- TypeScript with `"strict": true` preferred.
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
- Bundle everything into `main.js` (no unbundled runtime deps).
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
- Prefer `async/await` over promise chains; handle errors gracefully.
## Mobile
- Where feasible, test on iOS and Android.
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
- Avoid large in-memory structures; be mindful of memory and storage constraints.
## Agent do/don't
**Do**
- Add commands with stable IDs (don't rename once released).
- Provide defaults and validation in settings.
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
- Use `this.register*` helpers for everything that needs cleanup.
**Don't**
- Introduce network calls without an obvious user-facing reason and documentation.
- Ship features that require cloud services without clear disclosure and explicit opt-in.
- Store or transmit vault contents unless essential and consented.
## Common tasks
### Organize code across multiple files
**main.ts** (minimal, lifecycle only):
```ts
import { Plugin } from "obsidian";
import { MySettings, DEFAULT_SETTINGS } from "./settings";
import { registerCommands } from "./commands";
export default class MyPlugin extends Plugin {
settings: MySettings;
async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
registerCommands(this);
}
}
```
**settings.ts**:
```ts
export interface MySettings {
enabled: boolean;
apiKey: string;
}
export const DEFAULT_SETTINGS: MySettings = {
enabled: true,
apiKey: "",
};
```
**commands/index.ts**:
```ts
import { Plugin } from "obsidian";
import { doSomething } from "./my-command";
export function registerCommands(plugin: Plugin) {
plugin.addCommand({
id: "do-something",
name: "Do something",
callback: () => doSomething(plugin),
});
}
```
### Add a command
```ts
this.addCommand({
id: "your-command-id",
name: "Do the thing",
callback: () => this.doTheThing(),
});
```
### Persist settings
```ts
interface MySettings { enabled: boolean }
const DEFAULT_SETTINGS: MySettings = { enabled: true };
async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
await this.saveData(this.settings);
}
```
### Register listeners safely
```ts
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
this.registerDomEvent(window, "resize", () => { /* ... */ });
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
```
## Troubleshooting
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
## References
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
- API documentation: https://docs.obsidian.md
- Developer policies: https://docs.obsidian.md/Developer+policies
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
- Style guide: https://help.obsidian.md/style-guide

5
LICENSE Normal file
View File

@@ -0,0 +1,5 @@
Copyright (C) 2020-2025 by Dynalist Inc.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@@ -1,19 +0,0 @@
import { defaultTheme } from '@vuepress/theme-default';
import type { DefaultThemeOptions } from '@vuepress/theme-default';
import type { PluginOptions } from 'vuepress';
export default {
title: 'VitePress Demo',
description: 'Demo for VitePress with Canvas Viewer',
theme: defaultTheme<DefaultThemeOptions>({
navbar: [
{
text: 'Home',
link: '/',
},
],
}),
clientConfigFile: 'index.ts',
} satisfies PluginOptions;

View File

@@ -1,8 +0,0 @@
import { defineClientConfig } from '@vuepress/client';
import { CanvasViewerPlugin } from './plugins/canvas-viewer';
export default defineClientConfig({
enhance({ app, router }) {
app.use(CanvasViewerPlugin);
}
});

View File

@@ -1,7 +0,0 @@
import type { Plugin } from 'vue';
export const CanvasViewerPlugin: Plugin = {
install(app) {
app.component('CanvasViewer', () => import('./components/CanvasViewer.vue'));
}
};

View File

@@ -1,6 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from 'node:module';
const banner =
`/*
@@ -9,68 +9,41 @@ if you want to view the source, please visit the github repository of this plugi
*/
`;
const prod = (process.argv[2] === 'production');
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtinModules],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
console.log('Building production bundle...');
await esbuild.build({
banner: {
js: banner,
},
entryPoints: ['main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins],
format: 'cjs',
target: 'es2018',
logLevel: "info",
sourcemap: false,
treeShaking: true,
outfile: 'main.js',
});
console.log('Production bundle built successfully!');
await context.rebuild();
process.exit(0);
} else {
const ctx = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ['main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins],
format: 'cjs',
target: 'es2018',
logLevel: "info",
sourcemap: 'inline',
treeShaking: true,
outfile: 'main.js',
});
await ctx.watch();
await context.watch();
}

34
eslint.config.mts Normal file
View File

@@ -0,0 +1,34 @@
import tseslint from 'typescript-eslint';
import obsidianmd from "eslint-plugin-obsidianmd";
import globals from "globals";
import { globalIgnores } from "eslint/config";
export default tseslint.config(
{
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
projectService: {
allowDefaultProject: [
'eslint.config.js',
'manifest.json'
]
},
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json']
},
},
},
...obsidianmd.configs.recommended,
globalIgnores([
"node_modules",
"dist",
"esbuild.config.mjs",
"eslint.config.js",
"version-bump.mjs",
"versions.json",
"main.js",
]),
);

1210
main.ts

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

424
notes/architecture.md Normal file
View File

@@ -0,0 +1,424 @@
# 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
之前: ![](images/screenshot.png)
之后: ![](assets/screenshot.png)
```
**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

411
notes/flowchart.md Normal file
View File

@@ -0,0 +1,411 @@
# 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
之前: ![](path/to/image.png)
之后: ![](assets/image.png)
```
**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
- 增量导出(只处理修改的文件)

397
notes/render-canvas.md Normal file
View File

@@ -0,0 +1,397 @@
# 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]] # 相对路径
![](test.canvas) # Markdown 格式
![](folder/test.canvas) # Markdown 相对路径
![[test.canvas|别名]] # 带别名
![](%E6%B5%8B%E8%AF%95.canvas) # URL 编码
```
所有格式都应该被正确识别和转换。
## API 参考
### Canvas 内部方法
| 方法 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `getViewportBBox()` | - | `{minX, minY, maxX, maxY}` | 获取视口边界框 |
| `takeScreenshot(path, privacy)` | `path: string`, `privacy: boolean` | `Promise<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 等格式
- 可配置的图片质量
- 可配置的分辨率

4171
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,30 +3,27 @@
"version": "0.1.0",
"description": "Export Obsidian notes to VitePress",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"keywords": [
"obsidian",
"vitepress",
"export"
],
"author": "Your Name",
"license": "MIT",
"keywords": [],
"license": "0-BSD",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"dom-to-image-more": "^3.7.2",
"esbuild": "0.17.3",
"obsidian": "latest",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
"@eslint/js": "9.30.1",
"jiti": "2.6.1"
},
"dependencies": {
"dom-to-image-more": "^3.7.2"
"obsidian": "latest"
}
}

1443
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,92 +0,0 @@
// 尝试使用 Puppeteer 截图 Canvas(保留完整样式)
async tryNativeCanvasCapture(canvasFile: TFile): Promise<Uint8Array | null> {
let newWindowLeaf: any = null;
try {
console.log('🔍 Step 1: Opening canvas in new window...');
// 创建新窗口
newWindowLeaf = this.app.workspace.getLeaf('window');
if (!newWindowLeaf) {
console.log('❌ Failed to create new window');
return null;
}
console.log('✅ New window created, opening canvas file...');
await newWindowLeaf.openFile(canvasFile);
console.log('⏳ Step 2: Waiting for canvas render...');
// 等待 Canvas 渲染
await new Promise(resolve => setTimeout(resolve, 3000));
console.log('📸 Step 3: Capturing with Puppeteer...');
// @ts-ignore
const puppeteer = require('puppeteer');
// 启动 Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
// 获取新窗口的 HTML 内容
const container = newWindowLeaf.containerEl;
const canvasWrapper = container.querySelector('.canvas-wrapper');
if (!canvasWrapper) {
console.log('❌ Could not find canvas wrapper');
return null;
}
// 获取完整的 HTML,包括所有样式
const htmlContent = canvasWrapper.outerHTML;
// 设置 HTML 内容
await page.setContent(htmlContent, {
waitUntil: 'networkidle0',
timeout: 10000
});
// 计算内容尺寸
const contentSize = this.getCanvasContentSize(canvasWrapper);
const viewportWidth = Math.ceil(contentSize.width);
const viewportHeight = Math.ceil(contentSize.height);
console.log(`📸 Screenshot size: ${viewportWidth}x${viewportHeight}`);
// 截图
const screenshot = await page.screenshot({
type: 'png',
clip: {
x: contentSize.offsetX,
y: contentSize.offsetY,
width: contentSize.width,
height: contentSize.height
}
});
console.log('✅ Capture successful!');
return new Uint8Array(screenshot);
} finally {
await browser.close();
}
} catch (error) {
console.error('❌ Puppeteer capture failed:', error);
return null;
} finally {
// 关闭新窗口
if (newWindowLeaf) {
try {
newWindowLeaf.detach();
console.log('🧹 Closed new window');
} catch (e) {
console.log('⚠️ Could not close new window:', e);
}
}
}
}

448
src/main.ts Normal file
View File

@@ -0,0 +1,448 @@
import { Plugin, TFile, Notice } from 'obsidian';
import { DEFAULT_SETTINGS, VitePressExporterSettings, VitePressExporterSettingTab } from "./settings";
export default class VitePressExporterPlugin extends Plugin {
settings!: VitePressExporterSettings;
async onload() {
await this.loadSettings();
// 添加导出命令
this.addCommand({
id: 'export-to-vitepress',
name: 'Export current note to VitePress',
callback: () => {
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) => {
item
.setTitle("Export to VitePress")
.setIcon("upload")
.onClick(() => {
this.exportNote(file);
});
});
}
})
);
// Add editor menu item
this.registerEvent(
this.app.workspace.on("editor-menu", (menu: any) => {
menu.addItem((item: any) => {
item
.setTitle("Export to VitePress")
.setIcon("upload")
.onClick(() => {
this.exportCurrentNote();
});
});
})
);
// 添加设置面板
this.addSettingTab(new VitePressExporterSettingTab(this.app, this));
console.log('VitePress Exporter plugin loaded');
}
onunload() {
console.log('VitePress Exporter plugin unloaded');
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
// 导出当前笔记
async exportCurrentNote() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice('No active file found');
return;
}
if (activeFile.extension !== 'md') {
new Notice('Current file is not a markdown file');
return;
}
await this.exportNote(activeFile);
}
// 导出单个笔记
async exportNote(file: TFile) {
const { vault } = this.app;
// 使用 binary 模式读取文件以保留原始格式(包括行尾空格)
const contentBuffer = await vault.readBinary(file);
const content = new TextDecoder().decode(contentBuffer);
// 1. 扫描并复制所有资源文件(传入文件对象用于路径解析)
const updatedContent = await this.processAssets(content, file);
// 2. 处理 canvas 引用(暂时保留,后续实现)
// const finalContent = await this.processCanvases(updatedContent);
// 3. 写入到 docs 目录(使用 binary 模式保留原始格式)
const docsPath = `${this.settings.docsPath}/${file.name}`;
const updatedBuffer = new TextEncoder().encode(updatedContent);
await vault.adapter.writeBinary(docsPath, updatedBuffer.buffer);
console.log(`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;
}
const assetsToCopy = new Set<string>();
const canvasFiles = new Set<string>();
// 收集嵌入文件(图片等)
if (cache.embeds) {
for (const embed of cache.embeds) {
console.log('Processing embed:', embed);
const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
console.log('Resolved link for embed:', resolvedLink?.path);
if (resolvedLink) {
// 检查是否是 canvas 文件
if (resolvedLink.extension === 'canvas') {
canvasFiles.add(resolvedLink.path);
} else {
assetsToCopy.add(resolvedLink.path);
}
}
}
}
// 收集普通链接(可选,如果也想复制链接的文件)
if (cache.links) {
for (const link of cache.links) {
const resolvedLink = metadataCache.getFirstLinkpathDest(link.link, sourceFile.path);
// 只复制本地文件链接,不包括其他 md 文件
if (resolvedLink && resolvedLink.extension !== 'md') {
assetsToCopy.add(resolvedLink.path);
}
}
}
console.log('Assets to copy:', Array.from(assetsToCopy));
console.log('Canvas files to render:', Array.from(canvasFiles));
// 处理 Canvas 文件:渲染为图片
for (const canvasPath of canvasFiles) {
try {
const canvasFile = vault.getAbstractFileByPath(canvasPath);
if (canvasFile instanceof TFile) {
const renderedImage = await this.renderCanvas(canvasFile);
if (renderedImage) {
const imageFileName = canvasFile.name.replace('.canvas', '.png');
// 图片保存到 docs/assets/
const imagePath = `${this.settings.assetsPath}/${imageFileName}`;
// 确保目标目录存在
await vault.adapter.mkdir(this.settings.assetsPath);
// 写入渲染的图片
await vault.adapter.writeBinary(imagePath, renderedImage.buffer);
// 更新内容中的 canvas 引用,使用相对路径 assets/xxx.png
content = this.updateCanvasLinksInContent(content, canvasFile, canvasPath, imageFileName);
console.log(`Successfully rendered canvas: ${canvasPath} -> ${imagePath}`);
}
}
} catch (error) {
console.error(`Failed to render canvas ${canvasPath}:`, error);
}
}
// 复制资源文件到 docs/assets
for (const assetPath of assetsToCopy) {
try {
const sourceFile = vault.getAbstractFileByPath(assetPath);
console.log('Source file for asset:', sourceFile);
if (sourceFile instanceof TFile) {
// 使用 binary 读取模式来保留文件原始内容
const assetContent = await vault.readBinary(sourceFile);
const fileName = sourceFile.name;
const destPath = `${this.settings.assetsPath}/${fileName}`;
console.log('Copying asset:', assetPath, '->', destPath);
// 确保目标目录存在
await vault.adapter.mkdir(this.settings.assetsPath);
// 写入资源文件(使用 binary 模式)
await vault.adapter.writeBinary(destPath, assetContent);
// 更新内容中的资源路径(使用更智能的替换)
content = this.updateLinksInContent(content, sourceFile, assetPath, fileName);
console.log(`Successfully copied asset: ${assetPath} -> ${destPath}`);
}
} catch (error) {
console.error(`Failed to copy asset ${assetPath}:`, error);
}
}
return content;
}
// 更新内容中的链接
updateLinksInContent(content: string, sourceFile: TFile, assetPath: string, newFileName: string): string {
// 获取源文件的目录路径
const sourceDir = sourceFile.path.substring(0, sourceFile.path.lastIndexOf('/'));
if (!sourceDir) return content;
// 尝试多种可能的路径格式
const possiblePaths = [
// 完整路径
assetPath,
// 相对于源文件的路径
assetPath.substring(sourceDir.length + 1),
// URL 编码版本
encodeURI(assetPath),
encodeURI(assetPath.substring(sourceDir.length + 1)),
// 文件名
sourceFile.name,
encodeURI(sourceFile.name)
];
console.log('Trying to replace links for paths:', possiblePaths);
// 替换 markdown 格式: ![](path) -> ![](assets/newfile.png)
const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
content = content.replace(markdownRegex, (match, alt, path) => {
// 检查这个路径是否匹配我们的目标文件
for (const testPath of possiblePaths) {
if (path === testPath || path === testPath.replace(/^\//, '')) {
console.log(`Found matching link: ${path} -> ${this.settings.assetsPath}/${newFileName}`);
return `![](${this.settings.assetsPath}/${newFileName})`;
}
}
return match;
});
// 替换 wikilink 格式: ![[path]] -> ![[assets/newfile.png]]
const wikilinkRegex = /!\[\[([^\]]+)\]\]/g;
content = content.replace(wikilinkRegex, (match, link) => {
// 移除可能的别名字段
const cleanLink = link.split('|')[0].trim();
for (const testPath of possiblePaths) {
if (cleanLink === testPath || cleanLink === testPath.replace(/^\//, '')) {
console.log(`Found matching wikilink: ${cleanLink} -> ${this.settings.assetsPath}/${newFileName}`);
return `![[${this.settings.assetsPath}/${newFileName}]]`;
}
}
return match;
});
return content;
}
// 尝试捕获 Obsidian 原生 Canvas 渲染(通过临时调整视口大小)
async renderCanvas(canvasFile: TFile): 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;
}
// Use Canvas internal API to export
async exportCanvasWithInternalAPI(
canvas: any,
canvasFile: TFile
): Promise<Uint8Array | null> {
try {
console.log("=== Using Canvas Internal API ===");
const { vault } = this.app;
// 1. Get viewport bbox
// @ts-ignore
const bbox = canvas.getViewportBBox();
console.log("Viewport bbox:", bbox);
// 2. Calculate content size
const contentWidth = bbox.maxX - bbox.minX;
const contentHeight = bbox.maxY - bbox.minY;
console.log(`Content size: ${contentWidth}x${contentHeight}`);
// 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;
}
// Delete temp file
await vault.adapter.remove(tempFileName);
console.log("Removed temporary file");
// Write to final location (needs ArrayBuffer, not Uint8Array)
await vault.adapter.writeBinary(finalPath, imageBuffer);
console.log(`Image saved to ${finalPath}`);
// Return Uint8Array
return new Uint8Array(imageBuffer);
} catch (error) {
console.error("exportCanvasWithInternalAPI failed:", error);
return null;
}
}
// 更新内容中的 Canvas 链接
updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string {
// 获取源文件的目录路径
const sourceDir = canvasFile.path.substring(0, canvasFile.path.lastIndexOf('/'));
if (!sourceDir) return content;
// 尝试多种可能的路径格式
const possiblePaths = [
// 完整路径
canvasPath,
// 相对于源文件的路径
canvasPath.substring(sourceDir.length + 1),
// URL 编码版本
encodeURI(canvasPath),
encodeURI(canvasPath.substring(sourceDir.length + 1)),
// 文件名(包括和不包括 .canvas 扩展名)
canvasFile.name,
canvasFile.name.replace('.canvas', ''),
encodeURI(canvasFile.name),
encodeURI(canvasFile.name.replace('.canvas', ''))
];
console.log('Trying to replace canvas links for paths:', possiblePaths);
// 替换 markdown 格式: !(canvas) -> <Viewer src="assets/image.png" />
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}" />`;
}
}
return match;
});
// 替换 wikilink 格式: ![[canvas]] -> <Viewer src="assets/image.png" />
const wikilinkRegex = /!\[\[([^\]]+)\]\]/g;
content = content.replace(wikilinkRegex, (match, link) => {
// 移除可能的别名字段
const cleanLink = link.split('|')[0].trim();
for (const testPath of possiblePaths) {
if (cleanLink === testPath || cleanLink === testPath.replace(/^\//, '')) {
const relativePath = this.settings.assetsPath.replace('docs/', '');
console.log(`Found matching canvas wikilink: ${cleanLink} -> <Viewer src="${relativePath}/${imageFileName}" />`);
return `<Viewer src="${relativePath}/${imageFileName}" />`;
}
}
return match;
});
return content;
}
}

52
src/settings.ts Normal file
View File

@@ -0,0 +1,52 @@
import {App, PluginSettingTab, Setting} from "obsidian";
import VitePressExporterPlugin from "./main";
export interface VitePressExporterSettings {
docsPath: string;
assetsPath: string;
}
export const DEFAULT_SETTINGS: VitePressExporterSettings = {
docsPath: 'docs',
assetsPath: 'docs/assets'
}
// 设置面板
export class VitePressExporterSettingTab extends PluginSettingTab {
plugin: VitePressExporterPlugin;
constructor(app: App, plugin: VitePressExporterPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'VitePress Exporter Settings' });
new Setting(containerEl)
.setName('Docs path')
.setDesc('Path to the docs directory (relative to vault root)')
.addText(text => text
.setPlaceholder('docs')
.setValue(this.plugin.settings.docsPath)
.onChange(async (value) => {
this.plugin.settings.docsPath = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Assets path')
.setDesc('Path to the assets directory (relative to vault root)')
.addText(text => text
.setPlaceholder('docs/assets')
.setValue(this.plugin.settings.assetsPath)
.onChange(async (value) => {
this.plugin.settings.assetsPath = value;
await this.plugin.saveSettings();
}));
}
}

8
styles.css Normal file
View File

@@ -0,0 +1,8 @@
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/

View File

@@ -1,16 +1,22 @@
{
"compilerOptions": {
"baseUrl": ".",
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"importHelpers": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"strictNullChecks": true,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,
"useUnknownInCatchVariables": true,
"lib": [
"DOM",
"ES5",
@@ -19,11 +25,6 @@
]
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules",
"docs",
"**/*.vue"
"src/**/*.ts"
]
}

17
version-bump.mjs Normal file
View File

@@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

3
versions.json Normal file
View File

@@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}