Merge PR #1734: fix(web): clean up print iframe and blob resources

Conflicts: docs/ui-parity.md (header change text and change log: union of both sides)
This commit is contained in:
aiongg
2026-10-22 02:35:25 +00:00
3 changed files with 43 additions and 16 deletions
+1 -1
View File
@@ -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"] }
+40 -14
View File
@@ -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")?;
+2 -1
View File
@@ -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) |