diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index c89e127..0aad3b2 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -21,6 +21,10 @@ egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } +# File > Open… and dropped files (src/upload.rs): the browser's file picker and file reads. +[target.wasm32-unknown-unknown.dependencies] +web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] } + [target.'cfg(target_arch = "wasm32")'.dependencies] eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 6810cce..b15e68a 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -15,10 +15,13 @@ mod web { const CANVAS_ID: &str = "cadcraft_canvas"; const LOADING_ID: &str = "cadcraft_loading"; - struct Shell(CadApp); + /// The app, and the files File > Open… and drag and drop read for it in the background. + struct Shell(CadApp, crate::upload::Inbox); impl eframe::App for Shell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.1.take_dropped(ctx); + self.1.drain(&mut self.0); self.0.logic(ctx); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -77,7 +80,15 @@ mod web { if query().contains("sample") { let _ = app.run("ui.sample", serde_json::json!({})); } - Ok(Box::new(Shell(app))) + // No paths on the web: OPEN asks the browser's file picker, which reads the + // chosen file in the background (`upload.rs`), so the picker returns none. + let inbox = crate::upload::Inbox::new(&cc.egui_ctx); + let picker = inbox.clone(); + app.services.pick_open = Some(Box::new(move || { + picker.pick(); + None + })); + Ok(Box::new(Shell(app, inbox))) }), ) .await; @@ -93,6 +104,9 @@ mod web { } } +#[cfg(target_arch = "wasm32")] +mod upload; + #[cfg(target_arch = "wasm32")] fn main() { web::start(); diff --git a/apps/cadcraft-web/src/upload.rs b/apps/cadcraft-web/src/upload.rs new file mode 100644 index 0000000..d2edce7 --- /dev/null +++ b/apps/cadcraft-web/src/upload.rs @@ -0,0 +1,119 @@ +//! Browser uploads: File > Open… on the web, through web-sys only. There is no file system to pick +//! a path from, so the picker is a hidden ``; the chosen file (or one dropped on +//! the page) is read in the background and handed to the app on its next frame, which opens it +//! with OPEN `{data, name}` ([`CadApp::open_bytes`]). + +use std::cell::Cell; +use std::rc::Rc; + +use cadcraft_ui_egui::CadApp; +use wasm_bindgen::JsCast as _; +use web_sys::js_sys; + +/// The hidden file input (one at a time; a cancelled pick leaves it until the next one). +const INPUT_ID: &str = "cadcraft_open_input"; +/// What the picker offers: the formats OPEN reads. +const ACCEPT: &str = ".dxf,.dwg"; + +/// A file read in the browser (name, contents), or why it couldn't be read. +type Upload = Result<(String, Vec), String>; + +/// Files read in the background, waiting for the app's next frame. +#[derive(Clone)] +pub struct Inbox { + files: Rc>>, + ctx: egui::Context, +} + +impl Inbox { + pub fn new(ctx: &egui::Context) -> Self { + Inbox { files: Rc::default(), ctx: ctx.clone() } + } + + /// Show the browser's file picker and return at once. A chosen file arrives through + /// [`Self::drain`]; cancelling the picker does nothing. + pub fn pick(&self) { + if let Err(e) = self.show_picker() { + self.push(Err(format!("Open: the browser's file picker is not available ({e})"))); + } + } + + fn show_picker(&self) -> Result<(), String> { + let document = web_sys::window().and_then(|w| w.document()).ok_or("no browser document")?; + let body = document.body().ok_or("the page has no body")?; + if let Some(old) = document.get_element_by_id(INPUT_ID) { + old.remove(); + } + let input = document + .create_element("input") + .map_err(js_error)? + .dyn_into::() + .map_err(|_| "could not create a file input".to_string())?; + input.set_type("file"); + input.set_accept(ACCEPT); + input.set_id(INPUT_ID); + input.set_attribute("style", "display:none").map_err(js_error)?; + let (inbox, chosen) = (self.clone(), input.clone()); + // Called once, when a file was chosen (browsers don't fire `change` on cancel). + let on_change = wasm_bindgen::closure::Closure::once_into_js(move || { + if let Some(file) = chosen.files().and_then(|f| f.get(0)) { + inbox.read(file); + } + chosen.remove(); + }); + input.set_onchange(Some(on_change.unchecked_ref())); + body.append_child(&input).map_err(js_error)?; + input.click(); + Ok(()) + } + + /// Read the files dropped on the page this frame (eframe hands them over unread on the web). + pub fn take_dropped(&self, ctx: &egui::Context) { + for f in ctx.input(|i| i.raw.dropped_files.clone()) { + if let Some(file) = f.web_file() { + self.read(file.clone()); + } + } + } + + /// Read `file` in the background; it arrives through [`Self::drain`]. A file too large to + /// open is refused before it is read. + fn read(&self, file: web_sys::File) { + let name = file.name(); + // `File.size` is a whole number of bytes; anything else counts as too large. + let size = file.size(); + let len = if size.is_finite() && size >= 0.0 { size as u64 } else { u64::MAX }; + if let Some(e) = CadApp::open_size_error(&name, len) { + return self.push(Err(e)); + } + let inbox = self.clone(); + wasm_bindgen_futures::spawn_local(async move { + let r = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buf) => Ok((name, js_sys::Uint8Array::new(&buf).to_vec())), + Err(e) => Err(format!("Open: could not read {name}: {}", js_error(e))), + }; + inbox.push(r); + }); + } + + fn push(&self, upload: Upload) { + let mut files = self.files.take(); + files.push(upload); + self.files.set(files); + self.ctx.request_repaint(); + } + + /// Open what arrived since the last frame (or report why it couldn't be read). + pub fn drain(&self, app: &mut CadApp) { + for upload in self.files.take() { + match upload { + Ok((name, bytes)) => app.open_bytes(&name, &bytes), + Err(e) => app.open_failed(e), + } + } + } +} + +fn js_error(e: wasm_bindgen::JsValue) -> String { + e.as_string().unwrap_or_else(|| format!("{e:?}")) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb57077..da19a0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -245,7 +245,48 @@ impl CadApp { } pub fn open_path(&mut self, path: &str) { - if let Err(e) = self.run("open", json!({ "path": path })) { + let r = self.run("open", json!({ "path": path })); + self.opened(r); + } + + /// The largest file [`Self::open_bytes`] opens. A drawing opened from its contents (the + /// browser, where files have no path) is held in memory several times over while it is decoded, + /// so it is capped below the DWG reader's own limit instead of running out of memory. + pub const MAX_OPEN_BYTES: u64 = 256 << 20; + + /// Why a `len`-byte file called `name` is too large for [`Self::open_bytes`], if it is. + pub fn open_size_error(name: &str, len: u64) -> Option { + (len > Self::MAX_OPEN_BYTES).then(|| { + format!( + "Open: {name} is {:.1} MB, larger than the {} MB CADCraft opens in the browser", + len as f64 / 1048576.0, + Self::MAX_OPEN_BYTES >> 20 + ) + }) + } + + /// Open a drawing from its contents where there is no path to open (on the web: the file + /// picker, a dropped file). Runs OPEN with `{data, name}` as [`Self::open_path`] runs it with + /// `{path}`; failures show on the command line and in the status bar. + pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) { + if let Some(e) = Self::open_size_error(name, bytes.len() as u64) { + return self.open_failed(e); + } + let data = cadcraft_engine::cmd::file::base64_encode(bytes); + let r = self.run("open", json!({ "data": data, "name": name })); + self.opened(r); + } + + /// Report a file that could not be opened before OPEN ran (unreadable, too large) as a failed + /// OPEN is reported: on the command line and in the status bar. + pub fn open_failed(&mut self, e: impl Into) { + let e = e.into(); + self.session.echo(e.clone()); + self.set_status(e); + } + + fn opened(&mut self, r: Result) { + if let Err(e) = r { self.set_status(e); } else { self.ui.start_tab = false; diff --git a/crates/ui-egui/tests/open_bytes.rs b/crates/ui-egui/tests/open_bytes.rs new file mode 100644 index 0000000..bac55d0 --- /dev/null +++ b/crates/ui-egui/tests/open_bytes.rs @@ -0,0 +1,38 @@ +//! Opening a drawing from its contents, where the file has no path (the web's File > Open… picker +//! and files dropped on the page, #55). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +#[test] +fn open_bytes_opens_the_named_drawing_and_reports_failures() { + // A reader that only accepts what a DXF starts with: proves the bytes arrive unchanged. + set_io(IoHooks { + read: |b, _| if b.starts_with(b"0\nSECTION\n") { Ok(Drawing::new_imperial()) } else { Err("not a drawing".into()) }, + write: |_, _| Ok(Vec::new()), + plot: None, + }); + let mut app = CadApp::new(Session::empty(), Services::default()); + app.ui.start_tab = true; + + app.open_bytes("Bracket.dwg", b"0\nSECTION\n2\nHEADER\n"); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.state().map(|s| s.title.clone()).ok().as_deref(), Some("Bracket.dwg")); + assert!(!app.ui.start_tab && app.canvas.zoom_pending, "shown and zoomed like a drawing opened from a path"); + + // A file the reader rejects: nothing opens; the error reaches the command line and status bar. + app.open_bytes("junk.dxf", b"junk"); + assert_eq!(app.session.docs.len(), 1); + assert!(app.session.log.last().is_some_and(|l| l.contains("not a drawing")), "{:?}", app.session.log.last()); + assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("not a drawing"))); + + // Too large to hold in memory: refused before anything is read, with the limit named. + assert!(CadApp::open_size_error("big.dwg", CadApp::MAX_OPEN_BYTES).is_none()); + let e = CadApp::open_size_error("huge.dwg", CadApp::MAX_OPEN_BYTES * 2); + assert_eq!(e.as_deref(), Some("Open: huge.dwg is 512.0 MB, larger than the 256 MB CADCraft opens in the browser")); + app.open_failed(e.unwrap_or_default()); + assert_eq!(app.session.log.last().map(String::as_str), Some("Open: huge.dwg is 512.0 MB, larger than the 256 MB CADCraft opens in the browser")); + assert_eq!(app.session.docs.len(), 1); +}