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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 05:28:50 +07:00
co-authored by Claude Opus 5.5
parent 7485dac6e8
commit accfec7146
5 changed files with 84 additions and 17 deletions
+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();