diff --git a/ROADMAP.md b/ROADMAP.md index fa819ef3..1475b745 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ since 2026-10-01. | **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | -| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); Windows/Linux packaging and accessibility are pending | +| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending | | **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel | ### Where we're lacking (in priority order) diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 63578ace..c11d07ce 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -108,8 +108,8 @@ impl eframe::App for WebShell { match f.bytes_async().await { Ok(bytes) => { match target { - DropTarget::Place { at, embed } => { - place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) }) + DropTarget::Place(d) => { + place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) }) } DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), } diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 68cf5d71..6327b317 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -18,20 +18,30 @@ const THUMB: u32 = 64; const THUMB_OFFSET: egui::Vec2 = egui::vec2(14.0, 14.0); /// A file to place that arrived asynchronously (web): picked for File → Place (`drop: None`), or -/// dropped on the canvas at a point, embedded or not. +/// dropped on a canvas. pub struct PlaceArrival { pub name: String, pub bytes: Vec, - pub drop: Option<(Point, bool)>, + pub drop: Option, } pub type PlaceInbox = Arc>>; +/// Where files dropped on a canvas land: document `doc` ([`vectorcraft_engine::DocState::uid`]) at +/// `at` (document coordinates), embedded when Shift was held. The document is kept because the web +/// reads dropped files asynchronously: another document may have become active when they arrive. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DropAt { + pub doc: u64, + pub at: Point, + pub embed: bool, +} + /// Where files dropped on the window go. #[derive(Clone, Copy, Debug, PartialEq)] pub enum DropTarget { - /// The active document's canvas at `at` (document coordinates); `embed` when Shift is held. - Place { at: Point, embed: bool }, + /// The canvas of the document they were dropped on. + Place(DropAt), /// Opened as documents: no document is open, or the drop missed the canvas (the tab bar). Open, } @@ -270,21 +280,31 @@ impl VectorcraftApp { /// doesn't tell) go: onto the canvas of the open document (Shift embeds them), else opened. An /// unknown position counts as the middle of the view. pub fn drop_target(&self, pos: Option, shift: bool) -> DropTarget { - let (Some(rect), Some(v)) = (self.canvas_rect.filter(|_| self.session.active().is_some()), self.view()) else { return DropTarget::Open }; - match pos { - Some(p) if !rect.contains(p) => DropTarget::Open, - Some(p) => DropTarget::Place { at: crate::canvas::Xf::new(rect, v).to_doc(p), embed: shift }, - None => DropTarget::Place { at: v.center, embed: shift }, - } + let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open }; + let at = match pos { + Some(p) if !rect.contains(p) => return DropTarget::Open, + Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), + None => v.center, + }; + DropTarget::Place(DropAt { doc: st.uid, at, embed: shift }) } } /// Files dropped on the window (`(name, path, bytes)`): placed on the canvas or opened (and added -/// to Open Recent Files), as `target` says. +/// to Open Recent Files), as `target` says. Files for a canvas go to the document they were +/// dropped on, which becomes active again; none are placed when it has closed since. pub fn drop_files(app: &mut VectorcraftApp, files: Vec<(String, Option, Vec)>, target: DropTarget) { + if let DropTarget::Place(d) = target { + let Some(i) = app.session.documents().iter().position(|st| st.uid == d.doc) else { + let names: Vec<&str> = files.iter().map(|(name, ..)| name.as_str()).collect(); + app.status(format!("Couldn't place {}: the document it was dropped on was closed", names.join(", "))); + return; + }; + app.session.set_active(i); + } for (name, path, bytes) in files { let r = match target { - DropTarget::Place { at, embed } => { + DropTarget::Place(DropAt { at, embed, .. }) => { let mut p = match &path { Some(path) => json!({ "path": path }), None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes) }), @@ -313,7 +333,7 @@ pub fn drain(app: &mut VectorcraftApp) { let mut picked = vec![]; for a in arrived { match a.drop { - Some((at, embed)) => drop_files(app, vec![(a.name, None, a.bytes)], DropTarget::Place { at, embed }), + Some(d) => drop_files(app, vec![(a.name, None, a.bytes)], DropTarget::Place(d)), None => { picked.push(json!({ "name": a.name })); app.place.picked.retain(|(n, _)| *n != a.name); diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 2a7e91cb..79a75409 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -115,6 +115,53 @@ fn files_dropped_off_the_canvas_or_with_no_document_open_and_are_recent() { assert_eq!(app.session.documents().len(), 2); } +/// The images in open document `i`. +fn images(app: &VectorcraftApp, i: usize) -> usize { + let mut n = 0; + app.session.documents()[i].doc.walk(|node| n += usize::from(matches!(node.kind, NodeKind::Image(_)))); + n +} + +/// The web reads dropped files asynchronously (#359): a file that arrives after another document +/// became active still lands where it was dropped, and is dropped itself when that document closed. +#[test] +fn a_file_read_after_another_document_became_active_lands_where_it_was_dropped() { + let mut app = app(); + let inbox = crate::place::PlaceInbox::default(); + app.services.place_inbox = Some(inbox.clone()); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![], &[], false); + frame(&mut app, &ctx, vec![], &[], false); + let rect = app.canvas_rect.expect("the canvas is laid out"); + let pos = rect.center() + vec2(60.0, -40.0); + let drop_on_active = |app: &VectorcraftApp| match app.drop_target(Some(pos), false) { + crate::place::DropTarget::Place(d) => d, + t => panic!("{t:?}"), + }; + let arrive = + |name: &str, drop| inbox.lock().unwrap().push(crate::place::PlaceArrival { name: name.into(), bytes: png(20, 10, 72.0), drop: Some(drop) }); + // Dropped on A; another document opens while the file is read. + let on_a = drop_on_active(&app); + let want = Xf::new(rect, app.view().unwrap()).to_doc(pos); + app.run("file.new", json!({"width": 600, "height": 500})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + arrive("late.png", on_a); + frame(&mut app, &ctx, vec![], &[], false); + assert_eq!(app.session.active_index(), Some(0), "the document it was dropped on is active again"); + assert_eq!((images(&app, 0), images(&app, 1)), (1, 0)); + let c = selected_image(&app).geometric_bounds().unwrap().center(); + assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "where it was dropped: {c:?} vs {want:?}"); + // Dropped on B, which closes before the file arrives: nothing is placed. + app.run("document.activate", json!({"index": 1})).unwrap(); + let on_b = drop_on_active(&app); + app.run("file.close", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), 1); + arrive("orphan.png", on_b); + frame(&mut app, &ctx, vec![], &[], false); + assert_eq!(images(&app, 0), 1, "not placed in the document left open"); + assert!(app.ui.status.contains("orphan.png") && app.ui.status.contains("closed"), "{}", app.ui.status); +} + #[test] fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() { let mut app = app(); diff --git a/docs/development.md b/docs/development.md index d1e105d1..bebdb21c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -20,7 +20,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: - **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame. - **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. -- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox. (The app's synchronous drop path is compiled out on wasm32.) +- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files dropped on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.) - **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. - Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).