diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx
index 7a9b993..b997f1c 100644
--- a/apps/web/src/components/mode-toggle.tsx
+++ b/apps/web/src/components/mode-toggle.tsx
@@ -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;
@@ -61,6 +63,16 @@ export function ModeToggle(props: {
Sync scroll
)}
+ {props.onExportHtml && (
+
+ )}
{SAVE_LABEL[props.saveState]}
diff --git a/apps/web/src/components/note-editor.tsx b/apps/web/src/components/note-editor.tsx
index 58d879d..6cb4aaa 100644
--- a/apps/web/src/components/note-editor.tsx
+++ b/apps/web/src/components/note-editor.tsx
@@ -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";
@@ -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(".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)
@@ -400,6 +410,7 @@ export function NoteEditor({
saveState={saveState}
supportedModes={descriptorSupportedModes}
supportsScrollSync={descriptorSupportsScrollSync}
+ onExportHtml={showRendered && noteRenderer ? handleExportHtml : undefined}
/>
)}
{pluginHandler ? (
diff --git a/apps/web/src/lib/export-html.test.ts b/apps/web/src/lib/export-html.test.ts
new file mode 100644
index 0000000..ebe0acf
--- /dev/null
+++ b/apps/web/src/lib/export-html.test.ts
@@ -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", "Hello
");
+ expect(result).toContain("");
+ expect(result).toContain("My Note");
+ expect(result).toContain("Hello
");
+ });
+
+ it("escapes angle brackets and ampersands in the title", () => {
+ const result = buildHtmlDocument("Notes & Things", "body");
+ expect(result).toContain("<b>Notes & Things</b>");
+ expect(result).not.toContain("");
+ });
+
+ it("includes the export stylesheet", () => {
+ const result = buildHtmlDocument("T", "");
+ expect(result).toContain("
+
+
+${bodyHtml}
+
+