Add single-note HTML export with offline image embedding
Allow exporting the current note as a standalone .html file from the editor and list menus, embedding images when possible and warning when some cannot be inlined offline. Closes the HTML gap left from single-note Markdown/PDF export (#218).
This commit is contained in:
1 parent
fe959f14af
commit
f08b9591e1
11 files changed
+758
-3
No files matched your search
@@ -42,7 +42,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s
|
||||
- **Unlimited Nested Notebooks**: Organize your knowledge with arbitrary folder depth.
|
||||
- **One-Click Rich Copy for Newsletters & Blogs**: Designed for creators to convert notes into beautifully formatted rich text with inline CSS, ready to paste directly into Substack, Medium, WordPress, or newsletter editors without extra tools.
|
||||
- **Seamless Dual-View Editor**: Switch effortlessly between intuitive rich text editing and Markdown source code on desktop.
|
||||
- **Convenient Single-Note Export**: Export the current note directly as Markdown or PDF for standalone storage, sharing, or publishing.
|
||||
- **Convenient Single-Note Export**: Export the current note directly as Markdown, HTML, or PDF for standalone storage, sharing, or publishing.
|
||||
- **Native Mermaid Diagram Rendering**: Render clear flowcharts, sequence diagrams, and mind maps directly in notes, preserving clean, editable source code across Markdown and rich text views.
|
||||
- **Revision History**: Inspect and restore previous iterations of your notes with built-in version tracking.
|
||||
- **Public Note Sharing**: Share a note publicly and stop sharing it at any time.
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印
|
||||
- **无限层级笔记本**:轻松构建清晰的多级目录结构。
|
||||
- **微信公众号一键排版与复制**:专为中文创作者设计,支持将笔记一键转换为带行内样式的公众号美化格式,直接复制粘贴至微信公众号后台,告别复杂的第三方排版工具。
|
||||
- **优雅的双视图编辑**:桌面端支持在富文本与 Markdown 源码视图之间自由切换。
|
||||
- **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown 或 PDF,方便独立保存、分享与发布。
|
||||
- **单篇笔记便捷导出**:可将当前笔记直接导出为 Markdown、HTML 或 PDF,方便独立保存、分享与发布。
|
||||
- **Mermaid 架构图与流程图渲染**:原生支持 Mermaid 代码块渲染,视图切换时完整保留可编辑源码,让绘制逻辑图表更直观。
|
||||
- **笔记历史版本回溯**:自动记录修改历史,随时查阅与还原过往版本。
|
||||
- **公开笔记分享**:支持公开分享笔记,并可随时取消分享。
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
LoaderCircle,
|
||||
Info,
|
||||
FileDown,
|
||||
FileCode2,
|
||||
Printer,
|
||||
Link2,
|
||||
Copy,
|
||||
@@ -130,6 +131,8 @@ import { SystemInfoDialog } from "./SystemInfoDialog";
|
||||
import { fetchLatestRelease, isVersionOutdated } from "@/lib/version-check";
|
||||
import { RELEASE_STATUS_EVENT } from "@/lib/release-notice";
|
||||
import { downloadMarkdownFile } from "@/lib/note-markdown-export";
|
||||
import { NOTE_HTML_FULL_STYLES } from "@/lib/note-html-export-assets";
|
||||
import { downloadNoteHtmlFile, getHtmlImageEmbedNoticeKind } from "@/lib/note-html-export";
|
||||
import { openNotePrintPreview, serializeNoteDocumentForPrint } from "@/lib/note-print";
|
||||
import { isBrowserOffline } from "@/lib/network-status";
|
||||
import {
|
||||
@@ -2411,6 +2414,60 @@ const RichEditorPane = ({
|
||||
useMobilePlainTextEditor,
|
||||
]);
|
||||
|
||||
const handleExportHtml = useCallback(async () => {
|
||||
if (!isEditorReady(editor) || !memo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDocument = useMobilePlainTextEditor
|
||||
? markdownToDoc(getMobilePlainTextValue())
|
||||
: useMarkdownSourceEditor
|
||||
? markdownToDoc(markdownSource)
|
||||
: editor.getJSON() as TiptapDoc;
|
||||
const bodyHtml = serializeNoteDocumentForPrint(editor, currentDocument);
|
||||
|
||||
try {
|
||||
const { images } = await downloadNoteHtmlFile({
|
||||
bodyHtml,
|
||||
title: title.trim() || t("common.untitledMemo"),
|
||||
notebook: notebookOptions.find((notebook) => notebook.id === memo.notebookId)?.name ?? "",
|
||||
tags: parseTagsText(tagsText),
|
||||
updatedAt: formatDateTime(memo.updatedAt),
|
||||
language: i18n.resolvedLanguage ?? i18n.language,
|
||||
fallbackTitle: t("common.untitledMemo"),
|
||||
styles: NOTE_HTML_FULL_STYLES,
|
||||
});
|
||||
|
||||
const noticeKind = getHtmlImageEmbedNoticeKind(images);
|
||||
if (noticeKind === "partial") {
|
||||
window.alert(t("editor.htmlExport.imageEmbedPartial", {
|
||||
embedded: images.embedded,
|
||||
total: images.total,
|
||||
failed: images.failed,
|
||||
}));
|
||||
} else if (noticeKind === "failed-all") {
|
||||
window.alert(t("editor.htmlExport.imageEmbedFailed", {
|
||||
total: images.total,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
window.alert(t("editor.htmlExport.error"));
|
||||
}
|
||||
}, [
|
||||
editor,
|
||||
getMobilePlainTextValue,
|
||||
i18n.language,
|
||||
i18n.resolvedLanguage,
|
||||
markdownSource,
|
||||
memo,
|
||||
notebookOptions,
|
||||
t,
|
||||
tagsText,
|
||||
title,
|
||||
useMarkdownSourceEditor,
|
||||
useMobilePlainTextEditor,
|
||||
]);
|
||||
|
||||
const handleSaveAsTemplate = useCallback(() => {
|
||||
if (!memo) {
|
||||
return;
|
||||
@@ -2455,6 +2512,9 @@ const RichEditorPane = ({
|
||||
case "export-markdown":
|
||||
handleExportMarkdown();
|
||||
break;
|
||||
case "export-html":
|
||||
void handleExportHtml();
|
||||
break;
|
||||
case "export-pdf":
|
||||
handleExportPdf(documentActionRequest.printWindow);
|
||||
break;
|
||||
@@ -2465,6 +2525,7 @@ const RichEditorPane = ({
|
||||
}, [
|
||||
documentActionRequest,
|
||||
editor,
|
||||
handleExportHtml,
|
||||
handleExportMarkdown,
|
||||
handleExportPdf,
|
||||
handleSaveAsTemplate,
|
||||
@@ -3648,6 +3709,13 @@ const RichEditorPane = ({
|
||||
<FileDown className="h-4 w-4 text-slate-500" />
|
||||
{t("editor.exportMarkdown")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={() => void handleExportHtml()}
|
||||
>
|
||||
<FileCode2 className="h-4 w-4 text-slate-500" />
|
||||
{t("editor.exportHtml")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={() => handleExportPdf()}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
TagX,
|
||||
Link2,
|
||||
FileDown,
|
||||
FileCode2,
|
||||
Printer,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
@@ -1538,6 +1539,13 @@ export const MemoListPane = ({
|
||||
<FileDown className="h-4 w-4 text-slate-500" />
|
||||
{t("editor.exportMarkdown")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={() => requestContextDocumentAction("export-html")}
|
||||
>
|
||||
<FileCode2 className="h-4 w-4 text-slate-500" />
|
||||
{t("editor.exportHtml")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={() => requestContextDocumentAction("export-pdf")}
|
||||
|
||||
@@ -22,7 +22,7 @@ export type ShortcutBinding = {
|
||||
export type ShortcutSettings = Record<ShortcutAction, ShortcutBinding>;
|
||||
export type MobileBottomNavItem = "home" | "search" | "templates" | "settings";
|
||||
export type MemoContextMenuState = { memo: MemoSummary; x: number; y: number };
|
||||
export type MemoDocumentAction = "share" | "export-markdown" | "export-pdf" | "save-as-template";
|
||||
export type MemoDocumentAction = "share" | "export-markdown" | "export-html" | "export-pdf" | "save-as-template";
|
||||
export type MemoDocumentActionRequest = {
|
||||
id: number;
|
||||
memoId: string;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import katexStyles from "katex/dist/katex.min.css?inline";
|
||||
import exportStyles from "@/styles/note-html-export.css?inline";
|
||||
|
||||
/** Full stylesheet bundle for offline standalone HTML notes (browser/Vite only). */
|
||||
export const NOTE_HTML_FULL_STYLES = `${katexStyles}\n${exportStyles}`;
|
||||
@@ -0,0 +1,275 @@
|
||||
import hljs from "highlight.js/lib/common";
|
||||
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
|
||||
|
||||
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 buildStandaloneHtmlDocument = ({
|
||||
title,
|
||||
notebook = "",
|
||||
tags = [],
|
||||
updatedAt = "",
|
||||
language = "en",
|
||||
bodyHtml,
|
||||
styles = "",
|
||||
}: BuildStandaloneHtmlDocumentOptions) => {
|
||||
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 `<!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>
|
||||
<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>
|
||||
</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 = renderMermaidSVG(source, {
|
||||
...THEMES["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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f8fafc;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.edgeever-html-shell {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.edgeever-html-document {
|
||||
color: #172033;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.72;
|
||||
overflow-wrap: anywhere;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 2rem 1.75rem 2.5rem;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
}
|
||||
|
||||
.edgeever-html-title {
|
||||
margin: 0 0 0.75rem;
|
||||
color: #0f172a;
|
||||
font-size: 2rem;
|
||||
font-weight: 760;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.edgeever-html-meta {
|
||||
margin: 0 0 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 0.85rem;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.edgeever-html-content h1,
|
||||
.edgeever-html-content h2,
|
||||
.edgeever-html-content h3 {
|
||||
color: #0f172a;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.edgeever-html-content h1 {
|
||||
margin: 1.75rem 0 0.75rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content h2 {
|
||||
margin: 1.5rem 0 0.65rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content h3 {
|
||||
margin: 1.25rem 0 0.55rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content p {
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content ul,
|
||||
.edgeever-html-content ol {
|
||||
margin: 0 0 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.edgeever-html-content ul[data-type="taskList"] {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.edgeever-html-content li[data-type="taskItem"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content li[data-type="taskItem"] > label {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content li[data-type="taskItem"] > label input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
accent-color: #16a06e;
|
||||
}
|
||||
|
||||
.edgeever-html-content li[data-type="taskItem"] > div {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.edgeever-html-content li[data-type="taskItem"] > div > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.edgeever-html-content ul[data-type="taskList"] ul[data-type="taskList"] {
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content blockquote {
|
||||
margin: 1rem 0;
|
||||
border-left: 4px solid #10b981;
|
||||
background: #f0fdf4;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.edgeever-html-content hr {
|
||||
margin: 1.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.edgeever-html-content hr.edgeever-merge-divider,
|
||||
.edgeever-html-content hr[data-edgeever-merge-divider] {
|
||||
margin: 1.75rem 0;
|
||||
border: 0;
|
||||
border-top: 2px solid #66ca80;
|
||||
background: transparent;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.edgeever-html-content a {
|
||||
color: #047857;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edgeever-html-content code {
|
||||
border-radius: 0.25rem;
|
||||
background: #f1f5f9;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.edgeever-html-content pre,
|
||||
.edgeever-html-content .edgeever-code-source {
|
||||
max-width: 100%;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #dbe4ea;
|
||||
border-radius: 0.5rem;
|
||||
background: #f8fafc;
|
||||
padding: 0.85rem 1rem;
|
||||
color: #1e293b;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
overflow-x: auto;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.edgeever-html-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.edgeever-html-content img,
|
||||
.edgeever-html-mermaid svg {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.edgeever-html-content figure,
|
||||
.edgeever-html-mermaid {
|
||||
max-width: 100%;
|
||||
margin: 1.25rem auto;
|
||||
}
|
||||
|
||||
.edgeever-html-mermaid svg {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.edgeever-html-content table {
|
||||
width: 100%;
|
||||
margin: 1.25rem 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.edgeever-html-content th,
|
||||
.edgeever-html-content td {
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 0.5rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.edgeever-html-content th {
|
||||
background: #f1f5f9;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.edgeever-html-content [data-edgeever-theme-block] {
|
||||
margin: 1.25rem 0;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-left: 4px solid #059669;
|
||||
border-radius: 0.5rem;
|
||||
background: #f0fdf4;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.edgeever-html-content [data-theme-block-kind="chapter"] {
|
||||
border-width: 3px 0 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.edgeever-html-content .tableWrapper {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.edgeever-html-content .tiptap-mathematics-render[data-type="block-math"] {
|
||||
max-width: 100%;
|
||||
margin: 1.25rem 0;
|
||||
overflow-x: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edgeever-html-content .edgeever-image-controls,
|
||||
.edgeever-html-content .edgeever-code-copy-button,
|
||||
.edgeever-html-content .edgeever-theme-block__toolbar,
|
||||
.edgeever-html-content .edgeever-mermaid-source {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-literal {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-type,
|
||||
.hljs-attribute {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.edgeever-html-shell {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.edgeever-html-document {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -809,7 +809,15 @@ export const enUS = {
|
||||
noteIdCopied: "Note ID copied: {{id}}",
|
||||
noteIdCopyFailed: "Could not copy the note ID.",
|
||||
exportMarkdown: "Export Markdown",
|
||||
exportHtml: "Export HTML",
|
||||
exportPdf: "Export PDF",
|
||||
htmlExport: {
|
||||
error: "Could not export the note as HTML.",
|
||||
imageEmbedPartial:
|
||||
"HTML exported. Embedded {{embedded}} of {{total}} images. {{failed}} image(s) could not be embedded and may not display when the file is opened offline.",
|
||||
imageEmbedFailed:
|
||||
"HTML exported, but none of the {{total}} images could be embedded. They may not display when the file is opened offline.",
|
||||
},
|
||||
pdfExport: {
|
||||
preparing: "Preparing print preview…",
|
||||
ready: "Print preview is ready",
|
||||
|
||||
@@ -809,7 +809,15 @@ export const zhCN = {
|
||||
noteIdCopied: "笔记 ID 已复制:{{id}}",
|
||||
noteIdCopyFailed: "复制笔记 ID 失败。",
|
||||
exportMarkdown: "导出 Markdown",
|
||||
exportHtml: "导出 HTML",
|
||||
exportPdf: "导出 PDF",
|
||||
htmlExport: {
|
||||
error: "导出 HTML 失败。",
|
||||
imageEmbedPartial:
|
||||
"HTML 已导出。已内嵌 {{embedded}}/{{total}} 张图片;另有 {{failed}} 张未能内嵌,离线打开时可能无法显示。",
|
||||
imageEmbedFailed:
|
||||
"HTML 已导出,但全部 {{total}} 张图片都未能内嵌,离线打开时可能无法显示。",
|
||||
},
|
||||
pdfExport: {
|
||||
preparing: "正在生成打印预览…",
|
||||
ready: "打印预览已就绪",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildHtmlFilename,
|
||||
buildStandaloneHtmlDocument,
|
||||
createEmptyHtmlImageEmbedResult,
|
||||
createHtmlFile,
|
||||
escapeHtml,
|
||||
getHtmlImageEmbedNoticeKind,
|
||||
} from "../apps/web/src/lib/note-html-export";
|
||||
|
||||
describe("single-note HTML export", () => {
|
||||
test("creates a UTF-8 HTML file with the current content", async () => {
|
||||
const file = createHtmlFile(
|
||||
"<!DOCTYPE html><html><body><p>当前未保存内容</p></body></html>",
|
||||
"项目 / 计划",
|
||||
"Untitled note",
|
||||
);
|
||||
|
||||
expect(file.filename).toBe("项目 - 计划.html");
|
||||
expect(file.blob.type).toBe("text/html;charset=utf-8");
|
||||
expect(await file.blob.text()).toContain("当前未保存内容");
|
||||
});
|
||||
|
||||
test("sanitizes unsafe and reserved filenames", () => {
|
||||
expect(buildHtmlFilename("CON", "Untitled note")).toBe("_CON.html");
|
||||
expect(buildHtmlFilename('<>:"/\\|?*', "Untitled note")).toBe("---------.html");
|
||||
expect(buildHtmlFilename("...", "Untitled note")).toBe("Untitled note.html");
|
||||
expect(buildHtmlFilename("README.html", "Untitled note")).toBe("README.html");
|
||||
expect(buildHtmlFilename("notes.htm", "Untitled note")).toBe("notes.html");
|
||||
});
|
||||
|
||||
test("escapes HTML special characters", () => {
|
||||
expect(escapeHtml(`<script>alert("x")</script>&'`)).toBe(
|
||||
"<script>alert("x")</script>&'",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds a standalone HTML document with metadata and styles", () => {
|
||||
const html = buildStandaloneHtmlDocument({
|
||||
title: 'Release <notes> & "docs"',
|
||||
notebook: "Product",
|
||||
tags: ["ship", "html"],
|
||||
updatedAt: "2026-08-11 12:00",
|
||||
language: "zh-CN",
|
||||
bodyHtml: "<p>Hello <strong>world</strong></p>",
|
||||
styles: "body { color: #111; }",
|
||||
});
|
||||
|
||||
expect(html).toContain("<!DOCTYPE html>");
|
||||
expect(html).toContain('lang="zh-CN"');
|
||||
expect(html).toContain("<title>Release <notes> & "docs"</title>");
|
||||
expect(html).toContain('<h1 class="edgeever-html-title">Release <notes> & "docs"</h1>');
|
||||
expect(html).toContain("Product · 2026-08-11 12:00 · #ship · #html");
|
||||
expect(html).toContain("<p>Hello <strong>world</strong></p>");
|
||||
expect(html).toContain("body { color: #111; }");
|
||||
expect(html).toContain('meta name="generator" content="EdgeEver"');
|
||||
});
|
||||
|
||||
test("omits empty metadata row", () => {
|
||||
const html = buildStandaloneHtmlDocument({
|
||||
title: "Untitled",
|
||||
bodyHtml: "<p>body</p>",
|
||||
});
|
||||
|
||||
expect(html).not.toContain("edgeever-html-meta");
|
||||
expect(html).toContain("<p>body</p>");
|
||||
});
|
||||
|
||||
test("only warns about image embedding when some or all images fail", () => {
|
||||
expect(getHtmlImageEmbedNoticeKind(createEmptyHtmlImageEmbedResult())).toBe("none");
|
||||
expect(getHtmlImageEmbedNoticeKind({ total: 3, embedded: 3, failed: 0 })).toBe("none");
|
||||
expect(getHtmlImageEmbedNoticeKind({ total: 3, embedded: 2, failed: 1 })).toBe("partial");
|
||||
expect(getHtmlImageEmbedNoticeKind({ total: 2, embedded: 0, failed: 2 })).toBe("failed-all");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user