From 813c8278c7d239f8ff0916700b2f735376cfabcf Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 18 Jun 2026 21:04:44 +0800 Subject: [PATCH] fix(desktop): Export as HTML exports the current doc + lets you choose where MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in "Export as HTML": 1. It always exported a fixed "Welcome to Markup" document. The menu listener is registered once (empty-deps effect), so handleExportHtml closed over the first render's `tab` — the initial welcome buffer — not the active one. It now reads the live active tab from the store (useAppStore.getState()), matching how Save / Save As already work. Same stale-closure fix applied to Export PDF. 2. It silently downloaded to ~/Downloads. It now opens a Save dialog so the user picks the destination (filtered to .html, path authorized via the existing save-panel grant), writes the rendered HTML there, and shows an "Exported …" toast. Cancelling the dialog is a no-op. - tauri.ts: pickHtmlSavePath (save dialog + authorize). - App.tsx: handleExportHtml/handleExportPdf read the live tab; export writes to the chosen path via the existing write_file. - Removed the now-dead browser-download exportHtml/downloadString helpers. - EN + 中文 "Exported {0}" toast. tsc ✓ · biome ✓ · build ✓. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 34 +++++++++++++++++++++++++++------- src/lib/export.ts | 22 ---------------------- src/lib/locales/en.ts | 1 + src/lib/locales/zh.ts | 1 + src/lib/tauri.ts | 12 ++++++++++++ 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b6c8e84..b18f8fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,7 +69,7 @@ import { import { formatTable, toggleTaskCheckboxOnLine } from "./lib/cm-table-format"; import { collapseBlankLines } from "./lib/collapse-blanks"; import { sliceEmbed, splitEmbedTarget } from "./lib/embed-slice"; -import { exportHtml, exportPdfViaPrint } from "./lib/export"; +import { exportPdfViaPrint } from "./lib/export"; import { installFocusTypewriter } from "./lib/focus-typewriter"; import { wikilinkAtCursor } from "./lib/follow-wikilink"; import { @@ -131,6 +131,7 @@ import { refreshGitHubVault, openNewWindow, openVault, + pickHtmlSavePath, pickSavePath, pickVault, pushRecentFileNative, @@ -1215,19 +1216,38 @@ export function App() { }, []); async function handleExportHtml() { - if (!tab) return; - const baseName = (tab.name || "Untitled").replace(/\.[^.]+$/, ""); + // Read the live active tab from the store, not a captured render closure: + // the menu listener is registered once, so a closed-over `tab` would always + // be the first (welcome) document. + const state = useAppStore.getState(); + const t = state.activeTabId + ? state.tabs.find((x) => x.id === state.activeTabId) + : null; + if (!t) return; + const baseName = (t.name || "Untitled").replace(/\.[^.]+$/, ""); try { - await exportHtml(tab.content, baseName, exportTheme); + // Let the user pick where to save (instead of a silent Downloads dump). + const raw = await pickHtmlSavePath(`${baseName}.html`); + if (!raw) return; // cancelled + const target = /\.html?$/i.test(raw) ? raw : `${raw}.html`; + const html = await renderHtml(t.content, baseName, exportTheme); + await writeFile(target, html, null); + showToast(tr("toast.exportedHtml", target.split("/").pop() || target)); } catch (e) { console.error("exportHtml failed", e); + setActiveStatus("error", String(e)); } } async function handleExportPdf() { - if (!tab) return; - const title = tab.name || "Untitled"; + // Same stale-closure fix as handleExportHtml — read the live active tab. + const state = useAppStore.getState(); + const t = state.activeTabId + ? state.tabs.find((x) => x.id === state.activeTabId) + : null; + if (!t) return; + const title = t.name || "Untitled"; try { - await exportPdfViaPrint(tab.content, title, exportTheme); + await exportPdfViaPrint(t.content, title, exportTheme); } catch (e) { console.error("exportPdf failed", e); } diff --git a/src/lib/export.ts b/src/lib/export.ts index 561c5dd..85dac82 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -40,25 +40,3 @@ export async function exportPdfViaPrint( win.focus(); win.print(); } - -/** Trigger a download of a string as `filename` with given mime. */ -function downloadString(text: string, filename: string, mime: string) { - const blob = new Blob([text], { type: mime }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 1000); -} - -export async function exportHtml( - content: string, - baseName: string, - theme: ExportTheme = "github", -) { - const html = await renderHtml(content, baseName, theme); - downloadString(html, `${baseName}.html`, "text/html"); -} diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index 84e975a..af02f56 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -112,6 +112,7 @@ export const en = { "toast.githubUpdated": "GitHub vault updated ({0})", "toast.githubUpToDate": "Already up to date", "toast.githubPullFailed": "Couldn't pull from GitHub", + "toast.exportedHtml": "Exported {0}", "github.confirmPullDirty": "{0} file(s) have local changes since the last sync. Pulling from GitHub will overwrite them. Continue?", "toast.renameNoFile": "No file to rename", diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts index f54c4e7..4054b50 100644 --- a/src/lib/locales/zh.ts +++ b/src/lib/locales/zh.ts @@ -113,6 +113,7 @@ export const zh: Strings = { "toast.githubUpdated": "GitHub 仓库已更新({0})", "toast.githubUpToDate": "已是最新", "toast.githubPullFailed": "无法从 GitHub 拉取", + "toast.exportedHtml": "已导出 {0}", "github.confirmPullDirty": "有 {0} 个文件在上次同步后被本地修改过。从 GitHub 拉取会覆盖这些改动。是否继续?", "toast.renameNoFile": "没有可重命名的文件", diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index c450e22..53dc97d 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -24,6 +24,18 @@ export async function pickSavePath(defaultName: string): Promise return path ?? null; } +/** Save dialog for an exported HTML file (filters to .html). Authorizes the + * chosen path for writing, like pickSavePath. */ +export async function pickHtmlSavePath(defaultName: string): Promise { + const path = await saveDialog({ + title: "Export as HTML", + defaultPath: defaultName, + filters: [{ name: "HTML", extensions: ["html", "htm"] }], + }); + if (path) await authorizePaths([path]); + return path ?? null; +} + export async function openFileDialog(): Promise { return await invoke("open_file"); }