Fix #359: web drops land in the document they were dropped on

The web reads dropped files asynchronously, and the place inbox only kept
the drop point, so a file dropped on document A was placed into whatever
document was active when its bytes arrived. A drop on a canvas now carries
place::DropAt (the document's uid, the point, embed), and drop_files makes
that document active again before placing; when it has closed meanwhile,
nothing is placed and the status bar says so. The native drop path is
synchronous and goes through the same code, so it is unchanged in effect.

Test: a drop pending while another document opens lands in the first one
at the drop point; one whose document closes is not placed anywhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 05:28:50 +07:00
1 parent 7485dac6e8
commit accfec7146
5 files changed
+84 -17

No files matched your search

+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)),
}
+33 -13
View File
@@ -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<u8>,
pub drop: Option<(Point, bool)>,
pub drop: Option<DropAt>,
}
pub type PlaceInbox = Arc<Mutex<Vec<PlaceArrival>>>;
/// 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<egui::Pos2>, 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<String>, Vec<u8>)>, 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);
+47
View File
@@ -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();
+1 -1
View File
@@ -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 `<a download>`, 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).