feat(wasm): web Save/Save As go through the dialog with a direct download

On the web, Save and Save As now open the in-app Save dialog (filename +
format) like the desktop, and the unsaved-changes prompt's Save routes
through it too — instead of an immediate rfd download. Confirm serializes
to bytes and downloads directly via a Blob + hidden `<a download>` click
(a user gesture), so there is no intermediate "click to download" link.

Replaces the rfd-based download_web / WebSaveResult round-trip with a
synchronous sys::download_bytes. Native save is unchanged.

Native + web build; 75 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-17 00:52:20 +03:00
1 parent 93d136c5db
commit fefae274d4
6 files changed
+99 -107

No files matched your search

Generated
+2
View File
@@ -26,6 +26,7 @@ dependencies = [
"iced", "iced",
"image", "image",
"inventory", "inventory",
"js-sys",
"lzma-sys", "lzma-sys",
"ocs_plugin_api", "ocs_plugin_api",
"open", "open",
@@ -39,6 +40,7 @@ dependencies = [
"truck-shapeops", "truck-shapeops",
"ttf-parser", "ttf-parser",
"ureq", "ureq",
"wasm-bindgen",
"web-sys", "web-sys",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winresource", "winresource",
+11 -2
View File
@@ -82,8 +82,17 @@ ureq = { version = "3", default-features = false, features = ["rustls"] }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
# Opening external URLs (Donate / feedback / release notes) via window.open. wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["Window"] } 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; # 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 # `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 # system fonts, so without it all iced text (ribbon labels, command line) is
-3
View File
@@ -686,9 +686,6 @@ pub enum Message {
FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>), FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>),
SaveFile, SaveFile,
SaveAs, SaveAs,
/// Result of the web (browser download) save: file name or error.
#[cfg(target_arch = "wasm32")]
WebSaveResult(Result<String, String>),
// ── Custom Save-As dialog ───────────────────────────────────────────── // ── Custom Save-As dialog ─────────────────────────────────────────────
SaveDialogFormatChanged(String), SaveDialogFormatChanged(String),
SaveDialogFilenameChanged(String), SaveDialogFilenameChanged(String),
+59 -90
View File
@@ -48,39 +48,6 @@ fn format_size(bytes: u64) -> String {
} }
impl OpenCADStudio { 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()
}
}
}
/// Close the active in-canvas modal (Plan B), mirroring what closing the /// Close the active in-canvas modal (Plan B), mirroring what closing the
/// old OS window did: a style editor discards its staged (un-applied) /// old OS window did: a style editor discards its staged (un-applied)
/// changes, and the ribbon tool that launched the dialog is de-highlighted. /// changes, and the ribbon tool that launched the dialog is de-highlighted.
@@ -784,56 +751,31 @@ impl OpenCADStudio {
Message::ObjImportPath(None) => Task::none(), Message::ObjImportPath(None) => Task::none(),
Message::SaveFile => { Message::SaveFile => {
#[cfg(target_arch = "wasm32")] let i = self.active_tab;
{ // Native: save straight to the known path. Web has no path
self.web_save() // (downloads instead), so always go through the Save dialog.
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ if let Some(path) = self.tabs[i].current_path.clone() {
let i = self.active_tab; self.tabs[i].scene.document.header.user_real1 =
if let Some(path) = &self.tabs[i].current_path { self.tabs[i].scene.annotation_scale as f64;
let path = path.clone(); match crate::io::save(&self.tabs[i].scene.document, &path) {
self.tabs[i].scene.document.header.user_real1 = Ok(()) => {
self.tabs[i].scene.annotation_scale as f64; self.command_line
match crate::io::save(&self.tabs[i].scene.document, &path) { .push_output(&format!("Saved: {}", path.display()));
Ok(()) => { self.tabs[i].dirty = false;
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}")),
} }
Task::none() Err(e) => self.command_line.push_error(&format!("Save failed: {e}")),
} else {
self.save_dialog_for_unsaved = false;
self.open_save_dialog_window(i)
} }
return Task::none();
} }
self.save_dialog_for_unsaved = false;
self.open_save_dialog_window(i)
} }
Message::SaveAs => { Message::SaveAs => {
#[cfg(target_arch = "wasm32")] let i = self.active_tab;
{ self.save_dialog_for_unsaved = false;
self.web_save() self.open_save_dialog_window(i)
}
#[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()
} }
Message::SaveDialogFormatChanged(fmt) => { Message::SaveDialogFormatChanged(fmt) => {
@@ -884,24 +826,51 @@ impl OpenCADStudio {
name.to_string() name.to_string()
}; };
self.save_dialog_filename = filename.clone(); 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 close = self.close_save_dialog_window();
let i = self.active_tab; let i = self.active_tab;
sync_annotation_scale_header(&mut self.tabs[i].scene); sync_annotation_scale_header(&mut self.tabs[i].scene);
match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
Ok(()) => { // Native: write to the chosen path. Web: download the bytes
self.command_line // under the chosen name (no filesystem).
.push_output(&format!("Saved: {}", path.display())); #[cfg(not(target_arch = "wasm32"))]
self.tabs[i].current_path = Some(path.clone()); {
self.tabs[i].dirty = false; let path = self.save_dialog_folder.join(&filename);
if self.save_dialog_for_unsaved { match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) {
let next = self.update(Message::UnsavedPickedSavePath(Some(path))); Ok(()) => {
return Task::batch([close, next]); 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(), Message::SaveDialogCancel => self.close_save_dialog_window(),
@@ -5533,7 +5502,7 @@ impl OpenCADStudio {
Message::UnsavedDialogSave => { Message::UnsavedDialogSave => {
match self.pending_close.take() { match self.pending_close.take() {
Some(super::PendingClose::Tab(idx)) => { 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) { match crate::io::save(&self.tabs[idx].scene.document, &path) {
Ok(()) => { Ok(()) => {
self.command_line self.command_line
@@ -5560,7 +5529,7 @@ impl OpenCADStudio {
} }
Some(super::PendingClose::Quit) => { Some(super::PendingClose::Quit) => {
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { 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) { match crate::io::save(&self.tabs[idx].scene.document, &path) {
Ok(()) => { Ok(()) => {
self.command_line self.command_line
-12
View File
@@ -372,18 +372,6 @@ pub fn save_to_bytes(doc: &CadDocument, ext: &str) -> Result<Vec<u8>, 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<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 ────────────────────────────────────────────────────── // ── Post-load fixups ──────────────────────────────────────────────────────
+27
View File
@@ -32,3 +32,30 @@ pub fn handle_path(h: &rfd::FileHandle) -> std::path::PathBuf {
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()) std::path::PathBuf::from(h.file_name())
} }
/// Trigger a browser download of `bytes` as `name`. Builds a Blob, points a
/// hidden `<a download>` 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);
}