Files
odox/src/main.ts
nan f0ca3776f0
All checks were successful
Node.js build / build (20.x) (push) Successful in 21s
Node.js build / build (22.x) (push) Successful in 20s
feat: Implement Canvas file export functionality and enhance Markdown export process
- Added support for exporting `.canvas` files by rendering them to images and generating corresponding `.canvas.md` files.
- Updated the export logic to handle both Markdown and Canvas files, including file type checks and notifications.
- Improved resource management by ensuring assets are copied correctly and links are updated in the exported content.
- Refactored settings to change default export paths from 'docs' to 'wiki' for better organization.
- Created detailed implementation notes and flowcharts to document the export processes and core functionalities.
2026-04-21 22:06:37 +08:00

650 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Plugin, TFile, Notice, Menu, MenuItem } 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: () => {
void this.exportCurrentNote();
}
});
// Add file menu item
this.registerEvent(
// @ts-ignore
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")
.setIcon("upload")
.onClick(() => {
void this.exportNote(file);
});
});
}
})
);
// Add editor menu item
this.registerEvent(
// @ts-ignore
this.app.workspace.on("editor-menu", (menu: Menu) => {
menu.addItem((item: MenuItem) => {
item
.setTitle("Export to vitepress")
.setIcon("upload")
.onClick(() => {
void this.exportCurrentNote();
});
});
})
);
// 添加设置面板
this.addSettingTab(new vitepressExporterSettingTab(this.app, this));
console.warn('vitepress Exporter plugin loaded');
}
onunload() {
console.warn('vitepress Exporter plugin unloaded');
}
async loadSettings() {
// eslint-disable-next-line
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' && activeFile.extension !== 'canvas') {
new Notice('Current file is not a Markdown or canvas file');
return;
}
await this.exportNote(activeFile);
}
// 导出单个笔记
async exportNote(file: TFile) {
if (file.extension === 'canvas') {
await this.exportCanvasFile(file);
return;
}
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. 计算输出路径
let docsPath: string;
const fileNameWithoutExt = file.name.replace('.md', '');
const filePathWithoutExt = file.path.replace('.md', '');
if (this.settings.preserveFolderStructure && this.settings.nestNoteFolder) {
// 保留文件夹 + 嵌套: 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.nestNoteFolder) {
// 嵌套文件夹: 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.warn(`Exported ${file.name} to ${docsPath}`);
}
// 导出 canvas 文件
async exportCanvasFile(file: TFile) {
const { vault } = this.app;
// 1. 渲染 canvas 为图片
const renderedImage = await this.renderCanvas(file);
if (!renderedImage) {
new Notice(`Failed to render canvas: ${file.name}`);
return;
}
// 2. 保存图片到 assetsPath (扁平化存储)
const imageFileName = file.name + '.png';
const imagePath = `${this.settings.assetsPath}/${imageFileName}`;
// 确保目标目录存在
await vault.adapter.mkdir(this.settings.assetsPath);
// 写入渲染的图片
await vault.adapter.writeBinary(imagePath, renderedImage.buffer);
console.debug(`Successfully rendered canvas: ${file.path} -> ${imagePath}`);
// 3. 计算 .canvas.md 文件的输出路径(复用现有逻辑)
const fileNameWithoutExt = file.name.replace('.canvas', '');
const filePathWithoutExt = file.path.replace('.canvas', '');
let docsPath: string;
if (this.settings.preserveFolderStructure && this.settings.nestNoteFolder) {
// 保留文件夹 + 嵌套: notes/some-note.canvas > docs/notes/some-note.canvas/index.md
docsPath = `${this.settings.docsPath}/${filePathWithoutExt}.canvas/index.md`;
} else if (this.settings.preserveFolderStructure) {
// 保留文件夹结构: notes/some-note.canvas > docs/notes/some-note.canvas.md
docsPath = `${this.settings.docsPath}/${filePathWithoutExt}.md`;
} else if (this.settings.nestNoteFolder) {
// 嵌套文件夹: notes/some-note.canvas > docs/some-note.canvas/index.md
docsPath = `${this.settings.docsPath}/${fileNameWithoutExt}.canvas/index.md`;
} else {
// 默认: notes/some-note.canvas > docs/some-note.canvas.md
docsPath = `${this.settings.docsPath}/${fileNameWithoutExt}.canvas.md`;
}
// 4. 确保 .canvas.md 目标目录存在
const targetDir = docsPath.substring(0, docsPath.lastIndexOf('/'));
await vault.adapter.mkdir(targetDir);
// 5. 生成 .canvas.md 内容
const content = `<Viewer src="/${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.debug('Processing assets for file:', sourceFile.path);
// 使用 Obsidian API 获取文件的所有嵌入和链接
const cache = metadataCache.getCache(sourceFile.path);
console.debug('Cache:', cache);
console.debug('Embeds:', cache?.embeds);
console.debug('Links:', cache?.links);
if (!cache) {
console.debug('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.debug('Processing embed:', embed);
const resolvedLink = metadataCache.getFirstLinkpathDest(embed.link, sourceFile.path);
console.debug('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.debug('Assets to copy:', Array.from(assetsToCopy));
console.debug('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 + '.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.debug(`Successfully rendered canvas: ${canvasPath} -> ${imagePath}`);
}
}
} catch (error) {
console.error(`Failed to render canvas ${canvasPath}:`, error);
}
}
const activeLeaf = this.app.workspace.getLeaf();
await activeLeaf.openFile(sourceFile);
// 复制资源文件到 docs/assets
for (const assetPath of assetsToCopy) {
try {
const sourceFile = vault.getAbstractFileByPath(assetPath);
console.debug('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.debug('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.debug(`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.debug('Trying to replace links for paths:', possiblePaths);
// 使用根路径: /image.png
const assetsRef = `/${newFileName}`;
// 替换 markdown 格式: ![](path) -> ![](/filename.png)
const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
content = content.replace(markdownRegex, (match, alt, path) => {
// 检查这个路径是否匹配我们的目标文件
for (const testPath of possiblePaths) {
if (path === testPath || path === testPath.replace(/^\//, '')) {
console.debug(`Found matching link: ${path} -> ${assetsRef}`);
return `![](${assetsRef})`;
}
}
return match;
});
// 替换 wikilink 格式: ![[path]] -> ![[assets/newfile.png]]
const wikilinkRegex = /!\[\[([^\]]+)\]\]/g;
content = content.replace(wikilinkRegex, (match, link: string) => {
// 移除可能的别名字段
const cleanLink = link.split('|')[0]!.trim();
for (const testPath of possiblePaths) {
if (cleanLink === testPath || cleanLink === testPath.replace(/^\//, '')) {
console.debug(`Found matching wikilink: ${cleanLink} -> ${assetsRef}`);
return `![[${assetsRef}]]`;
}
}
return match;
});
return content;
}
// Use Canvas internal API to export (基于 Obsidian export as image 实现)
/* eslint-disable */
async renderCanvas(
canvasFile: TFile
): Promise<Uint8Array | null> {
const activeLeaf = this.app.workspace.getLeaf();
await activeLeaf.openFile(canvasFile);
//@ts-ignore
const canvas = activeLeaf.view.canvas;
if (!canvas) {
console.debug("Could not get canvas object from view");
return null;
}
// 选项设置(基于用户要求)
const zoomScale = 1.0; // 缩放比例 1.0 (100%)
const privacyMode = false; // 不启用隐私模式
try {
// 获取系统参数
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; // 缩放校正系数
// 获取 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; // 高度
// 计算最大缩放限制(避免超过 16284px)
const h = Math.min(2, 16284 / s / c, 16284 / s / u);
const d = Math.min(zoomScale, h); // 实际使用的缩放比例
console.debug(`Canvas export: scale=${d}, size=${c}x${u}`);
// Full canvas 模式 - 使用分块渲染
const e = canvas.wrapperEl;
// 准备 Canvas 环境
this.deselectAll();
e.addClass("is-screenshotting");
if (privacyMode) {
e.addClass("is-text-garbled");
}
// @ts-ignore
canvas.screenshotting = true;
document.body.appendChild(e);
try {
// 设置 Canvas 位置
const x = 5 / o;
e.setCssStyles({
top: `${x}px`,
left: `${x}px`,
bottom: `${x}px`,
right: `${x}px`,
width: "auto",
height: "auto"
});
// 计算视口
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;
console.debug(`Rendering ${cols}x${rows} tiles (${cols * rows} total)`);
// 创建最终 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++;
console.debug(`Rendered tile ${tileIndex}/${totalTiles}`);
}
}
// 转换为 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 {
// 清理
// @ts-ignore
this.app.workspace.getLeaf().view?.contentEl?.appendChild(e);
e.removeClass("is-screenshotting");
e.removeClass("is-text-garbled");
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();
}
} catch (error) {
console.error("Canvas render failed:", error);
return null;
}
}
/* eslint-disable */
// 计算 Canvas 的边界框
calculateBBox(nodes: unknown[]): { minX: number, minY: number, maxX: number, maxY: number } {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
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;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
}
return { minX, minY, maxX, maxY };
}
// 取消选择所有节点
deselectAll(): void {
// @ts-ignore
const canvas = this.app.workspace.getLeaf().view?.canvas;
if (canvas) {
// @ts-ignore
canvas.deselectAll();
}
}
// 更新内容中的 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.debug('Trying to replace canvas links for paths:', possiblePaths);
// 使用根路径: /image.png
const assetsRef = `/${imageFileName}`;
// 替换 markdown 格式: !(canvas) -> <Viewer src="/image.png" />
const markdownRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
content = content.replace(markdownRegex, (match, _alt, path) => {
// 检查这个路径是否匹配我们的 canvas 文件
for (const testPath of possiblePaths) {
if (path === testPath || path === testPath.replace(/^\//, '')) {
console.debug(`Found matching canvas link: ${path} -> <Viewer src="${assetsRef}" />`);
return `<Viewer src="${assetsRef}" />`;
}
}
return match;
});
// 替换 wikilink 格式: ![[canvas]] -> <Viewer src="/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(/^\//, '')) {
console.debug(`Found matching canvas wikilink: ${cleanLink} -> <Viewer src="${assetsRef}" />`);
return `<Viewer src="${assetsRef}" />`;
}
}
return match;
});
return content;
}
}