Node text stays between 8px and 12px, so a large architecture chart no longer fills the page. Print, HTML export, and WeChat copy use the same 12px ceiling.
291 lines
8.1 KiB
TypeScript
291 lines
8.1 KiB
TypeScript
import hljs from "highlight.js/lib/common";
|
|
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
|
|
import { MERMAID_THEME_PALETTES } from "@/components/ThemeProvider";
|
|
import { scaleMermaidSvg } from "@/lib/mermaid-svg";
|
|
|
|
const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
const EXPORT_FONT_FAMILY =
|
|
'system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
|
|
|
|
export type NoteHtmlExportMeta = {
|
|
title: string;
|
|
notebook?: string;
|
|
tags?: string[];
|
|
updatedAt?: string;
|
|
language?: string;
|
|
};
|
|
|
|
export type BuildStandaloneHtmlDocumentOptions = NoteHtmlExportMeta & {
|
|
bodyHtml: string;
|
|
styles?: string;
|
|
};
|
|
|
|
export type DownloadNoteHtmlOptions = NoteHtmlExportMeta & {
|
|
bodyHtml: string;
|
|
fallbackTitle: string;
|
|
styles?: string;
|
|
};
|
|
|
|
export type HtmlImageEmbedResult = {
|
|
/** Images with a non-empty src attribute. */
|
|
total: number;
|
|
/** Already data: URLs plus successfully fetched/inlined images. */
|
|
embedded: number;
|
|
/** Images that could not be inlined and may break offline. */
|
|
failed: number;
|
|
};
|
|
|
|
export type PreparedNoteHtmlExport = {
|
|
bodyHtml: string;
|
|
images: HtmlImageEmbedResult;
|
|
};
|
|
|
|
export type DownloadNoteHtmlResult = {
|
|
filename: string;
|
|
images: HtmlImageEmbedResult;
|
|
};
|
|
|
|
export const createEmptyHtmlImageEmbedResult = (): HtmlImageEmbedResult => ({
|
|
total: 0,
|
|
embedded: 0,
|
|
failed: 0,
|
|
});
|
|
|
|
/** Decide whether the UI should warn about image embedding after a successful download. */
|
|
export const getHtmlImageEmbedNoticeKind = (
|
|
images: HtmlImageEmbedResult,
|
|
): "none" | "partial" | "failed-all" => {
|
|
if (images.total === 0 || images.failed === 0) {
|
|
return "none";
|
|
}
|
|
if (images.embedded === 0) {
|
|
return "failed-all";
|
|
}
|
|
return "partial";
|
|
};
|
|
|
|
export const buildHtmlFilename = (title: string, fallback: string) => {
|
|
const sanitized = title
|
|
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-")
|
|
.replace(/\s+/g, " ")
|
|
.replace(/[. ]+$/g, "")
|
|
.trim()
|
|
.slice(0, 100);
|
|
const basename = sanitized || fallback;
|
|
const safeBasename = WINDOWS_RESERVED_NAME.test(basename) ? `_${basename}` : basename;
|
|
return /\.html?$/i.test(safeBasename) ? safeBasename.replace(/\.htm$/i, ".html") : `${safeBasename}.html`;
|
|
};
|
|
|
|
export const escapeHtml = (value: string) =>
|
|
value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
export const buildNoteHtmlContentMarkup = ({
|
|
title,
|
|
notebook = "",
|
|
tags = [],
|
|
updatedAt = "",
|
|
bodyHtml,
|
|
}: Pick<BuildStandaloneHtmlDocumentOptions, "title" | "notebook" | "tags" | "updatedAt" | "bodyHtml">) => {
|
|
const metadataParts = [notebook, updatedAt, ...tags.map((tag) => `#${tag}`)].filter(Boolean);
|
|
const metaHtml = metadataParts.length > 0
|
|
? `<div class="edgeever-html-meta">${escapeHtml(metadataParts.join(" · "))}</div>`
|
|
: "";
|
|
|
|
return `<div class="edgeever-html-shell">
|
|
<article class="edgeever-html-document">
|
|
<h1 class="edgeever-html-title">${escapeHtml(title)}</h1>
|
|
${metaHtml}
|
|
<div class="edgeever-html-content">
|
|
${bodyHtml}
|
|
</div>
|
|
</article>
|
|
</div>`;
|
|
};
|
|
|
|
export const buildStandaloneHtmlDocument = ({
|
|
title,
|
|
notebook = "",
|
|
tags = [],
|
|
updatedAt = "",
|
|
language = "en",
|
|
bodyHtml,
|
|
styles = "",
|
|
}: BuildStandaloneHtmlDocumentOptions) => {
|
|
const contentMarkup = buildNoteHtmlContentMarkup({ title, notebook, tags, updatedAt, bodyHtml });
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="${escapeHtml(language)}">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="generator" content="EdgeEver" />
|
|
<title>${escapeHtml(title)}</title>
|
|
<style>
|
|
${styles}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${contentMarkup}
|
|
</body>
|
|
</html>
|
|
`;
|
|
};
|
|
|
|
export const createHtmlFile = (html: string, title: string, fallback: string) => ({
|
|
blob: new Blob([html], { type: "text/html;charset=utf-8" }),
|
|
filename: buildHtmlFilename(title, fallback),
|
|
});
|
|
|
|
export const downloadHtmlFile = (html: string, title: string, fallback: string) => {
|
|
const { blob, filename } = createHtmlFile(html, title, fallback);
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
link.hidden = true;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
return filename;
|
|
};
|
|
|
|
const blobToDataUrl = (blob: Blob) =>
|
|
new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
if (typeof reader.result === "string") {
|
|
resolve(reader.result);
|
|
return;
|
|
}
|
|
reject(new Error("Failed to encode resource as data URL"));
|
|
};
|
|
reader.onerror = () => reject(reader.error ?? new Error("Failed to read resource blob"));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
|
|
const resolveResourceUrl = (src: string) => {
|
|
try {
|
|
return new URL(src, window.location.href).href;
|
|
} catch {
|
|
return src;
|
|
}
|
|
};
|
|
|
|
export const renderMermaidBlocksForHtmlExport = async (root: HTMLElement) => {
|
|
const blocks = Array.from(root.querySelectorAll<HTMLElement>("pre > code.language-mermaid"));
|
|
await Promise.all(blocks.map(async (code) => {
|
|
const source = code.textContent?.trim();
|
|
const container = code.closest("pre");
|
|
if (!source || !container) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const svg = scaleMermaidSvg(renderMermaidSVG(source, {
|
|
...THEMES["zinc-light"],
|
|
...MERMAID_THEME_PALETTES["zinc-light"],
|
|
transparent: true,
|
|
font: EXPORT_FONT_FAMILY,
|
|
padding: 24,
|
|
}));
|
|
const figure = document.createElement("figure");
|
|
figure.className = "edgeever-html-mermaid";
|
|
figure.innerHTML = svg;
|
|
container.replaceWith(figure);
|
|
} catch {
|
|
// Keep invalid Mermaid source readable in the exported document.
|
|
}
|
|
}));
|
|
};
|
|
|
|
export const highlightCodeBlocksForHtmlExport = (root: HTMLElement) => {
|
|
root.querySelectorAll<HTMLElement>("pre > code:not(.language-mermaid)").forEach((code) => {
|
|
try {
|
|
hljs.highlightElement(code);
|
|
} catch {
|
|
// Leave unhighlighted source readable when language detection fails.
|
|
}
|
|
});
|
|
};
|
|
|
|
export const embedImagesForHtmlExport = async (root: HTMLElement): Promise<HtmlImageEmbedResult> => {
|
|
const images = Array.from(root.querySelectorAll<HTMLImageElement>("img[src]"));
|
|
const result = createEmptyHtmlImageEmbedResult();
|
|
|
|
await Promise.all(images.map(async (image) => {
|
|
const src = image.getAttribute("src")?.trim();
|
|
if (!src) {
|
|
return;
|
|
}
|
|
|
|
result.total += 1;
|
|
|
|
if (src.startsWith("data:")) {
|
|
result.embedded += 1;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(src.startsWith("blob:") ? src : resolveResourceUrl(src), {
|
|
credentials: src.startsWith("blob:") ? "omit" : "include",
|
|
});
|
|
if (!response.ok) {
|
|
result.failed += 1;
|
|
return;
|
|
}
|
|
image.setAttribute("src", await blobToDataUrl(await response.blob()));
|
|
result.embedded += 1;
|
|
} catch {
|
|
// Leave the original URL so partial exports still open, but report the miss.
|
|
result.failed += 1;
|
|
}
|
|
}));
|
|
|
|
return result;
|
|
};
|
|
|
|
/** Enrich note body HTML for a self-contained offline file. */
|
|
export const prepareNoteBodyHtmlForExport = async (bodyHtml: string): Promise<PreparedNoteHtmlExport> => {
|
|
const container = document.createElement("div");
|
|
container.innerHTML = bodyHtml;
|
|
await renderMermaidBlocksForHtmlExport(container);
|
|
highlightCodeBlocksForHtmlExport(container);
|
|
const images = await embedImagesForHtmlExport(container);
|
|
return {
|
|
bodyHtml: container.innerHTML,
|
|
images,
|
|
};
|
|
};
|
|
|
|
export const downloadNoteHtmlFile = async ({
|
|
bodyHtml,
|
|
title,
|
|
notebook,
|
|
tags,
|
|
updatedAt,
|
|
language,
|
|
fallbackTitle,
|
|
styles = "",
|
|
}: DownloadNoteHtmlOptions): Promise<DownloadNoteHtmlResult> => {
|
|
const prepared = await prepareNoteBodyHtmlForExport(bodyHtml);
|
|
const html = buildStandaloneHtmlDocument({
|
|
title,
|
|
notebook,
|
|
tags,
|
|
updatedAt,
|
|
language,
|
|
bodyHtml: prepared.bodyHtml,
|
|
styles,
|
|
});
|
|
return {
|
|
filename: downloadHtmlFile(html, title, fallbackTitle),
|
|
images: prepared.images,
|
|
};
|
|
};
|