mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 19:56:48 -06:00
Merge pull request #209 from Dejan-Port/print-only
web: print straight to the system print dialog, not just export
This commit is contained in:
@@ -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:?}");
|
||||
|
||||
@@ -155,6 +155,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).
|
||||
@@ -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<Result<Json, String>> {
|
||||
let r = match id {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user