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;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试捕获 Obsidian 原生 Canvas 渲染(通过临时调整视口大小)
|
// Use Canvas internal API to export (基于 Obsidian export as image 实现)
|
||||||
async renderCanvas(canvasFile: TFile): Promise<Uint8Array | null> {
|
async renderCanvas(
|
||||||
|
canvasFile: TFile
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
|
||||||
const activeLeaf = this.app.workspace.getLeaf();
|
const activeLeaf = this.app.workspace.getLeaf();
|
||||||
if (!activeLeaf) {
|
if (!activeLeaf) {
|
||||||
@@ -282,113 +284,219 @@ export default class VitePressExporterPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await activeLeaf.openFile(canvasFile);
|
await activeLeaf.openFile(canvasFile);
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const canvas = activeLeaf.view.canvas;
|
const canvas = activeLeaf.view.canvas;
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
console.log("Could not get canvas object from view");
|
console.log("Could not get canvas object from view");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Use Canvas internal API
|
|
||||||
const imageData = await this.exportCanvasWithInternalAPI(canvas, canvasFile);
|
|
||||||
|
|
||||||
if (!imageData) {
|
// 选项设置(基于用户要求)
|
||||||
console.log("Canvas internal API capture failed");
|
const zoomScale = 1.0; // 缩放比例 1.0 (100%)
|
||||||
return null;
|
const privacyMode = false; // 不启用隐私模式
|
||||||
}
|
|
||||||
console.log("Canvas internal API capture successful!");
|
|
||||||
return imageData;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Canvas internal API to export
|
|
||||||
async exportCanvasWithInternalAPI(
|
|
||||||
canvas: any,
|
|
||||||
canvasFile: TFile
|
|
||||||
): Promise<Uint8Array | null> {
|
|
||||||
try {
|
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
|
// @ts-ignore
|
||||||
const bbox = canvas.getViewportBBox();
|
canvas.screenshotting = true;
|
||||||
console.log("Viewport bbox:", bbox);
|
document.body.appendChild(e);
|
||||||
|
|
||||||
// 2. Calculate content size
|
try {
|
||||||
const contentWidth = bbox.maxX - bbox.minX;
|
// 设置 Canvas 位置
|
||||||
const contentHeight = bbox.maxY - bbox.minY;
|
const x = 5 / o;
|
||||||
console.log(`Content size: ${contentWidth}x${contentHeight}`);
|
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 P = e.getBoundingClientRect();
|
||||||
const oldZoom = canvas.zoom;
|
const L = Math.ceil(P.x * o + 1);
|
||||||
// @ts-ignore
|
const I = Math.ceil(P.y * o + 1);
|
||||||
canvas.zoom = 0;
|
const O = Math.floor(P.width * o) - 2;
|
||||||
console.log(`Set zoom from ${oldZoom} to 0 (100%)`);
|
const F = Math.floor(P.height * o) - 2;
|
||||||
|
|
||||||
// 4. Request frame
|
// 设置缩放
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
canvas.requestFrame();
|
canvas.zoom = Math.log2(d / o);
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
// @ts-ignore
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
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
|
console.log(`Rendering ${cols}x${rows} tiles (${cols * rows} total)`);
|
||||||
await vault.adapter.mkdir(this.settings.assetsPath);
|
|
||||||
|
|
||||||
// Generate final save path (relative path)
|
// 创建最终 Canvas
|
||||||
const finalFileName = canvasFile.name.replace(".canvas", ".png");
|
const finalCanvas = document.createElement("canvas");
|
||||||
const finalPath = `${this.settings.assetsPath}/${finalFileName}`;
|
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
|
let tileIndex = 0;
|
||||||
// @ts-ignore
|
const totalTiles = cols * rows;
|
||||||
const vaultBasePath = vault.adapter.basePath;
|
|
||||||
console.log("Vault base path:", vaultBasePath);
|
|
||||||
|
|
||||||
// Build temp file absolute path
|
// 渲染每个分块
|
||||||
const tempFullPath = `${vaultBasePath}/${tempFileName}`;
|
for (let row = 0; row < rows; row++) {
|
||||||
console.log("Temp full path:", tempFullPath);
|
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)
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
// @ts-ignore
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
await canvas.takeScreenshot(tempFullPath, false);
|
|
||||||
console.log(`Screenshot saved to ${tempFullPath}`);
|
|
||||||
|
|
||||||
// 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)
|
// 转换为图片并绘制到最终 Canvas
|
||||||
const imageBuffer = await vault.adapter.readBinary(tempFileName);
|
const pngData = capturedData.toPNG();
|
||||||
console.log(`Read imageBuffer, type: ${imageBuffer?.constructor?.name}, byteLength: ${imageBuffer?.byteLength}`);
|
const blob = new Blob([pngData], { type: "image/png" });
|
||||||
|
const imageUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
// Check if read was successful
|
const img = document.createElement("img");
|
||||||
if (!imageBuffer || imageBuffer.byteLength === 0) {
|
await new Promise<void>((resolve, reject) => {
|
||||||
console.error("Failed to read temporary file");
|
img.onload = () => resolve();
|
||||||
return null;
|
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) {
|
} catch (error) {
|
||||||
console.error("exportCanvasWithInternalAPI failed:", error);
|
console.error("Canvas render failed:", error);
|
||||||
return null;
|
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 链接
|
// 更新内容中的 Canvas 链接
|
||||||
updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string {
|
updateCanvasLinksInContent(content: string, canvasFile: TFile, canvasPath: string, imageFileName: string): string {
|
||||||
// 获取源文件的目录路径
|
// 获取源文件的目录路径
|
||||||
@@ -445,4 +553,4 @@ export default class VitePressExporterPlugin extends Plugin {
|
|||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user