feat(web): return saved drawings to an embedding page

The web control channel gains a save operation that serializes the
active drawing in the requested format and returns it as base64 in the
reply, clearing the dirty flag, the counterpart of opening from bytes.
A host page can also define window.openCadStudioSaveFile(name, bytes)
to receive every saved or exported file in place of a browser
download; while it is set no file picker opens.

Fixes #1704
Fixes #1705

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 10:12:07 +03:00
1 parent d1a2e9db64
commit 85627ebc8a
4 files changed
+91 -2

No files matched your search

+27
View File
@@ -58,6 +58,33 @@ earlier drawing is still opening the request is answered with `busy`; retry it o
rather than a disk, so use `data_base64` for a file the page has not seen.
`data_base64` is rejected on native builds; use `path` there.
## Getting the drawing back
`save` serializes the active drawing and returns it in the reply, without a download:
```json
{"op":"save","name":"plan.dwg","target_format":"dwg","target_version":"AC1032"}
```
The reply carries `name`, `target_format`, `target_version`, `dropped_on_save`, `bytes`
and `data_base64`, and the tab is marked clean. `target_format` and `target_version`
default to the drawing's own; a conversion that would drop unsupported records needs
`allow_lossy: true`, as on native.
## Receiving saved files in the host page
A page that embeds the app can take every saved or exported file itself instead of a
browser download by defining a function on the app's window before saving:
```js
window.openCadStudioSaveFile = (name, bytes) => {
// name: file name, bytes: Uint8Array
};
```
While it is defined, Save, Save As, the save-before-close prompt and the exports call it
with the file name and the bytes, and no file picker opens. Without it nothing changes.
## Startup dialogs
A dialog can be open when the page is ready. The donation prompt appears once per
+43
View File
@@ -1147,6 +1147,49 @@ impl OpenCADStudio {
)
}
}
// Web: no filesystem path; the drawing comes back in the reply.
#[cfg(target_arch = "wasm32")]
"save" => {
use base64::Engine as _;
let default_is_dxf = crate::io::source_is_dxf(None, &self.tabs[i].scene.document);
let (version, is_dxf) = super::automation::requested_save_target(
req,
self.tabs[i].scene.document.version,
default_is_dxf,
None,
)
.map_err(|error| failure("invalid_target", error))?;
let dropped =
crate::io::dropped_on_save_count(&self.tabs[i].scene.document, version, is_dxf);
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
return Err(failure(
"lossy_conversion_not_acknowledged",
format!(
"Conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"
),
));
}
let ext = if is_dxf { "dxf" } else { "dwg" };
self.sync_view_state_for_save(i);
super::update::util::sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.sync_solid_models_for_save(i);
self.tabs[i].scene.sync_native_parametric_graph();
let bytes = crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version)
.map_err(|error| failure("save_failed", error))?;
self.tabs[i].dirty = false;
let name = req["name"].as_str().filter(|name| !name.is_empty());
let name = name.map_or_else(|| format!("drawing.{ext}"), str::to_string);
self.set_control_result(json!({
"name": name,
"target_format": ext,
"target_version": format!("{version:?}"),
"dropped_on_save": dropped,
"bytes": bytes.len(),
"data_base64": base64::engine::general_purpose::STANDARD.encode(&bytes),
}));
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
"save_verified" => {
let result = self.save_verified_request(req)?;
+3 -1
View File
@@ -3882,8 +3882,10 @@ impl OpenCADStudio {
// handle is secured at this gesture moment (or reused from a
// previous pick — retained handles need no activation to write)
// and only the write happens later.
// A registered host save hook takes the bytes through the
// download path, so no file picker opens.
let route = crate::io::fs_access::save_route(
crate::io::fs_access::supported(),
crate::io::fs_access::supported() && crate::sys::save_hook().is_none(),
self.save_plain_reuse_fs_handle,
crate::io::fs_access::handle_ext(tab_id).as_deref(),
ext,
+18 -1
View File
@@ -597,10 +597,15 @@ pub fn download_bytes(name: &str, bytes: &[u8]) {
let Some(window) = web_sys::window() else {
return;
};
let array = js_sys::Uint8Array::from(bytes);
// A host page that embeds the app takes the file instead of a download.
if let Some(hook) = save_hook() {
let _ = hook.call2(&window, &wasm_bindgen::JsValue::from_str(name), &array);
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 {
@@ -618,6 +623,18 @@ pub fn download_bytes(name: &str, bytes: &[u8]) {
let _ = web_sys::Url::revoke_object_url(&url);
}
/// The `window.openCadStudioSaveFile(name, bytes)` function an embedding page
/// registers to receive saved and exported files, if any.
#[cfg(target_arch = "wasm32")]
pub fn save_hook() -> Option<js_sys::Function> {
use wasm_bindgen::JsCast;
let window = web_sys::window()?;
js_sys::Reflect::get(window.as_ref(), &wasm_bindgen::JsValue::from_str("openCadStudioSaveFile"))
.ok()?
.dyn_into::<js_sys::Function>()
.ok()
}
/// Show a plot preview as an in-page overlay. The plotted PDF arrives already
/// rasterised to PNG pages: inline `<img>` elements render in every browser,
/// with no popup permission and no PDF viewer plugin — both of which embedded