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 <noreply@anthropic.com>
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",
|
"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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user