From d27dec031206600b118abacd99b6438b9ffe5a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Koci=C4=87?= Date: Sat, 10 Oct 2026 12:05:32 +0200 Subject: [PATCH 1/2] web: print straight to the system print dialog, not just export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build had no way to actually print — Ctrl+P (Cmd+P, declared on file.print's shortcut) did nothing at all, and the ribbon's "Print…" menu item ran file.print and silently discarded the result. file.print already builds a PDF and returns it as base64 specifically "for the UI to hand to the system print dialog" per its own doc comment — this wires that up. - ui-egui: new Services::print hook (web: hand PDF bytes to the browser's print flow directly, no download); SheetApp::print_now runs file.print, decodes the base64 result, and calls the hook. - ui-egui: Key::P now dispatches to print_now (was unbound); the ribbon's "Print…" menu item calls print_now instead of discarding file.print's result. - gridcraft-web: implements the hook (Blob -> object URL -> hidden iframe -> iframe.contentWindow.print() on load). Desktop builds are unaffected (no Services::print set there; print_now falls back to the existing alert-on-error behaviour with no UI action on success, same as before this change). --- apps/gridcraft-web/Cargo.toml | 2 +- apps/gridcraft-web/src/web.rs | 47 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 29 +++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 8 +++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index 356c63b84..0f69e38e7 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index f5744bd19..9f758b47e 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -136,6 +136,11 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { log::error!("download of {name} failed: {e}"); } })), + print: Some(Box::new(|bytes: &[u8]| { + if let Err(e) = print_pdf(bytes) { + log::error!("print failed: {e}"); + } + })), open_url: Some(Box::new(|url: &str| { if let Some(w) = web_sys::window() { let _ = w.open_with_url_and_target(url, "_blank"); @@ -179,6 +184,48 @@ fn copy_html(html: &str, text: &str) -> Result<(), String> { Ok(()) } +/// Opens `bytes` (a PDF) as an object URL in a hidden iframe and calls the iframe's own +/// `print()` once it has finished loading — the standard way to drive the browser's native print +/// dialog on a PDF without a download or a popup window the browser might block. The blob URL and +/// the iframe are both cleaned up once the print dialog closes, so repeated prints don't leak +/// them. +fn print_pdf(bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("application/pdf"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + + let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?; + iframe.style().set_property("display", "none").map_err(js)?; + document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?; + + // `onload` fires once the PDF has actually rendered inside the iframe; + // printing before that would show a blank page. Set the handler BEFORE + // `src` so a fast/cached load can't fire before we're listening. + let iframe_for_load = iframe.clone(); + let url_for_cleanup = url.clone(); + let on_load = wasm_bindgen::closure::Closure::once_into_js(move || { + let Some(content_window) = iframe_for_load.content_window() else { return }; + let _ = content_window.print(); + // `afterprint` fires once the print dialog (or print preview) closes, whether the user + // printed or cancelled — the right moment to release the blob URL and drop the iframe. + let iframe_for_cleanup = iframe_for_load.clone(); + let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || { + web_sys::Url::revoke_object_url(&url_for_cleanup).ok(); + iframe_for_cleanup.remove(); + }); + let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref()); + }); + iframe.set_onload(Some(on_load.unchecked_ref())); + iframe.set_src(&url); + Ok(()) +} + /// Triggers a browser download of `bytes`. fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..6ca6f05e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -121,6 +121,9 @@ pub struct Services { pub open_async: Option>, /// Files delivered asynchronously (name, bytes), opened on the next frame. pub inbox: Option, + /// Web: hand PDF bytes to the browser's print flow directly (no download, + /// no intermediate file) — opens the system print dialog on that PDF. + pub print: Option>, } /// Shared queue of files read asynchronously (browser file picker, drag and drop). @@ -287,6 +290,32 @@ impl SheetApp { } } + /// Runs `file.print`, builds the PDF and (where the host provides the hook — the web + /// build) hands the bytes straight to the browser's print flow; errors show the usual + /// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` + /// (which still produces the PDF — just without a UI to act on the result yet). + pub fn print_now(&mut self) { + match self.run("file.print", json!({})) { + Ok(r) => { + let Some(b64) = r.get("base64").and_then(Json::as_str) else { return }; + let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else { + self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into())); + return; + }; + if let Some(print) = &self.services.print { + print(&bytes); + } + } + Err(e) => { + if e.contains("is not available right now") { + self.toast = Some((e, now_ms())); + } else { + self.message = Some(("GridCraft".into(), clean_error(&e))); + } + } + } + } + /// UI-only commands (dialogs, view toggles that aren't saved in the file). fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { let r = match id { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..5fc91f8d9 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -76,7 +76,6 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ("New Workbook", "file.new"), ("Open…", "file.open"), ("Save As…", "file.saveAs"), - ("Print…", "file.print"), ("Sort A to Z", "data.sortAscending"), ("Calculate Now", "formulas.calculateNow"), ] { @@ -84,6 +83,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert(id, json!({})); } } + if ui.button("Print…").clicked() { + app.print_now(); + } }); // Centered title. let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); @@ -1850,6 +1852,10 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { Key::O => Some(("file.open", json!({}))), Key::N => Some(("file.new", json!({}))), Key::W => Some(("file.close", json!({}))), + Key::P => { + app.print_now(); + None + } Key::K => { app.open_dialog("insertLink", json!({})); None From 04fb60ea5d9d53e45e739cd816642e53dc24a80b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 15:04:09 -0400 Subject: [PATCH 2/2] Printing from the UI (Backstage, Ctrl+P, menu) goes through print_now, so the web build opens the browser's print dialog (review fix-up for #209) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 16 +++++++--------- crates/ui-egui/src/ribbon.rs | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0213fc81c..7ff4ff75e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -332,6 +332,10 @@ impl SheetApp { /// Runs a command and shows its error in a message box (for menu/ribbon clicks). pub fn run_or_alert(&mut self, id: &str, params: Json) { + // Printing from the UI also hands the PDF to the host's print flow (web). + if id == "file.print" { + return self.print_now(params); + } if let Err(e) = self.run(id, params) { self.alert(e); } @@ -350,8 +354,8 @@ impl SheetApp { /// build) hands the bytes straight to the browser's print flow; errors show the usual /// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` /// (which still produces the PDF — just without a UI to act on the result yet). - pub fn print_now(&mut self) { - match self.run("file.print", json!({})) { + pub fn print_now(&mut self, params: Json) { + match self.run("file.print", params) { Ok(r) => { let Some(b64) = r.get("base64").and_then(Json::as_str) else { return }; let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else { @@ -362,13 +366,7 @@ impl SheetApp { print(&bytes); } } - Err(e) => { - if e.contains("is not available right now") { - self.toast = Some((e, now_ms())); - } else { - self.message = Some(("GridCraft".into(), clean_error(&e))); - } - } + Err(e) => self.alert(e), } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fe32ee773..f91c8d391 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -106,7 +106,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } if ui.button("Print…").clicked() { - app.print_now(); + app.print_now(json!({})); } }); // Centered title. @@ -1994,7 +1994,7 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { Key::N => Some(("file.new", json!({}))), Key::W => Some(("file.close", json!({}))), Key::P => { - app.print_now(); + app.print_now(json!({})); None } Key::K => {