Merge pull request #375 from storytold/fix/issue-359

Fix #359: web drops land in the document they were dropped on
This commit is contained in:
BFlat 2026-10-08 05:32:40 +07:00 committed by GitHub
commit b5a7994df0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 84 additions and 17 deletions

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)

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)),
}

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

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();

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).