mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-15 06:42:55 +00:00
web: print straight to the system print dialog, not just export
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).
This commit is contained in:
1 parent
90b3c41eec
commit
d27dec0312
4 files changed
+84
-2
No files matched your search
@@ -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"] }
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -121,6 +121,9 @@ pub struct Services {
|
||||
pub open_async: Option<Box<dyn Fn()>>,
|
||||
/// Files delivered asynchronously (name, bytes), opened on the next frame.
|
||||
pub inbox: Option<Inbox>,
|
||||
/// 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<Box<dyn Fn(&[u8])>>,
|
||||
}
|
||||
|
||||
/// 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<Result<Json, String>> {
|
||||
let r = match id {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user