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 <[email protected]>
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",
"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",
+11 -2
View File
@@ -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
-3
View File
@@ -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<String, String>),
// ── Custom Save-As dialog ─────────────────────────────────────────────
SaveDialogFormatChanged(String),
SaveDialogFilenameChanged(String),
+59 -90
View File
@@ -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<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
/// 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
-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 ──────────────────────────────────────────────────────
+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 {
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);
}