diff --git a/Cargo.lock b/Cargo.lock index e6e990f4f..9b54f2f73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,7 @@ dependencies = [ "iced", "image", "inventory", + "js-sys", "lzma-sys", "ocs_plugin_api", "open", @@ -39,6 +40,7 @@ dependencies = [ "truck-shapeops", "ttf-parser", "ureq", + "wasm-bindgen", "web-sys", "windows-sys 0.61.2", "winresource", diff --git a/Cargo.toml b/Cargo.toml index 8308f3d68..ea079c829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,8 +82,17 @@ ureq = { version = "3", default-features = false, features = ["rustls"] } [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1" -# Opening external URLs (Donate / feedback / release notes) via window.open. -web-sys = { version = "0.3", features = ["Window"] } +wasm-bindgen = "0.2" +js-sys = "0.3" +# window.open (external URLs) + Blob/anchor file downloads (Save). +web-sys = { version = "0.3", features = [ + "Window", + "Document", + "Element", + "HtmlAnchorElement", + "Blob", + "Url", +] } # Browsers without WebGPU (e.g. Firefox by default) need wgpu's WebGL2 backend; # `webgl` enables it. `fira-sans` embeds the default UI font — the web has no # system fonts, so without it all iced text (ribbon labels, command line) is diff --git a/src/app/mod.rs b/src/app/mod.rs index 1353d3fc7..24279c207 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -686,9 +686,6 @@ pub enum Message { FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>), SaveFile, SaveAs, - /// Result of the web (browser download) save: file name or error. - #[cfg(target_arch = "wasm32")] - WebSaveResult(Result), // ── Custom Save-As dialog ───────────────────────────────────────────── SaveDialogFormatChanged(String), SaveDialogFilenameChanged(String), diff --git a/src/app/update.rs b/src/app/update.rs index 6323deb3a..4e3c2cdfc 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -48,39 +48,6 @@ fn format_size(bytes: u64) -> String { } impl OpenCADStudio { - /// Web save: serialize the active document to bytes and offer a browser - /// download. Used for both Save and Save As on wasm (there is no persistent - /// path or in-app save dialog). Format follows the tab name's extension, - /// defaulting to DWG. - #[cfg(target_arch = "wasm32")] - fn web_save(&mut self) -> Task { - let i = self.active_tab; - self.tabs[i].scene.document.header.user_real1 = - self.tabs[i].scene.annotation_scale as f64; - let base = self.tabs[i].tab_display_name(); - let lower = base.to_lowercase(); - let name = if lower.ends_with(".dwg") || lower.ends_with(".dxf") { - base - } else { - format!("{base}.dwg") - }; - let ext = if name.to_lowercase().ends_with(".dxf") { - "dxf" - } else { - "dwg" - }; - match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext) { - Ok(bytes) => { - self.tabs[i].dirty = false; - Task::perform(crate::io::download_web(name, bytes), Message::WebSaveResult) - } - Err(e) => { - self.command_line.push_error(&format!("Save failed: {e}")); - Task::none() - } - } - } - /// Close the active in-canvas modal (Plan B), mirroring what closing the /// old OS window did: a style editor discards its staged (un-applied) /// changes, and the ribbon tool that launched the dialog is de-highlighted. @@ -784,56 +751,31 @@ impl OpenCADStudio { Message::ObjImportPath(None) => Task::none(), Message::SaveFile => { - #[cfg(target_arch = "wasm32")] - { - self.web_save() - } + let i = self.active_tab; + // Native: save straight to the known path. Web has no path + // (downloads instead), so always go through the Save dialog. #[cfg(not(target_arch = "wasm32"))] - { - let i = self.active_tab; - if let Some(path) = &self.tabs[i].current_path { - let path = path.clone(); - self.tabs[i].scene.document.header.user_real1 = - self.tabs[i].scene.annotation_scale as f64; - match crate::io::save(&self.tabs[i].scene.document, &path) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - self.tabs[i].dirty = false; - } - Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), + if let Some(path) = self.tabs[i].current_path.clone() { + self.tabs[i].scene.document.header.user_real1 = + self.tabs[i].scene.annotation_scale as f64; + match crate::io::save(&self.tabs[i].scene.document, &path) { + Ok(()) => { + self.command_line + .push_output(&format!("Saved: {}", path.display())); + self.tabs[i].dirty = false; } - Task::none() - } else { - self.save_dialog_for_unsaved = false; - self.open_save_dialog_window(i) + Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), } + return Task::none(); } + self.save_dialog_for_unsaved = false; + self.open_save_dialog_window(i) } Message::SaveAs => { - #[cfg(target_arch = "wasm32")] - { - self.web_save() - } - #[cfg(not(target_arch = "wasm32"))] - { - let i = self.active_tab; - self.save_dialog_for_unsaved = false; - self.open_save_dialog_window(i) - } - } - - #[cfg(target_arch = "wasm32")] - Message::WebSaveResult(result) => { - match result { - Ok(name) => self.command_line.push_output(&format!("Saved: {name}")), - Err(e) if e != "Cancelled" => { - self.command_line.push_error(&format!("Save failed: {e}")) - } - _ => {} - } - Task::none() + let i = self.active_tab; + self.save_dialog_for_unsaved = false; + self.open_save_dialog_window(i) } Message::SaveDialogFormatChanged(fmt) => { @@ -884,24 +826,51 @@ impl OpenCADStudio { name.to_string() }; self.save_dialog_filename = filename.clone(); - let path = self.save_dialog_folder.join(&filename); + self.save_dialog_filename = filename.clone(); let close = self.close_save_dialog_window(); let i = self.active_tab; sync_annotation_scale_header(&mut self.tabs[i].scene); - match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { - Ok(()) => { - self.command_line - .push_output(&format!("Saved: {}", path.display())); - self.tabs[i].current_path = Some(path.clone()); - self.tabs[i].dirty = false; - if self.save_dialog_for_unsaved { - let next = self.update(Message::UnsavedPickedSavePath(Some(path))); - return Task::batch([close, next]); + + // Native: write to the chosen path. Web: download the bytes + // under the chosen name (no filesystem). + #[cfg(not(target_arch = "wasm32"))] + { + let path = self.save_dialog_folder.join(&filename); + match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { + Ok(()) => { + self.command_line + .push_output(&format!("Saved: {}", path.display())); + self.tabs[i].current_path = Some(path.clone()); + self.tabs[i].dirty = false; + if self.save_dialog_for_unsaved { + let next = self.update(Message::UnsavedPickedSavePath(Some(path))); + return Task::batch([close, next]); + } + } + Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), + } + close + } + #[cfg(target_arch = "wasm32")] + { + let _ = version; + match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext) { + Ok(bytes) => { + crate::sys::download_bytes(&filename, &bytes); + self.tabs[i].dirty = false; + self.command_line.push_output(&format!("Saved: {filename}")); + } + Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), + } + // Continue a pending tab close. + if self.save_dialog_for_unsaved { + if let Some(super::PendingClose::Tab(idx)) = self.pending_close.take() { + let cont = self.update(Message::TabClose(idx)); + return Task::batch([close, cont]); } } - Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), + close } - close } Message::SaveDialogCancel => self.close_save_dialog_window(), @@ -5533,7 +5502,7 @@ impl OpenCADStudio { Message::UnsavedDialogSave => { match self.pending_close.take() { Some(super::PendingClose::Tab(idx)) => { - if let Some(path) = self.tabs[idx].current_path.clone() { + if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } { match crate::io::save(&self.tabs[idx].scene.document, &path) { Ok(()) => { self.command_line @@ -5560,7 +5529,7 @@ impl OpenCADStudio { } Some(super::PendingClose::Quit) => { if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { - if let Some(path) = self.tabs[idx].current_path.clone() { + if let Some(path) = if cfg!(target_arch = "wasm32") { None } else { self.tabs[idx].current_path.clone() } { match crate::io::save(&self.tabs[idx].scene.document, &path) { Ok(()) => { self.command_line diff --git a/src/io/mod.rs b/src/io/mod.rs index 495240f49..f5067da2f 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -372,18 +372,6 @@ pub fn save_to_bytes(doc: &CadDocument, ext: &str) -> Result, String> { } } -/// Web "save as": offer a download of `bytes` under `name` via the browser's -/// save dialog. Returns the file name on success. -#[cfg(target_arch = "wasm32")] -pub async fn download_web(name: String, bytes: Vec) -> Result { - let handle = rfd::AsyncFileDialog::new() - .set_file_name(&name) - .save_file() - .await - .ok_or_else(|| "Cancelled".to_string())?; - handle.write(&bytes).await.map_err(|e| e.to_string())?; - Ok(name) -} // ── Post-load fixups ────────────────────────────────────────────────────── diff --git a/src/sys.rs b/src/sys.rs index 58e0a2697..8cd889b80 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -32,3 +32,30 @@ pub fn handle_path(h: &rfd::FileHandle) -> std::path::PathBuf { pub fn handle_path(h: &rfd::FileHandle) -> std::path::PathBuf { std::path::PathBuf::from(h.file_name()) } + +/// Trigger a browser download of `bytes` as `name`. Builds a Blob, points a +/// hidden `` at it and clicks it programmatically — because this +/// runs inside the Save button's click (a user gesture), the file downloads +/// immediately with no extra "click to download" link. Web only. +#[cfg(target_arch = "wasm32")] +pub fn download_bytes(name: &str, bytes: &[u8]) { + use wasm_bindgen::JsCast; + let Some(window) = web_sys::window() else { return }; + let Some(document) = window.document() else { return }; + let array = js_sys::Uint8Array::from(bytes); + let parts = js_sys::Array::new(); + parts.push(&array.buffer()); + let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence(&parts) else { + return; + }; + let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { + return; + }; + if let Ok(el) = document.create_element("a") { + let a: web_sys::HtmlAnchorElement = el.unchecked_into(); + a.set_href(&url); + a.set_download(name); + a.click(); + } + let _ = web_sys::Url::revoke_object_url(&url); +}