feat: Implement Canvas rendering system and VitePress exporter plugin
- 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:
448
src/main.ts
Normal file
448
src/main.ts
Normal 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 格式:  -> 
|
||||
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 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
52
src/settings.ts
Normal 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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user