forked from 1b-agent/opencadstudio-upstream
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:
1 parent
93d136c5db
commit
fefae274d4
6 files changed
+99
-107
No files matched your search
Generated
+2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user