feat(wasm): save files from the browser

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-16 18:20:22 +03:00
1 parent 1ab892c635
commit 9d378a0533
3 files changed
+108 -17

No files matched your search

+3
View File
@@ -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<String, String>),
// ── Custom Save-As dialog ─────────────────────────────────────────────
SaveDialogFormatChanged(String),
SaveDialogFilenameChanged(String),
+76 -17
View File
@@ -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<Message> {
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<Message> {
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) => {
+29
View File
@@ -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<Vec<u8>, 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<u8>) -> Result<String, String> {
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