From 9d378a0533ba999f1a2832112f776b2b8bb2c4fe Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 16 Jun 2026 18:20:01 +0300 Subject: [PATCH] feat(wasm): save files from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up save for the web build: - `io::save_to_bytes`: serialize a document to a byte buffer (DXF via write_to_vec, DWG via write_to_writer over a Cursor). - `io::download_web`: browser save dialog → write bytes (triggers a download). - Save and Save As on wasm both go through a new `web_save` helper: serialize the active tab and offer a download (no persistent path or in-app save dialog on the web). Format follows the tab name's extension, defaulting to DWG. Result reported via `WebSaveResult`. Native build + 75 tests unchanged. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 3 ++ src/app/update.rs | 93 ++++++++++++++++++++++++++++++++++++++--------- src/io/mod.rs | 29 +++++++++++++++ 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index af31c2e91..9dc975c36 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -678,6 +678,9 @@ 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 7bf7c02c9..ebb95e97f 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -49,6 +49,39 @@ 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() + } + } + } + pub fn update(&mut self, msg: Message) -> Task { let task = self.update_inner(msg); // After every message, mirror the active command step's prompt so @@ -709,30 +742,56 @@ impl OpenCADStudio { Message::ObjImportPath(None) => Task::none(), Message::SaveFile => { - 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; + #[cfg(target_arch = "wasm32")] + { + self.web_save() + } + #[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}")), } - Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), + Task::none() + } else { + self.save_dialog_for_unsaved = false; + self.open_save_dialog_window(i) } - Task::none() - } else { + } + } + + 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) } } - Message::SaveAs => { - 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() } Message::SaveDialogFormatChanged(fmt) => { diff --git a/src/io/mod.rs b/src/io/mod.rs index 92ca9c6e8..495240f49 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -356,6 +356,35 @@ pub fn save(doc: &CadDocument, path: &Path) -> Result<(), String> { save_as_version(doc, path, doc.version) } +/// Serialize a document to an in-memory byte buffer, format chosen by `ext` +/// (`dxf` → DXF, anything else → DWG). Used by the web build, which hands the +/// bytes to a browser download instead of writing a path. +pub fn save_to_bytes(doc: &CadDocument, ext: &str) -> Result, String> { + let mut doc = doc.clone(); + sync_current_styles_on_save(&mut doc); + match ext.to_lowercase().as_str() { + "dxf" => DxfWriter::new(&doc).write_to_vec().map_err(|e| e.to_string()), + _ => { + let mut buf = std::io::Cursor::new(Vec::new()); + DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?; + Ok(buf.into_inner()) + } + } +} + +/// 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 ────────────────────────────────────────────────────── // Resolve the current text / dimension / multiline style from the handle the