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 f4f4e3fb1..8b1e51da2 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 ea7404e77..7ff4ff75e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -155,6 +155,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). @@ -329,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); } @@ -343,6 +350,26 @@ 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, 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 { + self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into())); + return; + }; + if let Some(print) = &self.services.print { + print(&bytes); + } + } + Err(e) => self.alert(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 1cf6f8b7f..f91c8d391 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -98,7 +98,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"), ] { @@ -106,6 +105,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(json!({})); + } }); // Centered title. let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); @@ -1991,6 +1993,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(json!({})); + None + } Key::K => { app.open_dialog("insertLink", json!({})); None