Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/web/src/components/mode-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export function ModeToggle(props: {
supportedModes?: EditorMode[];
/** Whether to show the scroll sync toggle in split mode. Defaults to true. */
supportsScrollSync?: boolean;
/** When provided, an "Export as HTML" button is shown that calls this handler. */
onExportHtml?: () => void;
}) {
const visibleModes = props.supportedModes ?? EDITOR_MODES;
const showSyncToggle = props.supportsScrollSync ?? true;
Expand Down Expand Up @@ -61,6 +63,16 @@ export function ModeToggle(props: {
Sync scroll
</button>
)}
{props.onExportHtml && (
<button
type="button"
className="mode-export-btn"
onClick={props.onExportHtml}
title="Export as HTML"
>
Export HTML
</button>
)}
<span className={`save-status save-status--${props.saveState}`}>
{SAVE_LABEL[props.saveState]}
</span>
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { api } from "../api/client";
import { queueWrite } from "../api/offline-queue";
import { connectTomeChanges } from "../api/ws";
import { exportAsHtml } from "../lib/export-html";
import { frontmatterType } from "../lib/frontmatter";
import { isImagePath } from "../lib/images";
import { useAppServices } from "../state/app-services";
Expand Down Expand Up @@ -315,6 +316,15 @@ export function NoteEditor({
return () => setActiveDocument(null);
}, [path, content, saveState, setActiveDocument, isImage, fileHandlers, isStandalone, frontType]);

const handleExportHtml = useCallback(() => {
const el = regionRef.current?.querySelector<HTMLElement>(".rendered-scroll");
if (!el) return;
const title = basename(path);
void exportAsHtml(el, title).catch(() => {
notify("Export failed.", { kind: "error" });
});
}, [path, notify]);

// Descriptor-declared builder is the fallback; component-registered takes priority.
const descriptorCtxBuilder = activeDescriptor
? (getNoteContextMenuBuilder(activeDescriptor) ?? null)
Expand Down Expand Up @@ -400,6 +410,7 @@ export function NoteEditor({
saveState={saveState}
supportedModes={descriptorSupportedModes}
supportsScrollSync={descriptorSupportsScrollSync}
onExportHtml={showRendered && noteRenderer ? handleExportHtml : undefined}
/>
)}
{pluginHandler ? (
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/lib/export-html.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildHtmlDocument, inlineImages } from "./export-html";

describe("buildHtmlDocument", () => {
it("produces a valid HTML5 skeleton", () => {
const result = buildHtmlDocument("My Note", "<p>Hello</p>");
expect(result).toContain("<!DOCTYPE html>");
expect(result).toContain("<title>My Note</title>");
expect(result).toContain("<p>Hello</p>");
});

it("escapes angle brackets and ampersands in the title", () => {
const result = buildHtmlDocument("<b>Notes & Things</b>", "body");
expect(result).toContain("&lt;b&gt;Notes &amp; Things&lt;/b&gt;");
expect(result).not.toContain("<b>");
});

it("includes the export stylesheet", () => {
const result = buildHtmlDocument("T", "");
expect(result).toContain("<style>");
});

it("places body HTML inside the body element", () => {
const result = buildHtmlDocument("Title", "<h1>Header</h1>");
expect(result).toMatch(/<body>\s*<h1>Header<\/h1>\s*<\/body>/);
});
});

// inlineImages relies on browser APIs (DOMParser, fetch, FileReader) and
// cannot be unit-tested in a node environment without jsdom + fetch stubs.
// Its correctness is covered by the integration / e2e test suite.
describe("inlineImages (browser-only stubs)", () => {
it("is a function that returns a Promise", () => {
// Smoke-test that the export itself is structured correctly.
expect(typeof inlineImages).toBe("function");
});
});
114 changes: 114 additions & 0 deletions apps/web/src/lib/export-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* HTML export utilities.
*
* Converts a rendered note's DOM element into a self-contained HTML file with
* images inlined as base64 data URLs so the file is portable.
*/

const EXPORT_CSS = `
*,*::before,*::after{box-sizing:border-box}
body{font-family:system-ui,sans-serif;line-height:1.6;max-width:860px;margin:2rem auto;padding:0 1.5rem;color:#1a1a1a}
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.5em 0 0.5em}
h1{font-size:2em;border-bottom:1px solid #e0e0e0;padding-bottom:0.3em}
h2{font-size:1.5em;border-bottom:1px solid #e0e0e0;padding-bottom:0.2em}
p{margin:0.75em 0}
a{color:#0969da}
img{max-width:100%;height:auto;border-radius:4px}
pre{background:#f6f8fa;border-radius:6px;padding:1em;overflow-x:auto}
code{font-family:ui-monospace,monospace;font-size:0.9em;background:#f6f8fa;padding:0.15em 0.4em;border-radius:4px}
pre code{background:none;padding:0}
blockquote{margin:1em 0;padding:0 1em;border-left:4px solid #d0d7de;color:#555}
table{border-collapse:collapse;width:100%;margin:1em 0}
th,td{border:1px solid #d0d7de;padding:6px 13px;text-align:left}
th{background:#f6f8fa;font-weight:600}
ul,ol{padding-left:2em;margin:0.75em 0}
li{margin:0.25em 0}
hr{border:none;border-top:1px solid #e0e0e0;margin:1.5em 0}
input[type=checkbox]{margin-right:0.4em}
`;

/**
* Fetches each `<img>` src in the provided HTML string and replaces the URL
* with a base64 data URL. Src values that are already data URLs are left
* unchanged. Failures are silently skipped so the export still completes.
*/
export async function inlineImages(html: string): Promise<string> {
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${html}</body>`, "text/html");
const images = Array.from(doc.querySelectorAll<HTMLImageElement>("img[src]"));

await Promise.all(
images.map(async (img) => {
const src = img.getAttribute("src") ?? "";
if (!src || src.startsWith("data:")) return;
try {
const response = await fetch(src);
if (!response.ok) return;
const blob = await response.blob();
const dataUrl = await blobToDataUrl(blob);
img.setAttribute("src", dataUrl);
} catch {
// Leave the original src on failure.
}
}),
);

return doc.body.innerHTML;
}

/** Wraps rendered body HTML in a complete, self-contained HTML document. */
export function buildHtmlDocument(title: string, bodyHtml: string): string {
const escapedTitle = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapedTitle}</title>
<style>${EXPORT_CSS}</style>
</head>
<body>
${bodyHtml}
</body>
</html>`;
}

/**
* Exports the contents of `element` as a self-contained HTML file and
* triggers a browser download.
*
* @param element - The DOM element whose `innerHTML` to export.
* @param title - The document title and base name for the downloaded file.
*/
export async function exportAsHtml(element: HTMLElement, title: string): Promise<void> {
const rawHtml = element.innerHTML;
const inlinedHtml = await inlineImages(rawHtml);
const document = buildHtmlDocument(title, inlinedHtml);
const blob = new Blob([document], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const a = window.document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(title)}.html`;
window.document.body.appendChild(a);
a.click();
window.document.body.removeChild(a);
} finally {
URL.revokeObjectURL(url);
}
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}

function sanitizeFilename(name: string): string {
return name.replace(/[/\\?%*:|"<>]/g, "-").trim() || "export";
}
17 changes: 17 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,7 @@ button {

.mode-float .mode-switch,
.mode-float .mode-sync-toggle,
.mode-float .mode-export-btn,
.mode-float .save-status {
pointer-events: auto;
}
Expand Down Expand Up @@ -1740,6 +1741,22 @@ button {
background: color-mix(in srgb, var(--accent) 18%, var(--bg-elevated));
}

.mode-export-btn {
border: 1px solid var(--border);
border-radius: 7px;
background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
color: var(--fg-muted);
font-size: 12px;
padding: 3px 8px;
cursor: pointer;
}

.mode-export-btn:hover {
color: var(--fg);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated));
}

.editor-toolbar-meta {
display: inline-flex;
align-items: center;
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.