The web app ran CadApp with Services::default(), so OPEN found no file
picker and returned without doing anything: no picker, no message.
The web app now installs an open picker (apps/cadcraft-web/src/upload.rs,
web-sys only): a hidden <input type="file" accept=".dxf,.dwg"> is
clicked, the chosen file is read in the background (Blob.arrayBuffer)
and handed to the app on its next frame, which runs OPEN with
{data, name} through the new CadApp::open_bytes. Cancelling the picker
does nothing. Files dropped on the page, which eframe hands over unread
on the web and the UI ignored there, take the same route.
Files over 256 MB (CadApp::MAX_OPEN_BYTES; the browser holds the drawing
several times while decoding it) are refused before they are read.
Unreadable or rejected files report on the command line and in the
status bar; nothing panics.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
238162f332
commit
a560048dd0
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 `<input type="file">`; 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<u8>), String>;
|
||||
|
||||
/// Files read in the background, waiting for the app's next frame.
|
||||
#[derive(Clone)]
|
||||
pub struct Inbox {
|
||||
files: Rc<Cell<Vec<Upload>>>,
|
||||
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::<web_sys::HtmlInputElement>()
|
||||
.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:?}"))
|
||||
}
|
||||
|
|
@ -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<String> {
|
||||
(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<String>) {
|
||||
let e = e.into();
|
||||
self.session.echo(e.clone());
|
||||
self.set_status(e);
|
||||
}
|
||||
|
||||
fn opened(&mut self, r: Result<Value, String>) {
|
||||
if let Err(e) = r {
|
||||
self.set_status(e);
|
||||
} else {
|
||||
self.ui.start_tab = false;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue