From 566ad4d08181681327cbdf7dac4cc9878c4ba774 Mon Sep 17 00:00:00 2001 From: PathGao Date: Sat, 1 Aug 2026 12:42:42 +0800 Subject: [PATCH 1/4] test: refresh stale toolbar UI contracts --- scripts/reloadOpenToolbar.test.ts | 6 +++--- scripts/toolbarCustomizationWiring.test.ts | 24 +++++++++------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/scripts/reloadOpenToolbar.test.ts b/scripts/reloadOpenToolbar.test.ts index b7830656..6c2ed434 100644 --- a/scripts/reloadOpenToolbar.test.ts +++ b/scripts/reloadOpenToolbar.test.ts @@ -24,10 +24,10 @@ test('preview-level open shortcut handles Ctrl/Cmd+O outside Monaco without doub assert.match(markdownViewer, /cmdOrCtrl[\s\S]*key === 'o'[\s\S]*selectFile\(\)/); }); -test('editor toolbar delegates to Monaco actions and exposes expected Markdown commands', () => { +test('editor toolbar delegates actions and optional payloads to Monaco', () => { assert.match(markdownViewer, /import EditorToolbar from '\.\/components\/EditorToolbar\.svelte'/); - assert.match(markdownViewer, / editorPane\?\.runEditorAction\(actionId\)\}/); - assert.match(editor, /export function runEditorAction\(actionId: string\)/); + assert.match(markdownViewer, / editorPane\?\.runEditorAction\(actionId, payload\)\}/); + assert.match(editor, /export function runEditorAction\(actionId: string, payload\?: any\)/); for (const actionId of [ 'fmt-bold', diff --git a/scripts/toolbarCustomizationWiring.test.ts b/scripts/toolbarCustomizationWiring.test.ts index 1af4c54a..483292df 100644 --- a/scripts/toolbarCustomizationWiring.test.ts +++ b/scripts/toolbarCustomizationWiring.test.ts @@ -78,21 +78,17 @@ test('settings modal does not light-dismiss when clicking the backdrop', () => { assert.doesNotMatch(settingsComponent, /function handleBackdropClick[\s\S]*onclose\(\)/); }); -test('toolbar settings use collapsed accordions for application and editor toolbars', () => { - const accordionBlocks = Array.from(settingsComponent.matchAll(//g)); - - assert.equal(accordionBlocks.length, 2); - assert.match(accordionBlocks[0][0], /settings\.applicationToolbar/); - assert.match(accordionBlocks[1][0], /settings\.editorToolbar/); - assert.match(accordionBlocks[0][0], //); - assert.match(accordionBlocks[1][0], //); - assert.match(accordionBlocks[0][0], /class="toolbar-settings-chevron"/); - assert.match(accordionBlocks[1][0], /class="toolbar-settings-chevron"/); - for (const block of accordionBlocks) { - assert.doesNotMatch(block[0], /]*\sopen(?:[\s=>]|$)/); +test('toolbar settings expose application and editor sections', () => { + const sectionBlocks = Array.from(settingsComponent.matchAll(/
\s*<\/div>/g)); + + assert.equal(sectionBlocks.length, 2); + assert.match(sectionBlocks[0][0], /settings\.applicationToolbar/); + assert.match(sectionBlocks[1][0], /settings\.editorToolbar/); + for (const block of sectionBlocks) { + assert.match(block[0], /class="toolbar-section-header"/); + assert.match(block[0], /class="toolbar-settings-list" role="list"/); } - assert.match(settingsComponent, /\.toolbar-settings\[open\]\s+\.toolbar-settings-chevron/); - assert.match(settingsComponent, /\.toolbar-settings-summary::-webkit-details-marker/); + assert.doesNotMatch(settingsComponent, /toolbar-settings-accordion/); }); test('interactive button labels are directly translated for every supported language', () => { From d4e040e14a74d7303e54e4f0216add50b086ecf8 Mon Sep 17 00:00:00 2001 From: PathGao Date: Fri, 31 Jul 2026 20:12:23 +0800 Subject: [PATCH 2/4] fix(print): use full page width from split view --- scripts/issue261EditorPdf.test.ts | 7 +++++++ src/styles.css | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/scripts/issue261EditorPdf.test.ts b/scripts/issue261EditorPdf.test.ts index 97c6aea2..5d523e1d 100644 --- a/scripts/issue261EditorPdf.test.ts +++ b/scripts/issue261EditorPdf.test.ts @@ -25,3 +25,10 @@ test('print layout releases measured fold heights before text reflows', () => { /@media print\s*\{[\s\S]*?\.foldable-content-wrapper\.is-collapsed\s*\{[\s\S]*?height:\s*0\s*!important;/, ); }); + +test('print layout lets the viewer pane escape the interactive split flex ratio', () => { + assert.match( + styles, + /@media print\s*\{[\s\S]*?\.pane\.viewer-pane\s*\{[\s\S]*?flex:\s*none\s*!important;/, + ); +}); diff --git a/src/styles.css b/src/styles.css index ff43128c..13c00e7d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1438,6 +1438,12 @@ body { border-radius: 0 !important; } + /* The viewer pane carries an inline split ratio during normal interaction. + Width alone cannot override that flex value in the print layout. */ + .pane.viewer-pane { + flex: none !important; + } + .markdown-body { padding: 0 !important; margin: 0 !important; From c26be5c65ccd01a9992f73e6f73c49d9e8cf2ab7 Mon Sep 17 00:00:00 2001 From: PathGao Date: Sat, 1 Aug 2026 17:10:21 +0800 Subject: [PATCH 3/4] fix(print): use native PDF export controls --- scripts/windowsPdfExport.test.ts | 19 +++++++ src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 87 ++++++++++++++++++++++++++++++++ src/lib/MarkdownViewer.svelte | 15 +++++- src/lib/utils/export.ts | 24 +++++++-- 6 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 scripts/windowsPdfExport.test.ts diff --git a/scripts/windowsPdfExport.test.ts b/scripts/windowsPdfExport.test.ts new file mode 100644 index 00000000..7c2d35b5 --- /dev/null +++ b/scripts/windowsPdfExport.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const exporter = readFileSync('src/lib/utils/export.ts', 'utf8'); +const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8'); + +test('Windows PDF export uses WebView2 settings that suppress print headers and footers', () => { + assert.match(exporter, /export async function exportAsPdf\(ctx: PdfExportContext\)/); + assert.match(exporter, /if \(ctx\.osType !== 'windows'\) \{\s*await invoke\('print_pdf'\);/); + assert.match(exporter, /filters: \[\{ name: 'PDF', extensions: \['pdf'\] \}\]/); + assert.match(exporter, /invoke\('export_pdf_windows', \{ path: selected \}\)/); + assert.match(tauriLib, /async fn export_pdf_windows\(/); + assert.match(tauriLib, /fn print_pdf\(window: tauri::WebviewWindow\) -> Result<\(\), String> \{\s*window\.print\(\)/); + assert.match(tauriLib, /save_file_content,\s*\n\s*export_pdf_windows,\s*\n\s*print_pdf,/); + assert.match(tauriLib, /SetShouldPrintHeaderAndFooter\(false\)/); + assert.match(tauriLib, /PrintToPdf\(/); + assert.match(tauriLib, /recv_timeout\(Duration::from_secs\(60\)\)/); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 47bc86c5..09250c3c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -31,6 +31,8 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "webview2-com", + "windows", "winreg 0.52.0", "zip 2.4.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d5868de..346372ab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,8 @@ image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(windows)'.dependencies] mslnk = "0.1" winreg = "0.52" +webview2-com = "0.38.2" +windows = "0.61.3" [profile.release] panic = "abort" # Strip expensive panic clean-up logic diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99ce9cd8..1df0b7db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -747,6 +747,91 @@ fn save_file_content(path: String, content: String) -> Result<(), String> { atomic_write(Path::new(&path), content.as_bytes()).map_err(|e| e.to_string()) } +#[tauri::command] +fn print_pdf(window: tauri::WebviewWindow) -> Result<(), String> { + window.print().map_err(|error| error.to_string()) +} + +#[tauri::command] +async fn export_pdf_windows(window: tauri::WebviewWindow, path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use std::sync::mpsc::sync_channel; + use std::time::Duration; + use webview2_com::{ + callback::PrintToPdfCompletedHandler, + Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment6, ICoreWebView2_7}, + }; + use windows::core::{Interface, HSTRING}; + + let (sender, receiver) = sync_channel(1); + window + .with_webview(move |platform_webview| unsafe { + let result = (|| -> Result<(), String> { + let controller = platform_webview.controller(); + let webview = controller + .CoreWebView2() + .map_err(|error| format!("failed to access WebView2: {error}"))? + .cast::() + .map_err(|error| { + format!("WebView2 runtime does not support PDF export: {error}") + })?; + let settings = platform_webview + .environment() + .cast::() + .map_err(|error| { + format!("WebView2 runtime does not support print settings: {error}") + })? + .CreatePrintSettings() + .map_err(|error| format!("failed to create PDF print settings: {error}"))?; + + settings + .SetShouldPrintHeaderAndFooter(false) + .map_err(|error| { + format!("failed to disable PDF headers and footers: {error}") + })?; + settings + .SetShouldPrintBackgrounds(true) + .map_err(|error| format!("failed to enable PDF backgrounds: {error}"))?; + + let callback_sender = sender.clone(); + let completion = + PrintToPdfCompletedHandler::create(Box::new(move |status, succeeded| { + let result = status + .map_err(|error| format!("WebView2 PDF export failed: {error}")) + .and_then(|_| { + succeeded.then_some(()).ok_or_else(|| { + "WebView2 did not create the PDF file".to_string() + }) + }); + let _ = callback_sender.send(result); + Ok(()) + })); + + webview + .PrintToPdf(&HSTRING::from(path), &settings, &completion) + .map_err(|error| format!("could not start PDF export: {error}")) + })(); + + if let Err(error) = result { + let _ = sender.send(Err(error)); + } + }) + .map_err(|error| format!("failed to schedule PDF export: {error}"))?; + + tauri::async_runtime::spawn_blocking(move || receiver.recv_timeout(Duration::from_secs(60))) + .await + .map_err(|error| format!("PDF export task failed: {error}"))? + .map_err(|error| format!("PDF export callback failed or timed out: {error}"))? + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (window, path); + Err("controlled PDF export is only available on Windows".to_string()) + } +} + #[tauri::command] fn save_file_binary(path: String, data: Vec) -> Result<(), String> { atomic_write(Path::new(&path), &data).map_err(|e| e.to_string()) @@ -1500,6 +1585,8 @@ pub fn run() { read_file_content, read_file_as_data_url, save_file_content, + export_pdf_windows, + print_pdf, save_file_binary, get_app_mode, setup::install_app, diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index d2044950..b2558af0 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -20,7 +20,7 @@ import Toc from './components/Toc.svelte'; import Toast from './components/Toast.svelte'; import FindBar from './components/FindBar.svelte'; - import { exportAsHtml as _exportHtml, exportAsPdf } from './utils/export'; + import { exportAsHtml as _exportHtml, exportAsPdf as _exportPdf } from './utils/export'; import { askToOpenExportedFile } from './utils/openExportedFile.js'; import ZoomOverlay from './components/ZoomOverlay.svelte'; import { processMarkdownHtml } from './utils/markdown'; @@ -1834,6 +1834,19 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } } + async function exportAsPdf() { + const tab = tabManager.activeTab; + try { + await _exportPdf({ + tabPath: tab?.path || '', + osType: settings.osType, + }); + } catch (error) { + console.error('Failed to export PDF', error); + addToast('Failed to export PDF', 'error'); + } + } + function handleNewFile() { tabManager.addNewTab(); showHome = false; diff --git a/src/lib/utils/export.ts b/src/lib/utils/export.ts index 87983902..78a7936d 100644 --- a/src/lib/utils/export.ts +++ b/src/lib/utils/export.ts @@ -21,6 +21,11 @@ export type ExportHtmlResult = { missingImages: number; }; +export interface PdfExportContext { + tabPath: string; + osType: 'macos' | 'windows' | 'linux' | 'unknown'; +} + async function buildExportArticle(ctx: ExportContext): Promise<{ html: string; embeddedImages: number; missingImages: number }> { const body = getMarkdownBodyWithoutFrontMatter(ctx.rawContent); const rendered = (await invoke('render_markdown', { content: body })) as string; @@ -162,9 +167,18 @@ ${article.html} } } -export function exportAsPdf() { - // The current PDF path delegates to the WebView/system print dialog. That - // API does not expose the saved PDF path, so prompt+open belongs in a future - // controlled PDF exporter that knows the target file. - window.print(); +export async function exportAsPdf(ctx: PdfExportContext) { + if (ctx.osType !== 'windows') { + await invoke('print_pdf'); + return; + } + + const defaultName = ctx.tabPath ? ctx.tabPath.replace(/\.[^.]+$/, '.pdf') : 'export.pdf'; + const selected = await save({ + filters: [{ name: 'PDF', extensions: ['pdf'] }], + defaultPath: defaultName, + }); + if (!selected) return; + + await invoke('export_pdf_windows', { path: selected }); } From 89cbc235e09813666d68c9cb21e54a664109f214 Mon Sep 17 00:00:00 2001 From: PathGao Date: Sat, 1 Aug 2026 17:26:11 +0800 Subject: [PATCH 4/4] fix: import public WebView2 PDF callback --- scripts/windowsPdfExport.test.ts | 2 ++ src-tauri/src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/windowsPdfExport.test.ts b/scripts/windowsPdfExport.test.ts index 7c2d35b5..9205d04c 100644 --- a/scripts/windowsPdfExport.test.ts +++ b/scripts/windowsPdfExport.test.ts @@ -15,5 +15,7 @@ test('Windows PDF export uses WebView2 settings that suppress print headers and assert.match(tauriLib, /save_file_content,\s*\n\s*export_pdf_windows,\s*\n\s*print_pdf,/); assert.match(tauriLib, /SetShouldPrintHeaderAndFooter\(false\)/); assert.match(tauriLib, /PrintToPdf\(/); + assert.match(tauriLib, /use webview2_com::\{\s*PrintToPdfCompletedHandler,/); + assert.doesNotMatch(tauriLib, /callback::PrintToPdfCompletedHandler/); assert.match(tauriLib, /recv_timeout\(Duration::from_secs\(60\)\)/); }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1df0b7db..c8e3abe5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -759,7 +759,7 @@ async fn export_pdf_windows(window: tauri::WebviewWindow, path: String) -> Resul use std::sync::mpsc::sync_channel; use std::time::Duration; use webview2_com::{ - callback::PrintToPdfCompletedHandler, + PrintToPdfCompletedHandler, Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment6, ICoreWebView2_7}, }; use windows::core::{Interface, HSTRING};