feat: Enhance Canvas rendering with internal API and improved export functionality
This commit is contained in:
264
src/main.ts
264
src/main.ts
@@ -272,8 +272,10 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
return content;
|
||||
}
|
||||
|
||||
// 尝试捕获 Obsidian 原生 Canvas 渲染(通过临时调整视口大小)
|
||||
async renderCanvas(canvasFile: TFile): Promise<Uint8Array | null> {
|
||||
// Use Canvas internal API to export (基于 Obsidian export as image 实现)
|
||||
async renderCanvas(
|
||||
canvasFile: TFile
|
||||
): Promise<Uint8Array | null> {
|
||||
|
||||
const activeLeaf = this.app.workspace.getLeaf();
|
||||
if (!activeLeaf) {
|
||||
@@ -282,113 +284,219 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
}
|
||||
|
||||
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;
|
||||
// 选项设置(基于用户要求)
|
||||
const zoomScale = 1.0; // 缩放比例 1.0 (100%)
|
||||
const privacyMode = false; // 不启用隐私模式
|
||||
|
||||
}
|
||||
|
||||
// Use Canvas internal API to export
|
||||
async exportCanvasWithInternalAPI(
|
||||
canvas: any,
|
||||
canvasFile: TFile
|
||||
): Promise<Uint8Array | null> {
|
||||
try {
|
||||
console.log("=== Using Canvas Internal API ===");
|
||||
// 获取系统参数
|
||||
const win = window;
|
||||
//@ts-ignore
|
||||
const electron = win.electron?.remote;
|
||||
const webContents = electron?.getCurrentWebContents();
|
||||
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; // 缩放校正系数
|
||||
|
||||
const { vault } = this.app;
|
||||
// 获取 Canvas 边界
|
||||
const nodes = Array.from(canvas.nodes.values());
|
||||
const bbox = this.calculateBBox(nodes);
|
||||
const c = bbox.maxX - bbox.minX; // 宽度
|
||||
const u = bbox.maxY - bbox.minY; // 高度
|
||||
|
||||
// 1. Get viewport bbox
|
||||
// 计算最大缩放限制(避免超过 16284px)
|
||||
const h = Math.min(2, 16284 / s / c, 16284 / s / u);
|
||||
const d = Math.min(zoomScale, h); // 实际使用的缩放比例
|
||||
|
||||
console.log(`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
|
||||
const bbox = canvas.getViewportBBox();
|
||||
console.log("Viewport bbox:", bbox);
|
||||
canvas.screenshotting = true;
|
||||
document.body.appendChild(e);
|
||||
|
||||
// 2. Calculate content size
|
||||
const contentWidth = bbox.maxX - bbox.minX;
|
||||
const contentHeight = bbox.maxY - bbox.minY;
|
||||
console.log(`Content size: ${contentWidth}x${contentHeight}`);
|
||||
try {
|
||||
// 设置 Canvas 位置
|
||||
const x = 5 / o;
|
||||
e.setCssStyles({
|
||||
top: `${x}px`,
|
||||
left: `${x}px`,
|
||||
bottom: `${x}px`,
|
||||
right: `${x}px`,
|
||||
width: "auto",
|
||||
height: "auto"
|
||||
});
|
||||
|
||||
// 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%)`);
|
||||
// 计算视口
|
||||
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;
|
||||
|
||||
// 4. Request frame
|
||||
// @ts-ignore
|
||||
canvas.requestFrame();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
// 设置缩放
|
||||
// @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));
|
||||
|
||||
// Generate temp filename (relative path)
|
||||
const tempFileName = `temp_canvas_${Date.now()}.png`;
|
||||
// 计算分块数量
|
||||
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;
|
||||
|
||||
// Ensure assets directory exists
|
||||
await vault.adapter.mkdir(this.settings.assetsPath);
|
||||
console.log(`Rendering ${cols}x${rows} tiles (${cols * rows} total)`);
|
||||
|
||||
// Generate final save path (relative path)
|
||||
const finalFileName = canvasFile.name.replace(".canvas", ".png");
|
||||
const finalPath = `${this.settings.assetsPath}/${finalFileName}`;
|
||||
// 创建最终 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");
|
||||
}
|
||||
|
||||
// Get vault full path
|
||||
// @ts-ignore
|
||||
const vaultBasePath = vault.adapter.basePath;
|
||||
console.log("Vault base path:", vaultBasePath);
|
||||
let tileIndex = 0;
|
||||
const totalTiles = cols * rows;
|
||||
|
||||
// Build temp file absolute path
|
||||
const tempFullPath = `${vaultBasePath}/${tempFileName}`;
|
||||
console.log("Temp full path:", tempFullPath);
|
||||
// 渲染每个分块
|
||||
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();
|
||||
|
||||
// Call takeScreenshot (using absolute path)
|
||||
// @ts-ignore
|
||||
await canvas.takeScreenshot(tempFullPath, false);
|
||||
console.log(`Screenshot saved to ${tempFullPath}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Wait for file write to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// 捕获当前视口
|
||||
const capturedData = await webContents.capturePage({ x: L, y: I, width: O, height: F });
|
||||
|
||||
// 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}`);
|
||||
// 转换为图片并绘制到最终 Canvas
|
||||
const pngData = capturedData.toPNG();
|
||||
const blob = new Blob([pngData], { type: "image/png" });
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Check if read was successful
|
||||
if (!imageBuffer || imageBuffer.byteLength === 0) {
|
||||
console.error("Failed to read temporary file");
|
||||
return null;
|
||||
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.log(`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.activeLeaf?.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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
console.error("Canvas render failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 Canvas 的边界框
|
||||
calculateBBox(nodes: any[]): { 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.activeLeaf?.view?.canvas;
|
||||
if (canvas) {
|
||||
// @ts-ignore
|
||||
canvas.deselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
// 更新内容中的 Canvas 链接
|
||||
updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string {
|
||||
// 获取源文件的目录路径
|
||||
@@ -445,4 +553,4 @@ export default class VitePressExporterPlugin extends Plugin {
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user