Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
import { PlaitBoard } from "@plait/core";
|
|
|
|
export type ImageExportOptions = {
|
|
scale: 1 | 2 | 3;
|
|
transparent: boolean;
|
|
backgroundColor: string;
|
|
};
|
|
|
|
const textStyles = [
|
|
"display",
|
|
"box-sizing",
|
|
"margin",
|
|
"padding",
|
|
"width",
|
|
"height",
|
|
"font-family",
|
|
"font-size",
|
|
"font-weight",
|
|
"font-style",
|
|
"line-height",
|
|
"color",
|
|
"background-color",
|
|
"text-align",
|
|
"text-decoration",
|
|
"white-space",
|
|
"word-break",
|
|
"overflow-wrap",
|
|
"border-radius",
|
|
"vertical-align",
|
|
];
|
|
|
|
async function embedResource(url: string): Promise<string> {
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
if (!response.ok) throw new Error("图片资源加载失败,请稍后重试");
|
|
const blob = await response.blob();
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(String(reader.result));
|
|
reader.onerror = () => reject(new Error("图片资源读取失败"));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
/** Render the complete content layer in board coordinates, independent of pan/zoom. */
|
|
export async function exportBoardImage(
|
|
board: PlaitBoard,
|
|
options: ImageExportOptions
|
|
) {
|
|
if (!board.children.length) throw new Error("当前工作表没有可导出的内容");
|
|
await document.fonts.ready;
|
|
const host = PlaitBoard.getElementHost(board);
|
|
const bounds = host.getBBox();
|
|
const padding = 32;
|
|
const width = Math.ceil(bounds.width + padding * 2);
|
|
const height = Math.ceil(bounds.height + padding * 2);
|
|
const pixelWidth = width * options.scale;
|
|
const pixelHeight = height * options.scale;
|
|
if (!bounds.width || !bounds.height)
|
|
throw new Error("画布尚未准备好,请稍后重试");
|
|
if (
|
|
pixelWidth > 16_384 ||
|
|
pixelHeight > 16_384 ||
|
|
pixelWidth * pixelHeight > 64_000_000
|
|
) {
|
|
throw new Error("图片尺寸过大,请降低清晰度或拆分工作表后导出");
|
|
}
|
|
const clone = host.cloneNode(true) as SVGGElement;
|
|
clone.removeAttribute("transform");
|
|
const sources = host.querySelectorAll("foreignObject *");
|
|
const copies = clone.querySelectorAll("foreignObject *");
|
|
sources.forEach((source, index) => {
|
|
const copy = copies[index] as HTMLElement;
|
|
const computed = getComputedStyle(source);
|
|
for (const property of textStyles)
|
|
copy.style.setProperty(property, computed.getPropertyValue(property));
|
|
copy.removeAttribute("contenteditable");
|
|
});
|
|
const resources = new Map<string, Promise<string>>();
|
|
await Promise.all(
|
|
Array.from(clone.querySelectorAll("img, image")).map(async (node) => {
|
|
const attribute = node.tagName.toLowerCase() === "img" ? "src" : "href";
|
|
const url =
|
|
node.getAttribute(attribute) ||
|
|
node.getAttributeNS("http://www.w3.org/1999/xlink", "href");
|
|
if (!url || url.startsWith("data:")) return;
|
|
if (!resources.has(url)) resources.set(url, embedResource(url));
|
|
node.setAttribute(attribute, await resources.get(url)!);
|
|
node.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
|
|
})
|
|
);
|
|
const namespace = "http://www.w3.org/2000/svg";
|
|
const svg = document.createElementNS(namespace, "svg");
|
|
svg.setAttribute("width", String(width));
|
|
svg.setAttribute("height", String(height));
|
|
svg.setAttribute(
|
|
"viewBox",
|
|
`${bounds.x - padding} ${bounds.y - padding} ${width} ${height}`
|
|
);
|
|
for (const defs of PlaitBoard.getHost(board).querySelectorAll(
|
|
":scope > defs"
|
|
))
|
|
svg.append(defs.cloneNode(true));
|
|
const style = document.createElementNS(namespace, "style");
|
|
const weights = [400, 500, 700, 800];
|
|
const names = ["Regular", "Medium", "Bold", "Extrabold"];
|
|
style.textContent = (
|
|
await Promise.all(
|
|
names.map(async (name, index) => {
|
|
const data = await embedResource(`/fonts/xmind/NeverMind-${name}.ttf`);
|
|
return `@font-face{font-family:NeverMind;src:url('${data}') format('truetype');font-weight:${weights[index]};}`;
|
|
})
|
|
)
|
|
).join("\n");
|
|
svg.append(style, clone);
|
|
const image = new Image();
|
|
const svgData = new XMLSerializer().serializeToString(svg);
|
|
// A self-contained data URI keeps foreignObject content safe to draw on canvas.
|
|
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`;
|
|
await image.decode();
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = pixelWidth;
|
|
canvas.height = pixelHeight;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) throw new Error("浏览器无法生成图片");
|
|
if (!options.transparent) {
|
|
context.fillStyle = options.backgroundColor;
|
|
context.fillRect(0, 0, pixelWidth, pixelHeight);
|
|
}
|
|
context.drawImage(image, 0, 0, pixelWidth, pixelHeight);
|
|
const blob = await new Promise<Blob>((resolve, reject) =>
|
|
canvas.toBlob(
|
|
(value) => (value ? resolve(value) : reject(new Error("图片生成失败"))),
|
|
"image/png"
|
|
)
|
|
);
|
|
return { blob, width: pixelWidth, height: pixelHeight };
|
|
}
|