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:
commit
b5a7994df0
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue