1211 lines
37 KiB
TypeScript
1211 lines
37 KiB
TypeScript
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 格式:  -> 
|
||
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;
|
||
}
|
||
|
||
// 辅助函数:转义正则表达式特殊字符
|
||
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();
|
||
}));
|
||
}
|
||
}
|