Files
odox/main.ts

1211 lines
37 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 { App, Plugin, PluginSettingTab, Setting, TFile, Notice } from 'obsidian';
interface VitePressExporterSettings {
docsPath: string;
assetsPath: string;
}
const DEFAULT_SETTINGS: VitePressExporterSettings = {
docsPath: 'docs',
assetsPath: 'docs/assets'
}
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();
}
});
// 添加功能区按钮
this.addRibbonIcon('upload', 'Export to VitePress', () => {
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;
}
new Notice(`Exporting ${activeFile.name} to VitePress...`);
try {
await this.exportNote(activeFile);
new Notice(`Successfully exported ${activeFile.name} to VitePress`);
} catch (error) {
console.error('Export failed:', error);
new Notice(`Export failed: ${error.message}`);
}
}
// 导出单个笔记
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);
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.renderCanvasToImage(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);
// 更新内容中的 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;
}
// 获取原始链接文本
getOriginalLink(content: string, targetPath: string, sourcePath: string): string | null {
const { metadataCache } = this.app;
// 尝试多种可能的链接格式
const possibleFormats = [
// Wikilink 格式
new RegExp(`!\\[\\[${this.escapeRegExp(targetPath)}(\\|[^\\]]*)?\\]\\]`, 'g'),
new RegExp(`\\[\\[${this.escapeRegExp(targetPath)}(\\|[^\\]]*)?\\]\\]`, 'g'),
// Markdown 格式
new RegExp(`!\\[.*?\\]\\(${this.escapeRegExp(targetPath)}\\)`, 'g'),
new RegExp(`\\[.*?\\]\\(${this.escapeRegExp(targetPath)}\\)`, 'g'),
// 相对路径格式
new RegExp(`!\\[\\[${this.escapeRegExp(this.getRelativePath(targetPath, sourcePath))}(\\|[^\\]]*)?\\]\\]`, 'g'),
];
for (const regex of possibleFormats) {
const match = content.match(regex);
if (match) {
return match[0];
}
}
return null;
}
// 更新内容中的链接
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;
}
// 辅助函数:转义正则表达式特殊字符
escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// 辅助函数:获取相对路径
getRelativePath(targetPath: string, sourcePath: string): string {
// 简化的相对路径计算
const targetParts = targetPath.split('/');
const sourceParts = sourcePath.split('/');
// 移除文件名
sourceParts.pop();
let commonIndex = 0;
while (commonIndex < Math.min(targetParts.length - 1, sourceParts.length) &&
targetParts[commonIndex] === sourceParts[commonIndex]) {
commonIndex++;
}
const relativeParts = [];
for (let i = commonIndex; i < sourceParts.length; i++) {
relativeParts.push('..');
}
return [...relativeParts, ...targetParts.slice(commonIndex)].join('/');
}
// 渲染 Canvas 为图片(使用 Obsidian 原生渲染)
async renderCanvasToImage(canvasFile: TFile): Promise<Uint8Array | null> {
try {
console.log('🎯 Attempting to render canvas using Obsidian native rendering:', canvasFile.path);
// 使用 Workspace API 打开 Canvas 并捕获原生渲染
const nativeImage = await this.tryNativeCanvasCapture(canvasFile);
if (nativeImage) {
console.log('✅ Successfully captured native Obsidian canvas rendering');
return nativeImage;
}
console.log('⚠️ Native capture failed, using placeholder');
// 降级方案:生成占位图
return this.generatePlaceholderImage(canvasFile);
} catch (error) {
console.error('❌ Error rendering canvas:', error);
return this.generatePlaceholderImage(canvasFile);
}
}
// 隐藏 Canvas UI 元素(用于导出时)
hideCanvasUIElements(): HTMLStyleElement {
const styleElement = document.createElement('style');
styleElement.id = 'canvas-export-hide-ui';
styleElement.textContent = `
/* 隐藏 Canvas 控制按钮 */
.canvas-control-menu,
.canvas-card-menu,
.canvas-node-menu-button,
.canvas-node:hover .canvas-node-menu-button,
.canvas-controls,
.canvas-help-button {
display: none !important;
}
/* 移除选中状态的高亮 */
.canvas-node.is-selected,
.canvas-node.is-focused,
.canvas-edge.is-selected {
box-shadow: none !important;
outline: none !important;
}
/* 隐藏工具提示和弹出菜单 */
.tooltip,
.popover,
.modal {
display: none !important;
}
/* 隐藏滚动条但保留滚动功能 */
.canvas-view-content::-webkit-scrollbar {
display: none !important;
}
/* 确保内容不被裁切 */
.canvas-view-content {
overflow: visible !important;
}
/* Privacy Mode: 防止卡片内容因缩放被隐藏 */
.canvas-node-content-hidden {
display: block !important;
opacity: 1 !important;
}
/* 确保所有卡片内容始终可见 */
.canvas-node-content {
display: block !important;
opacity: 1 !important;
}
/* 防止卡片被折叠 */
.canvas-node .canvas-node-content {
display: block !important;
}
`;
document.head.appendChild(styleElement);
console.log('✅ Canvas UI elements hidden for export (privacy mode enabled)');
return styleElement;
}
// 计算实际的 Canvas 内容尺寸和偏移
getCanvasContentSize(canvasDom: Element): { width: number; height: number; offsetX: number; offsetY: number } {
const nodes = canvasDom.querySelectorAll('.canvas-node');
console.log(`📐 Found ${nodes.length} canvas nodes`);
if (nodes.length === 0) {
// 降级方案:使用容器尺寸
return {
width: (canvasDom as any).offsetWidth || 800,
height: (canvasDom as any).offsetHeight || 600,
offsetX: 0,
offsetY: 0
};
}
// 计算所有节点的边界框
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach(node => {
const rect = node.getBoundingClientRect();
const canvasRect = canvasDom.getBoundingClientRect();
// 计算相对于 Canvas 的位置
const relativeX = rect.left - canvasRect.left;
const relativeY = rect.top - canvasRect.top;
minX = Math.min(minX, relativeX);
minY = Math.min(minY, relativeY);
maxX = Math.max(maxX, relativeX + rect.width);
maxY = Math.max(maxY, relativeY + rect.height);
});
// 添加 padding
const padding = 50;
const offsetX = Math.max(0, minX - padding);
const offsetY = Math.max(0, minY - padding);
const contentWidth = maxX - minX + padding * 2;
const contentHeight = maxY - minY + padding * 2;
console.log(`📐 Canvas: ${contentWidth}x${contentHeight}, offset: (${offsetX}, ${offsetY})`);
return { width: contentWidth, height: contentHeight, offsetX, offsetY };
}
// 通过滚动拼接捕获完整 Canvas
async captureCanvasByScrolling(
canvasDom: Element,
contentSize: { width: number; height: number; offsetX: number; offsetY: number }
): Promise<Uint8Array | null> {
try {
// 检查 canvasDom 本身是否就是 wrapper,或者 wrapper 是其子元素
let canvasWrapper: HTMLElement | null = null;
if ((canvasDom as HTMLElement).classList.contains('canvas-wrapper')) {
console.log('📦 canvasDom itself is the wrapper');
canvasWrapper = canvasDom as HTMLElement;
} else {
canvasWrapper = canvasDom.querySelector('.canvas-wrapper') as HTMLElement;
if (!canvasWrapper) {
console.log('❌ No canvas wrapper found');
return null;
}
}
// 使用 canvasWrapper 获取视口尺寸
const viewportWidth = canvasWrapper.offsetWidth;
const viewportHeight = canvasWrapper.offsetHeight;
const totalWidth = contentSize.width;
const totalHeight = contentSize.height;
console.log(`📐 Viewport: ${viewportWidth}x${viewportHeight}, Total: ${totalWidth}x${totalHeight}`);
// 如果内容小于视口,直接捕获
if (totalWidth <= viewportWidth && totalHeight <= viewportHeight) {
console.log('📸 Content fits in viewport, single capture');
return null; // 让降级处理
}
// 计算需要分块的数量
const cols = Math.ceil(totalWidth / viewportWidth);
const rows = Math.ceil(totalHeight / viewportHeight);
console.log(`🧩 Will capture ${cols}x${rows} = ${cols * rows} tiles`);
console.log(`📍 Content offset: (${contentSize.offsetX}, ${contentSize.offsetY})`);
// 保存原始滚动位置
const originalScrollLeft = canvasWrapper.scrollLeft;
const originalScrollTop = canvasWrapper.scrollTop;
// 创建最终拼接的 Canvas
const finalCanvas = document.createElement('canvas');
finalCanvas.width = totalWidth;
finalCanvas.height = totalHeight;
const ctx = finalCanvas.getContext('2d');
if (!ctx) {
console.log('❌ Failed to get canvas context');
return null;
}
// 填充白色背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, totalWidth, totalHeight);
// @ts-ignore
const domToImage = require('dom-to-image-more');
// 分块捕获并拼接
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * viewportWidth;
const y = row * viewportHeight;
// 滚动到对应位置 (确保从内容起始位置开始)
const targetScrollLeft = Math.max(0, contentSize.offsetX + x);
const targetScrollTop = Math.max(0, contentSize.offsetY + y);
canvasWrapper.scrollLeft = targetScrollLeft;
canvasWrapper.scrollTop = targetScrollTop;
console.log(`🔄 Scrolled to (${targetScrollLeft}, ${targetScrollTop})`);
// 等待渲染
await new Promise(resolve => setTimeout(resolve, 150));
// 捕获当前视口
// @ts-ignore
const domToImage = require('dom-to-image-more');
const tileDataUrl = await domToImage.toPng(canvasWrapper, {
backgroundColor: '#ffffff',
quality: 1,
width: viewportWidth,
height: viewportHeight
});
// 加载图片并绘制到最终 Canvas
const img = await this.loadImage(tileDataUrl);
if (img) {
const drawWidth = Math.min(viewportWidth, totalWidth - x);
const drawHeight = Math.min(viewportHeight, totalHeight - y);
ctx.drawImage(img, 0, 0, drawWidth, drawHeight, x, y, drawWidth, drawHeight);
console.log(`✅ Tile [${row},${col}] at (${x},${y}) size ${drawWidth}x${drawHeight}`);
}
if (img) {
const drawWidth = Math.min(viewportWidth, totalWidth - x);
const drawHeight = Math.min(viewportHeight, totalHeight - y);
ctx.drawImage(img, 0, 0, drawWidth, drawHeight, x, y, drawWidth, drawHeight);
console.log(`✅ Tile [${row},${col}] at (${x},${y}) size ${drawWidth}x${drawHeight}`);
}
}
}
// 恢复滚动位置
canvasWrapper.scrollLeft = originalScrollLeft;
canvasWrapper.scrollTop = originalScrollTop;
// 转换为 Uint8Array
const finalDataUrl = finalCanvas.toDataURL('image/png');
const base64Data = finalDataUrl.split(',')[1];
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
console.log('✅ Stitched canvas complete');
return bytes;
} catch (error) {
console.error('❌ Scroll and stitch failed:', error);
return null;
}
}
// 加载图片
async loadImage(dataUrl: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = dataUrl;
});
}
// 将 DataURL 转换为 Uint8Array
dataUrlToUint8Array(dataUrl: string): Uint8Array {
const base64Data = dataUrl.split(',')[1];
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// 尝试捕获 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');
}
}
// 在指定的 leaf 中查找 Canvas DOM 元素
async findCanvasDomInLeaf(leaf: any): Promise<Element | null> {
console.log('🔍 Searching for canvas DOM in leaf...');
// 获取 leaf 的容器
const container = leaf.containerEl;
if (!container) {
console.log('❌ Leaf has no container');
return null;
}
console.log('📦 Leaf container:', container);
// 在 leaf 容器中查找 Canvas(最多等待 20 秒)
const maxWait = 20000;
const startTime = Date.now();
return new Promise(resolve => {
const checkCanvas = () => {
const elapsed = Date.now() - startTime;
if (elapsed > maxWait) {
console.log('⏰ Canvas render timeout after', maxWait, 'ms');
resolve(null);
return;
}
// 尝试多种可能的选择器(在 leaf 容器内搜索)
const selectors = [
'.canvas-view-content',
'.canvas-wrapper',
'.canvas-container',
'[data-type="canvas"]',
'.canvas-view'
];
for (const selector of selectors) {
const canvas = container.querySelector(selector);
if (canvas) {
const nodes = canvas.querySelectorAll('.canvas-node');
if (nodes.length > 0) {
console.log(`✅ Found canvas with selector: ${selector} (${elapsed}ms) with ${nodes.length} nodes`);
setTimeout(() => resolve(canvas), 500);
return;
}
}
}
// 继续等待
setTimeout(checkCanvas, 100);
};
console.log('⏳ Starting to wait for canvas render in leaf...');
checkCanvas();
});
}
// 等待 Canvas 渲染完成
async waitForCanvasRender(): Promise<void> {
return new Promise(resolve => {
const maxWait = 20000; // 增加到 20 秒(新窗口需要更多时间)
const startTime = Date.now();
const checkCanvas = () => {
const elapsed = Date.now() - startTime;
if (elapsed > maxWait) {
console.log('⏰ Canvas render timeout after', maxWait, 'ms');
resolve();
return;
}
// 尝试多种可能的选择器
const selectors = [
'.canvas-view-content',
'.canvas-wrapper',
'.canvas-container',
'[data-type="canvas"]',
'.workspace-leaf-content .canvas-view'
];
for (const selector of selectors) {
const canvas = document.querySelector(selector);
if (canvas) {
// 检查是否有节点
const nodes = canvas.querySelectorAll('.canvas-node');
if (nodes.length > 0) {
console.log(`✅ Canvas rendered using selector: ${selector} (${elapsed}ms) with ${nodes.length} nodes`);
// 额外等待确保渲染完全完成
setTimeout(resolve, 1000);
return;
}
}
// 继续等待
}
setTimeout(checkCanvas, 100);
};
console.log('⏳ Starting to wait for canvas render...');
checkCanvas();
});
}
// 查找 Canvas DOM 元素
findCanvasDomElement(): Element | null {
console.log('🔍 Searching for canvas DOM elements...');
// 尝试多种可能的选择器
const selectors = [
'.canvas-view-content',
'.canvas-wrapper',
'.canvas-container',
'[data-type="canvas"]',
'.workspace-leaf-content .canvas-view',
'.canvas-view'
];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (element) {
console.log('✅ Found canvas element with selector: "' + selector + '"');
console.log(' Element:', element.tagName, element.className);
// @ts-ignore - HTMLElement 类型扩展
console.log(' Size:', element.offsetWidth, 'x', element.offsetHeight);
console.log(' Children:', element.children.length);
return element;
}
}
console.log('❌ No canvas element found');
return null;
}
// 从 Canvas JSON 渲染图片
async renderCanvasFromJSON(canvasFile: TFile): Promise<Uint8Array | null> {
try {
console.log('📖 Reading canvas JSON...');
const { vault } = this.app;
// 读取 Canvas JSON
const content = await vault.read(canvasFile);
const canvasData = JSON.parse(content);
console.log('📊 Canvas data:', canvasData);
// 解析节点和边
const nodes = canvasData.nodes || [];
const edges = canvasData.edges || [];
console.log(`📦 Found ${nodes.length} nodes and ${edges.length} edges`);
if (nodes.length === 0) {
console.log('⚠️ No nodes found in canvas');
return null;
}
// 计算画布边界
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of nodes) {
const x = node.x || 0;
const y = node.y || 0;
const width = node.width || 200;
const height = node.height || 100;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
}
// 添加 padding
const padding = 50;
minX -= padding;
minY -= padding;
maxX += padding;
maxY += padding;
const canvasWidth = maxX - minX;
const canvasHeight = maxY - minY;
console.log(`📐 Canvas size: ${canvasWidth} x ${canvasHeight}`);
// 创建 Canvas
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.log('❌ Failed to get canvas context');
return null;
}
// 填充背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// 绘制边(连接线)
console.log('🔗 Drawing edges...');
ctx.strokeStyle = '#999999';
ctx.lineWidth = 2;
for (const edge of edges) {
const fromNode = nodes.find((n: any) => n.id === edge.fromNode || n.id === edge.from);
const toNode = nodes.find((n: any) => n.id === edge.toNode || n.id === edge.to);
if (fromNode && toNode) {
const fromX = fromNode.x + (fromNode.width || 200) / 2 - minX;
const fromY = fromNode.y + (fromNode.height || 100) / 2 - minY;
const toX = toNode.x + (toNode.width || 200) / 2 - minX;
const toY = toNode.y + (toNode.height || 100) / 2 - minY;
ctx.beginPath();
ctx.moveTo(fromX, fromY);
ctx.lineTo(toX, toY);
ctx.stroke();
}
}
// 绘制节点
console.log('🎨 Drawing nodes...');
for (const node of nodes) {
await this.drawCanvasNode(ctx, node, minX, minY);
}
console.log('💾 Converting to Uint8Array...');
// 转换为 Uint8Array
const dataUrl = canvas.toDataURL('image/png');
const base64Data = dataUrl.split(',')[1];
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
} catch (error) {
console.error('❌ Error rendering canvas from JSON:', error);
return null;
}
}
// 绘制单个 Canvas 节点
async drawCanvasNode(ctx: CanvasRenderingContext2D, node: any, offsetX: number, offsetY: number): Promise<void> {
const x = (node.x || 0) - offsetX;
const y = (node.y || 0) - offsetY;
const width = node.width || 200;
const height = node.height || 100;
console.log(` Drawing node "${node.id}" at (${x}, ${y})`);
// 设置背景颜色
let bgColor = '#f0f0f0';
if (node.color) {
const colorMap: Record<string, string> = {
'1': '#ff6b6b',
'2': '#4ecdc4',
'3': '#45b7d1',
'4': '#96ceb4',
'5': '#ffeaa7',
'6': '#dfe6e9'
};
bgColor = colorMap[node.color] || node.color;
}
// 绘制节点背景
ctx.fillStyle = bgColor;
ctx.fillRect(x, y, width, height);
// 绘制边框
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
// 绘制文本内容
if (node.type === 'text' && node.text) {
ctx.fillStyle = '#333333';
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
// 简单的文本换行处理
const padding = 10;
const maxWidth = width - padding * 2;
const lineHeight = 20;
this.wrapText(ctx, node.text, x + padding, y + padding, maxWidth, lineHeight);
} else if (node.type === 'file' && node.file) {
ctx.fillStyle = '#333333';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 显示文件名
const fileName = node.file.split('/').pop();
ctx.fillText(fileName || node.file, x + width / 2, y + height / 2);
} else if (node.label) {
ctx.fillStyle = '#333333';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.label, x + width / 2, y + height / 2);
}
}
// 文本换行辅助函数
wrapText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number): void {
const words = text.split(' ');
let line = '';
let currentY = y;
for (let i = 0; i < words.length; i++) {
const testLine = line + words[i] + ' ';
const metrics = ctx.measureText(testLine);
const testWidth = metrics.width;
if (testWidth > maxWidth && i > 0) {
ctx.fillText(line, x, currentY);
line = words[i] + ' ';
currentY += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, x, currentY);
}
// 生成占位图
generatePlaceholderImage(canvasFile: TFile): Uint8Array | null {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// 设置占位图尺寸
canvas.width = 800;
canvas.height = 600;
// 绘制背景
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 绘制边框
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 2;
ctx.strokeRect(10, 10, canvas.width - 20, canvas.height - 20);
// 绘制文字
ctx.fillStyle = '#666';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.fillText('Canvas 占位图', canvas.width / 2, canvas.height / 2 - 20);
ctx.font = '16px Arial';
ctx.fillText(canvasFile.name, canvas.width / 2, canvas.height / 2 + 20);
ctx.font = '14px Arial';
ctx.fillText('(需要手动导出或等待原生渲染支持)', canvas.width / 2, canvas.height / 2 + 50);
// 转换为 Uint8Array
const dataUrl = canvas.toDataURL('image/png');
const base64Data = dataUrl.split(',')[1];
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
} catch (error) {
console.error('Error generating placeholder image:', 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;
}
}
// 设置面板
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();
}));
}
}