diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index eac6c1354..4856b565d 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", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions"] } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c0fcb22da..6efbbaedb 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -224,15 +224,20 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; + 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)?; + let body = document.body().ok_or("no body")?; + 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)?; + if let Err(e) = body.append_child(&iframe) { + cleanup_print(&iframe, &url); + return Err(js(e)); + } // `onload` fires once the PDF has actually rendered inside the iframe; // printing before that would show a blank page. Set the handler BEFORE @@ -240,22 +245,44 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> { 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_for_load.set_onload(None); + if let Err(e) = print_loaded_pdf(&iframe_for_load, &url_for_cleanup) { + cleanup_print(&iframe_for_load, &url_for_cleanup); + log::error!("print failed: {e:?}"); + } }); iframe.set_onload(Some(on_load.unchecked_ref())); iframe.set_src(&url); Ok(()) } +fn cleanup_print(iframe: &web_sys::HtmlIFrameElement, url: &str) { + // Both operations are idempotent: print can fail after dispatching `afterprint`. + web_sys::Url::revoke_object_url(url).ok(); + iframe.remove(); +} + +fn print_loaded_pdf(iframe: &web_sys::HtmlIFrameElement, url: &str) -> Result<(), JsValue> { + let content_window = iframe.content_window().ok_or_else(|| JsValue::from_str("no print content window"))?; + let iframe_for_cleanup = iframe.clone(); + let url_for_cleanup = url.to_owned(); + let on_after_print = wasm_bindgen::closure::Closure::once(move || { + cleanup_print(&iframe_for_cleanup, &url_for_cleanup); + }); + // Listen before `print()`, which can dispatch `afterprint` before it returns. + // A one-shot listener also releases the callback when the dialog closes or is cancelled. + let options = web_sys::AddEventListenerOptions::new(); + options.set_once(true); + let callback = on_after_print.as_ref().unchecked_ref(); + content_window.add_event_listener_with_callback_and_add_event_listener_options("afterprint", callback, &options)?; + if let Err(e) = content_window.print() { + let _ = content_window.remove_event_listener_with_callback("afterprint", callback); + return Err(e); + } + let _ = on_after_print.into_js_value(); + Ok(()) +} + fn mime_for(name: &str) -> &'static str { match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -274,7 +301,6 @@ fn mime_for(name: &str) -> &'static str { } /// Trigger a browser save dialog or download of `bytes` named after `name`. - fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let window = web_sys::window().ok_or("no window")?; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 360b75be9..c73a2fd8c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (shared UI clock uses the browser-aware platform clock, #1026; New sheet (+) right after the last sheet tab, #943; next/previous sheet navigation stops at visible-sheet boundaries; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (web print resource cleanup, #1054; shared UI clock uses the browser-aware platform clock, #1026; New sheet (+) right after the last sheet tab, #943; next/previous sheet navigation stops at visible-sheet boundaries; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -163,6 +163,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. |---|---|---| | 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format | | 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) | +| 2026-10-16 | minor | Web printing registers one-shot `afterprint` cleanup before opening the dialog; missing content windows and setup, listener or print failures also release the hidden iframe and PDF blob URL (#1054) | | 2026-10-16 | minor | The shared UI clock delegates to the core Unix-millisecond clock, which reads Date.now() on wasm, instead of returning zero there (#1026). Native clock contract tested; browser timing remains unverified | | 2026-10-12 | minor | Next/previous sheet navigation no longer activates hidden worksheets at either workbook boundary; command tests cover Hidden, VeryHidden and selection retention | | 2026-10-13 | minor | Sheet tab bar: the tab strip shrinks to its tabs, up to 60% of the bar, so New sheet (+) sits right after the last tab and the horizontal scrollbar takes the rest (part of #943) |