On wasm, saving skipped the file write but still marked the drawing saved, renamed its tab and reported success, so nothing reached the user. Saving now hands the bytes to a host delivery hook (`file::set_deliver`); the web app installs a small web-sys download helper (Blob + object URL + temporary <a download>), reusable for other files such as plots. Without the hook, or when the download can't be started, save errors and the drawing stays dirty. A started download counts as saved and keeps the name for later QSAVEs. The web app's Save/Save As (menu, close prompt) download under the drawing's name, as there is no file picker in the browser. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d125493f9
commit
3b73884abd
|
|
@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
|
|||
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url"] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Browser downloads: hand bytes to the user as a file, through web-sys only (a `Blob`, an object
|
||||
//! URL and a temporary `<a download>` link). This is the web's file route for everything that
|
||||
//! produces a file: drawing saves today (installed as the engine's
|
||||
//! [`cadcraft_engine::cmd::file::set_deliver`] hook), plots next.
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use web_sys::js_sys;
|
||||
|
||||
/// How long the object URL stays valid after the click; browsers read it asynchronously.
|
||||
const REVOKE_AFTER_MS: i32 = 60_000;
|
||||
|
||||
/// Start a browser download of `bytes` as a file called `name` with MIME type `mime`.
|
||||
///
|
||||
/// `Ok` once the download was triggered. Where it lands (or whether the user cancels a "save as"
|
||||
/// prompt of the browser) is up to the browser and can't be observed from the page.
|
||||
pub fn download(name: &str, mime: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err("a file name is required".into());
|
||||
}
|
||||
let window = web_sys::window().ok_or("no browser window")?;
|
||||
let document = window.document().ok_or("no browser document")?;
|
||||
let body = document.body().ok_or("the page has no body")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let options = web_sys::BlobPropertyBag::new();
|
||||
options.set_type(mime);
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_error)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_error)?;
|
||||
if let Err(e) = click_link(&document, &body, &url, name) {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
return Err(e);
|
||||
}
|
||||
let later = url.clone();
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&later);
|
||||
});
|
||||
if window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_AFTER_MS).is_err() {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn click_link(document: &web_sys::Document, body: &web_sys::HtmlElement, url: &str, name: &str) -> Result<(), String> {
|
||||
let link = document
|
||||
.create_element("a")
|
||||
.map_err(js_error)?
|
||||
.dyn_into::<web_sys::HtmlAnchorElement>()
|
||||
.map_err(|_| "could not create a download link".to_string())?;
|
||||
link.set_href(url);
|
||||
link.set_download(name);
|
||||
link.set_attribute("style", "display:none").map_err(js_error)?;
|
||||
body.append_child(&link).map_err(js_error)?;
|
||||
link.click();
|
||||
link.remove();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn js_error(e: wasm_bindgen::JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
|
@ -3,9 +3,12 @@
|
|||
//! Runs the same [`cadcraft_ui_egui::CadApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the sample
|
||||
//! drawing.
|
||||
//! drawing. Saving downloads the drawing through the browser (`download.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod download;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use cadcraft_engine::Session;
|
||||
|
|
@ -44,6 +47,12 @@ mod web {
|
|||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
|
||||
fn services() -> Services {
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
|
||||
|
|
@ -51,6 +60,8 @@ mod web {
|
|||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
// No file system here: saved files reach the user as browser downloads.
|
||||
cadcraft_engine::cmd::file::set_deliver(crate::download::download);
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
|
|
@ -68,7 +79,7 @@ mod web {
|
|||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let mut app = CadApp::new(Session::new(), services());
|
||||
app.system_languages =
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! File menu: new, close, switching drawings. Open/save of DWG/DXF go through the io layer,
|
||||
//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`].
|
||||
//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`]. On the web, where there is
|
||||
//! no file system, saved files reach the user through the host's [`crate::cmd::file::set_deliver`].
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -29,6 +30,21 @@ pub fn io() -> Option<&'static IoHooks> {
|
|||
IO.get()
|
||||
}
|
||||
|
||||
/// Hand finished file bytes to the user where there is no file system (the browser: a download).
|
||||
/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started.
|
||||
pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>;
|
||||
|
||||
static DELIVER: OnceLock<DeliverHook> = OnceLock::new();
|
||||
|
||||
/// Install the host's file delivery (the web app: a browser download). Saving uses it on wasm;
|
||||
/// anything else that produces a file on the web (plots) can use [`deliver`] too.
|
||||
pub fn set_deliver(h: DeliverHook) {
|
||||
let _ = DELIVER.set(h);
|
||||
}
|
||||
pub fn deliver() -> Option<DeliverHook> {
|
||||
DELIVER.get().copied()
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("new", "New Drawing...", run_new)
|
||||
|
|
@ -119,12 +135,34 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
|||
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
||||
let hooks = io().ok_or_else(|| bad("save", "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(s.doc()?, path).map_err(|e| bad("save", e))?;
|
||||
commit_save(s, path, &bytes, store)
|
||||
}
|
||||
|
||||
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
|
||||
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let name = file_name(path);
|
||||
let deliver = deliver().ok_or_else(|| bad("save", "saving files is not available in this build; use document.bytes"))?;
|
||||
deliver(&name, "application/octet-stream", bytes).map_err(|e| bad("save", format!("{name}: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
|
||||
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
|
||||
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
|
||||
/// a later QSAVE downloads the drawing again under it.
|
||||
fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&str, &[u8]) -> Result<()>) -> Result<usize> {
|
||||
s.state()?;
|
||||
store(path, bytes)?;
|
||||
let st = s.state_mut()?;
|
||||
st.saved = st.doc.clone();
|
||||
st.path = Some(path.to_string());
|
||||
|
|
@ -132,19 +170,28 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
|||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
/// The save result; on the web it also names the file the browser downloaded.
|
||||
fn saved(path: &str, bytes: usize) -> Value {
|
||||
let mut r = json!({ "path": path, "bytes": bytes });
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
r["download"] = json!(file_name(path));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn run_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = match str_param(p, "path") {
|
||||
Some(p) => p.to_string(),
|
||||
None => s.state()?.path.clone().ok_or_else(|| bad("qsave", "drawing has no file name yet; use saveas {path}"))?,
|
||||
};
|
||||
let n = save_to(s, &path)?;
|
||||
Ok(json!({ "path": path, "bytes": n }))
|
||||
Ok(saved(&path, n))
|
||||
}
|
||||
|
||||
fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("saveas", "`path` is required"))?.to_string();
|
||||
let n = save_to(s, &path)?;
|
||||
Ok(json!({ "path": path, "bytes": n }))
|
||||
Ok(saved(&path, n))
|
||||
}
|
||||
|
||||
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -300,3 +347,37 @@ mod tests {
|
|||
assert_eq!(active_title(&s), "Drawing2");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pr193_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 4,4 1").unwrap();
|
||||
let st = s.state().unwrap();
|
||||
let title = st.title.clone();
|
||||
assert!(st.is_dirty() && st.path.is_none());
|
||||
|
||||
// The file route is unavailable or fails (the web without a download): no success, still dirty.
|
||||
let failed = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |_, _| Err(bad("save", "no download")));
|
||||
assert!(failed.is_err());
|
||||
let st = s.state().unwrap();
|
||||
assert!(st.is_dirty(), "a save that delivered nothing must keep the unsaved state");
|
||||
assert_eq!((st.path.as_deref(), st.title.as_str()), (None, title.as_str()));
|
||||
|
||||
// The file route took the bytes (written, or a download started): saved under that name.
|
||||
let mut got = None;
|
||||
let n = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |p, b| {
|
||||
got = Some((p.to_string(), b.len()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((n, got), (6, Some(("a/Owned.dxf".to_string(), 6))));
|
||||
let st = s.state().unwrap();
|
||||
assert!(!st.is_dirty());
|
||||
assert_eq!((st.path.as_deref(), st.title.as_str()), (Some("a/Owned.dxf"), "Owned.dxf"));
|
||||
assert_eq!(saved("a/Owned.dxf", n)["bytes"], 6);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue