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);
+}