mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
7485dac6e8
commit
accfec7146
5 files changed
+84
-17
No files matched your search
+1
-1
@@ -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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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)
|
### Where we're lacking (in priority order)
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ impl eframe::App for WebShell {
|
|||||||
match f.bytes_async().await {
|
match f.bytes_async().await {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
match target {
|
match target {
|
||||||
DropTarget::Place { at, embed } => {
|
DropTarget::Place(d) => {
|
||||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) })
|
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)),
|
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-13
@@ -18,20 +18,30 @@ const THUMB: u32 = 64;
|
|||||||
const THUMB_OFFSET: egui::Vec2 = egui::vec2(14.0, 14.0);
|
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
|
/// 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 struct PlaceArrival {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub bytes: Vec<u8>,
|
pub bytes: Vec<u8>,
|
||||||
pub drop: Option<(Point, bool)>,
|
pub drop: Option<DropAt>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type PlaceInbox = Arc<Mutex<Vec<PlaceArrival>>>;
|
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.
|
/// Where files dropped on the window go.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum DropTarget {
|
pub enum DropTarget {
|
||||||
/// The active document's canvas at `at` (document coordinates); `embed` when Shift is held.
|
/// The canvas of the document they were dropped on.
|
||||||
Place { at: Point, embed: bool },
|
Place(DropAt),
|
||||||
/// Opened as documents: no document is open, or the drop missed the canvas (the tab bar).
|
/// Opened as documents: no document is open, or the drop missed the canvas (the tab bar).
|
||||||
Open,
|
Open,
|
||||||
}
|
}
|
||||||
@@ -270,21 +280,31 @@ impl VectorcraftApp {
|
|||||||
/// doesn't tell) go: onto the canvas of the open document (Shift embeds them), else opened. An
|
/// 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.
|
/// unknown position counts as the middle of the view.
|
||||||
pub fn drop_target(&self, pos: Option<egui::Pos2>, shift: bool) -> DropTarget {
|
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 };
|
let (Some(rect), Some(st), Some(v)) = (self.canvas_rect, self.session.active(), self.view()) else { return DropTarget::Open };
|
||||||
match pos {
|
let at = match pos {
|
||||||
Some(p) if !rect.contains(p) => DropTarget::Open,
|
Some(p) if !rect.contains(p) => return DropTarget::Open,
|
||||||
Some(p) => DropTarget::Place { at: crate::canvas::Xf::new(rect, v).to_doc(p), embed: shift },
|
Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p),
|
||||||
None => DropTarget::Place { at: v.center, embed: shift },
|
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
|
/// 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) {
|
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 {
|
for (name, path, bytes) in files {
|
||||||
let r = match target {
|
let r = match target {
|
||||||
DropTarget::Place { at, embed } => {
|
DropTarget::Place(DropAt { at, embed, .. }) => {
|
||||||
let mut p = match &path {
|
let mut p = match &path {
|
||||||
Some(path) => json!({ "path": path }),
|
Some(path) => json!({ "path": path }),
|
||||||
None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes) }),
|
None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes) }),
|
||||||
@@ -313,7 +333,7 @@ pub fn drain(app: &mut VectorcraftApp) {
|
|||||||
let mut picked = vec![];
|
let mut picked = vec![];
|
||||||
for a in arrived {
|
for a in arrived {
|
||||||
match a.drop {
|
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 => {
|
None => {
|
||||||
picked.push(json!({ "name": a.name }));
|
picked.push(json!({ "name": a.name }));
|
||||||
app.place.picked.retain(|(n, _)| *n != 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);
|
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]
|
#[test]
|
||||||
fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() {
|
fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- **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.
|
- **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`.
|
- **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).
|
- 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).
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user