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} + +`; +} + +/** + * 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 { + 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 { + 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"; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c62c557..3bff917 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; } @@ -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; diff --git a/package-lock.json b/package-lock.json index 12cd6a4..bd92106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4713,6 +4713,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0"