diff --git a/Cargo.toml b/Cargo.toml index 3a6ed8573..3313d9a47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,10 @@ web-sys = { version = "=0.3.85", features = [ "HtmlCanvasElement", "CanvasRenderingContext2d", "ImageData", + "DragEvent", + "DataTransfer", + "FileList", + "Event", ] } iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5f01e223d..6bf607bda 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2231,6 +2231,10 @@ pub enum Message { OpenPathPicked(Option<(PathBuf, u64)>), /// A file was dragged from the desktop and dropped on the window (#344). FileDropped(PathBuf), + /// Web only: a drawing dragged onto the page — the DOM drop listener has + /// already read the bytes; this opens through the web-picker flow (#344). + #[cfg(target_arch = "wasm32")] + WebFileDropped(String, Vec), /// Web only: Ctrl+V captured by a focused text field, whose iced-internal /// paste is inert there — read the async clipboard instead (#346). WebFieldPaste, @@ -4077,6 +4081,8 @@ pub enum Message { /// Trigger OBJ import: show open-file dialog. ObjImport, /// Callback after the user picks (or cancels) the OBJ file path. + /// Web never constructs it — the browser handle's bytes are read directly. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] ObjImportPath(Option), ObjImportFinished( u64, @@ -5000,6 +5006,9 @@ impl Drop for OpenCADStudio { /// unavailable for now (see issue #45). Native keeps the multi-window `run`. #[cfg(target_arch = "wasm32")] pub fn run_web() -> iced::Result { + // winit never emits DroppedFile on the web, so the document drag & drop + // listeners are installed here and feed the control poll (#344). + crate::io::web_drop::install(); iced::application( OpenCADStudio::boot_web, OpenCADStudio::update, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c7af1563e..c885d5467 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -4395,19 +4395,33 @@ impl OpenCADStudio { } // Model space keeps its established extents + 5% margin behaviour. + // A sheet edge below this floor (drawing with tiny extents, or the + // 0×0 paper a fresh unsaved drawing carries) cannot be previewed, + // printed, or measured meaningfully; the standard-sheet fallback + // keeps the output a real page instead of a speck. + const MIN_MODEL_SHEET_MM: f64 = 10.0; + let standard_sheet = { + let (w, h) = selected_sheet; + (w.min(h) >= MIN_MODEL_SHEET_MM).then_some((w, h)) + }; let (page_w, page_h, offset_x, offset_y) = if let Some((min, max)) = scene.model_space_extents() { let margin = 1.05_f64; let width = ((max.x - min.x) as f64 * margin).max(1.0); let height = ((max.y - min.y) as f64 * margin).max(1.0); - let pad_x = (width - (max.x - min.x) as f64) * 0.5; - let pad_y = (height - (max.y - min.y) as f64) * 0.5; - ( - width, - height, - -(min.x as f64) + pad_x, - -(min.y as f64) + pad_y, - ) + if width < MIN_MODEL_SHEET_MM || height < MIN_MODEL_SHEET_MM { + let (w, h) = standard_sheet.unwrap_or((297.0, 210.0)); + (w, h, -min.x as f64, -min.y as f64) + } else { + let pad_x = (width - (max.x - min.x) as f64) * 0.5; + let pad_y = (height - (max.y - min.y) as f64) * 0.5; + ( + width, + height, + -(min.x as f64) + pad_x, + -(min.y as f64) + pad_y, + ) + } } else { (297.0, 210.0, 0.0, 0.0) }; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a178e9d5a..6d662109f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -662,11 +662,25 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] Message::PollWebControl => { - if let Some(request) = super::control::web_request() { + let control = if let Some(request) = super::control::web_request() { self.update(Message::ControlRequest(request)) } else { Task::none() - } + }; + // Dragged-in drawings ride the same poll (#344): start one + // only while the open slot is free — the same guard as a + // desktop drop — and leave the rest queued, so a multi-file + // drop opens sequentially without its own subscription. + let dropped = if self.opening.is_none() + && self.active_modal != Some(super::ModalKind::Recovery) + { + crate::io::web_drop::take_dropped() + .map(|(name, bytes)| self.update(Message::WebFileDropped(name, bytes))) + .unwrap_or_else(Task::none) + } else { + Task::none() + }; + Task::batch([control, dropped]) } #[cfg(not(target_arch = "wasm32"))] Message::PollWebControl => Task::none(), @@ -1090,6 +1104,32 @@ impl OpenCADStudio { self.drain_pending_open() } + #[cfg(target_arch = "wasm32")] + Message::WebFileDropped(name, bytes) => { + // Bytes are already in hand (no picker round-trip), so this is + // `on_open_file`'s web branch with the real name and size on + // the overlay from the start. + let state = std::sync::Arc::new(crate::io::OpenProgressState::new( + crate::app::OPEN_PHASE_READING, + )); + let open_id = self.next_open_id(); + self.opening = Some(crate::app::OpenProgress { + id: open_id, + name: name.clone(), + source_path: Some(std::path::PathBuf::from(&name)), + size_bytes: bytes.len() as u64, + state: state.clone(), + started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + recovery_bytes: None, + }); + Task::perform( + crate::io::web_drop::open_dropped(name, bytes, state), + move |outcome| Message::WebFileOpened(open_id, outcome), + ) + } + #[cfg(target_arch = "wasm32")] Message::WebFileOpened(open_id, mut outcome) => { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { @@ -1615,19 +1655,37 @@ impl OpenCADStudio { .push_error(crate::t!("STLOUT: no 3D mesh data in this drawing.").as_ref()); return Task::none(); } - Task::perform( - async { - crate::sys::file_dialog() - .set_title(crate::t!("Export STL").as_ref()) - .set_file_name("export.stl") - .add_filter(crate::t!("STL Files").as_ref(), &["stl"]) - .add_filter(crate::t!("All Files").as_ref(), &["*"]) - .save_file() - .await - .map(|h| crate::sys::handle_path(&h)) - }, - Message::StlExportPath, - ) + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform( + async { + crate::sys::file_dialog() + .set_title(crate::t!("Export STL").as_ref()) + .set_file_name("export.stl") + .add_filter(crate::t!("STL Files").as_ref(), &["stl"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .save_file() + .await + .map(|h| crate::sys::handle_path(&h)) + }, + Message::StlExportPath, + ) + } + // Web: rfd's save dialog shows no UI, so skip straight to a + // virtual path — the browser download supplies the file name. + #[cfg(target_arch = "wasm32")] + { + let stem = self.tabs[i] + .current_path + .as_deref() + .and_then(|p: &std::path::Path| p.file_stem()) + .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".into()); + Task::perform( + crate::io::stl::pick_stl_path_owned(stem), + Message::StlExportPath, + ) + } } Message::StlExportPath(Some(path)) => self.on_stl_export_path_some(path), @@ -1655,19 +1713,37 @@ impl OpenCADStudio { ); return Task::none(); } - Task::perform( - async { - crate::sys::file_dialog() - .set_title(crate::t!("Export STEP AP203").as_ref()) - .set_file_name("export.step") - .add_filter(crate::t!("STEP Files").as_ref(), &["step", "stp"]) - .add_filter(crate::t!("All Files").as_ref(), &["*"]) - .save_file() - .await - .map(|h| crate::sys::handle_path(&h)) - }, - Message::StepExportPath, - ) + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform( + async { + crate::sys::file_dialog() + .set_title(crate::t!("Export STEP AP203").as_ref()) + .set_file_name("export.step") + .add_filter(crate::t!("STEP Files").as_ref(), &["step", "stp"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .save_file() + .await + .map(|h| crate::sys::handle_path(&h)) + }, + Message::StepExportPath, + ) + } + // Web: rfd's save dialog shows no UI, so skip straight to a + // virtual path — the browser download supplies the file name. + #[cfg(target_arch = "wasm32")] + { + let stem = self.tabs[i] + .current_path + .as_deref() + .and_then(|p: &std::path::Path| p.file_stem()) + .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".into()); + Task::perform( + crate::io::step::pick_step_path_owned(stem), + Message::StepExportPath, + ) + } } Message::StepExportPath(Some(path)) => self.on_step_export_path_some(path), @@ -1687,6 +1763,7 @@ impl OpenCADStudio { } // ── OBJ import ──────────────────────────────────────────────── + #[cfg(not(target_arch = "wasm32"))] Message::ObjImport => Task::perform( async { crate::sys::file_dialog() @@ -1700,6 +1777,39 @@ impl OpenCADStudio { Message::ObjImportPath, ), + // Web: the browser picker hands back a handle whose bytes must be + // read directly — the name-only `PathBuf` it maps to cannot be + // opened — so pick, read, and parse in one step and feed the + // shared finished handler. A cancelled picker stays silent. + #[cfg(target_arch = "wasm32")] + Message::ObjImport => { + let tab_id = self.tabs[self.active_tab].id; + Task::perform( + async move { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Import OBJ Mesh").as_ref()) + .add_filter(crate::t!("Wavefront OBJ").as_ref(), &["obj", "OBJ"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await?; + let path = crate::sys::handle_path(&handle); + let bytes = handle.read().await; + let result = crate::io::obj::parse_obj( + &String::from_utf8_lossy(&bytes), + [0.7, 0.7, 0.85, 1.0], + ) + .ok_or_else(|| "no usable geometry in file".to_string()); + Some((tab_id, path, result)) + }, + |picked| match picked { + Some((tab_id, path, result)) => { + Message::ObjImportFinished(tab_id, path, result) + } + None => Message::Noop, + }, + ) + } + Message::ObjImportPath(Some(path)) => self.on_obj_import_path_some(path), Message::ObjImportPath(None) => Task::none(), diff --git a/src/io/mod.rs b/src/io/mod.rs index d411d0d55..f2c7db8df 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -31,6 +31,8 @@ pub mod visual_styles; pub(crate) mod web_recent; #[cfg(target_arch = "wasm32")] mod web_worker; +#[cfg(target_arch = "wasm32")] +pub(crate) mod web_drop; pub mod windows_media; pub mod xref; pub mod xref_model; diff --git a/src/io/step.rs b/src/io/step.rs index a71520445..3cdcc6128 100644 --- a/src/io/step.rs +++ b/src/io/step.rs @@ -252,6 +252,13 @@ pub fn build_step(meshes: &[&MeshModel]) -> Option { Some(file) } +/// Web: no save dialog — the export continues with a virtual filename and +/// the generated bytes are delivered as a browser download. +#[cfg(target_arch = "wasm32")] +pub async fn pick_step_path_owned(stem: String) -> Option { + Some(std::path::PathBuf::from(format!("{stem}.step"))) +} + /// Returns an ISO 8601-like timestamp string for the STEP file header. fn chrono_timestamp() -> String { // Use seconds since Unix epoch for a simple timestamp without chrono dep. diff --git a/src/io/stl.rs b/src/io/stl.rs index a2831b3fa..8500c55bc 100644 --- a/src/io/stl.rs +++ b/src/io/stl.rs @@ -96,6 +96,13 @@ pub fn build_stl(meshes: &[&MeshModel]) -> Option> { Some(buf) } +/// Web: no save dialog — the export continues with a virtual filename and +/// the generated bytes are delivered as a browser download. +#[cfg(target_arch = "wasm32")] +pub async fn pick_stl_path_owned(stem: String) -> Option { + Some(std::path::PathBuf::from(format!("{stem}.stl"))) +} + #[cfg(test)] mod tests { use super::build_stl; diff --git a/src/io/web_drop.rs b/src/io/web_drop.rs new file mode 100644 index 000000000..92d031e8c --- /dev/null +++ b/src/io/web_drop.rs @@ -0,0 +1,143 @@ +//! Web drag & drop file opening (#344). +//! +//! winit's web backend never emits `DroppedFile`, so the page itself listens +//! for DOM `dragover`/`drop` events, reads each dropped drawing's bytes, and +//! queues them for the 50 ms control poll to open through the web-picker flow. + +use std::collections::VecDeque; +use std::sync::{Mutex, OnceLock}; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; + +/// A stray multi-drop must not grow the queue without bound; extras are +/// rejected with a console warning instead of being silently dropped. +const QUEUE_CAP: usize = 8; + +static DROPPED: Mutex)>> = Mutex::new(VecDeque::new()); +static INSTALLED: OnceLock<()> = OnceLock::new(); + +fn warn(line: &str) { + web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(line)); +} + +fn enqueue(name: String, bytes: Vec) { + let mut queue = DROPPED.lock().unwrap(); + if queue.len() >= QUEUE_CAP { + drop(queue); + warn(&format!( + "ocs: dropped-file queue full; ignoring \"{name}\"" + )); + return; + } + queue.push_back((name, bytes)); +} + +/// JS-callable entry (same shape as `ocs_control_submit`) for hosts that +/// forward drops themselves. +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn ocs_web_drop_file(name: String, bytes: js_sys::Uint8Array) { + enqueue(name, bytes.to_vec()); +} + +/// Pop the oldest dropped file that the poll has not started opening yet. +pub fn take_dropped() -> Option<(String, Vec)> { + DROPPED.lock().unwrap().pop_front() +} + +/// Feed already-read bytes through the same loader tail the web picker uses +/// (including the OPFS recent-copy payload), so `WebFileOpened` cannot tell +/// the two doors apart. +pub async fn open_dropped( + name: String, + bytes: Vec, + progress: std::sync::Arc, +) -> crate::io::WebOpenOutcome { + progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2); + let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(bytes); + let size_bytes = bytes.len() as u64; + let result = super::load_web_bytes(&name, &bytes, progress, false, "", None).await; + let keep_for_recovery = result + .as_ref() + .err() + .is_some_and(|error| error.recovery_available); + let cache_bytes = result.is_ok().then(|| std::sync::Arc::clone(&bytes)); + crate::io::WebOpenOutcome { + name, + size_bytes, + result, + recovery_bytes: keep_for_recovery.then(|| std::sync::Arc::clone(&bytes)), + cache_bytes, + record_recent: false, + } +} + +/// Register the document-level `dragover`/`drop` listeners once. `dragover` +/// MUST preventDefault or the browser navigates to the dropped file instead +/// of delivering it to the page. +pub fn install() { + if INSTALLED.set(()).is_err() { + return; + } + let Some(document) = web_sys::window().and_then(|window| window.document()) else { + return; + }; + let on_dragover = wasm_bindgen::closure::Closure::::new( + move |event: web_sys::Event| { + event.prevent_default(); + }, + ); + let _ = + document.add_event_listener_with_callback("dragover", on_dragover.as_ref().unchecked_ref()); + on_dragover.forget(); + + let on_drop = wasm_bindgen::closure::Closure::::new( + move |event: web_sys::Event| { + event.prevent_default(); + event.stop_propagation(); + let Some(drag) = event.dyn_ref::() else { + return; + }; + let Some(files) = drag.data_transfer().and_then(|transfer| transfer.files()) else { + return; + }; + for index in 0..files.length() { + let Some(file) = files.get(index) else { + continue; + }; + let name = file.name(); + // A drop has no picker filter: accept the formats the web Open + // dialog accepts; anything else is logged, never parsed. + if !is_drawing_name(&name) { + warn(&format!( + "ocs: ignored dropped file \"{name}\" (not .dwg/.dxf)" + )); + continue; + } + wasm_bindgen_futures::spawn_local(async move { + match read_file(&file).await { + Ok(bytes) => enqueue(name, bytes), + Err(error) => web_sys::console::error_1(&wasm_bindgen::JsValue::from_str( + &format!("ocs: failed to read dropped file \"{name}\": {error}"), + )), + } + }); + } + }, + ); + let _ = document.add_event_listener_with_callback("drop", on_drop.as_ref().unchecked_ref()); + on_drop.forget(); +} + +fn is_drawing_name(name: &str) -> bool { + name.rsplit_once('.').is_some_and(|(_, extension)| { + matches!(extension.to_ascii_lowercase().as_str(), "dwg" | "dxf") + }) +} + +/// Same Blob → bytes read as the OPFS recent copies (`web_recent`). +async fn read_file(file: &web_sys::File) -> Result, String> { + let buffer = JsFuture::from(file.array_buffer()) + .await + .map_err(|error| error.as_string().unwrap_or_else(|| format!("{error:?}")))?; + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) +}