From a10e288a581d2fda01275b0a07c2f148852f5f76 Mon Sep 17 00:00:00 2001 From: texjer Date: Tue, 3 Nov 2026 21:54:43 -0500 Subject: [PATCH] Place: dropped images load the place cursor, in the order they came Split out of #2073. Port of #1307's drops and #1666. - Several images dropped together load the place cursor in the order they came, each run of them where it comes among the other files, after the images it already holds; click to place them one by one. - One image dropped off a frame that takes it loads the place cursor instead of being placed at once; on such a frame it still fills it. - A drop is handled in its order, each file at its own pointer, and a dialog (a multi-page PDF's Import Options, an alert) cancels the rest. - On the web, files dropped together are read as one batch (none if one can't be read), and a single dropped image loads the place cursor. --- ROADMAP.md | 1 + apps/designcraft-web/src/browser_policy.rs | 39 ++++++- apps/designcraft-web/src/web.rs | 23 +++- crates/ui-egui/src/app/files.rs | 97 ++++++++++++---- crates/ui-egui/src/app/input.rs | 9 +- crates/ui-egui/src/tests.rs | 128 ++++++++++++++++++++- docs/multi-image-placement.md | 10 +- 7 files changed, 269 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3cef01494..8aedbe1f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -405,6 +405,7 @@ Newest first. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. - **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. +- **2026-10-30** · Dropping images from the file manager works as in InDesign: images dropped together load the place cursor in the order they came, and one image dropped off a frame that takes it loads the place cursor instead of being placed at once (on a frame it still fills it). Documents and text files dropped keep opening and flowing as before. Ported from #1307 and #1666; see [multi-image placement](docs/multi-image-placement.md). No readiness percentage change. - **2026-10-30** · Placing several images works as in InDesign. File › Place takes several files, and they load the place cursor in the order chosen: it shows the first one's thumbnail with how many are loaded ("(6)"), each click places the next, arrow keys while dragging lay out a grid (`place.grid`), and Escape unloads the rest (`place.cancel`). A drag with the cursor draws the frame at the graphic's proportions, with its scale. Ported from #1307, #1392 and #1432 (multi-image placement, cursor thumbnail and count); see [multi-image placement](docs/multi-image-placement.md). No readiness percentage change. - **2026-10-30** · macOS: a file dragged in from the Finder fills the frame it is dropped on. winit sends no pointer events during such a drag, so the drop asks the system where the pointer is (`Services::pointer`, from `NSEvent.mouseLocation`), at any UI scale. No readiness percentage change. - **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). diff --git a/apps/designcraft-web/src/browser_policy.rs b/apps/designcraft-web/src/browser_policy.rs index 524df6cc5..ca82c71ca 100644 --- a/apps/designcraft-web/src/browser_policy.rs +++ b/apps/designcraft-web/src/browser_policy.rs @@ -1,4 +1,4 @@ -//! Pure browser startup and download metadata policies. +//! Browser startup, download metadata, and atomic import policies. /// Query flags are case-sensitive decoded keys. Values are ignored: `?sample=false` /// still enables sample startup, and repeated keys have the same presence semantics. @@ -65,3 +65,40 @@ mod tests { } } } + +/// Read a drop in order, publishing nothing unless every file can be read. +pub(crate) async fn read_batch(files: impl IntoIterator, mut read: F) -> Result, E> +where + F: FnMut(T) -> Fut, + Fut: std::future::Future>, +{ + let mut result = Vec::new(); + for file in files { + result.push(read(file).await?); + } + Ok(result) +} + +#[cfg(test)] +mod batch_tests { + #[test] + fn failed_read_aborts_the_whole_batch() { + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + let mut seen = Vec::new(); + let result = { + let future = super::read_batch([1, 2, 3], |n| { + seen.push(n); + std::future::ready(if n == 2 { Err("read failed") } else { Ok(n) }) + }); + let mut future = std::pin::pin!(future); + future.as_mut().poll(&mut Context::from_waker(Waker::noop())) + }; + assert_eq!(result, Poll::Ready(Err("read failed"))); + assert_eq!(seen, [1, 2]); + let mut success = std::pin::pin!(super::read_batch([3, 1], |n| std::future::ready(Ok::<_, ()>(n)))); + assert_eq!(success.as_mut().poll(&mut Context::from_waker(Waker::noop())), Poll::Ready(Ok(vec![3, 1]))); + } +} diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 8b23ef6b7..c87581e32 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -357,18 +357,29 @@ impl eframe::App for WebShell { let _ = self.graphics.run_if_healthy(|| { // Dropped files belong to this host iteration's raw input, not to stale logic input: // read them here once, before the batch is saved as pending for a hidden pass. - for f in std::mem::take(&mut raw.dropped_files) { + // Files dropped together arrive together, in their order, so several images load the + // place cursor as one batch. + let dropped = std::mem::take(&mut raw.dropped_files); + if !dropped.is_empty() { let request = self.app.import_request("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes }); + let files = crate::browser_policy::read_batch(dropped, |f| { + let request = request.clone(); + async move { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + let bytes = f.bytes_async().await.map_err(|e| format!("couldn't read dropped file {name}: {e}"))?; + Ok::<_, String>(ImportedFile { request, name, bytes }) + } + }) + .await; + match files { + Ok(files) => { + inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(files); ctx.request_repaint(); } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + Err(error) => log::error!("{error}"), } }); } diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3df85b504..bb0a20661 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -162,7 +162,7 @@ impl DesignApp { let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); let cursor_image = |file: &ImportedFile| { - file.request.purpose == "place" + matches!(file.request.purpose.as_str(), "place" | "drop") && !file.name.to_ascii_lowercase().ends_with(".pdf") && (is_cursor_image(&file.name) || designcraft_render::image_size(&file.bytes).is_some()) }; @@ -175,7 +175,7 @@ impl DesignApp { shortcut_import::import_delivered(self, &file.bytes); continue; } - let placing = file.request.purpose == "place"; + let placing = matches!(file.request.purpose.as_str(), "place" | "drop"); let mut group = vec![]; if cursor_image(&file) { while let Some(next) = @@ -184,8 +184,9 @@ impl DesignApp { group.push(next); } } - // Several images: into the place cursor. - if !group.is_empty() { + // Several images, or one dropped (a browser drop has no position to place it at or + // frame to fill): into the place cursor. + if !group.is_empty() || (file.request.purpose == "drop" && cursor_image(&file)) { if self.ui.dialog.is_some() { self.status(crate::i18n::tr(&self.ui.language, "Image import cancelled while a dialog is open")); continue; @@ -262,9 +263,9 @@ impl DesignApp { } /// A file dropped on the window: documents open, the swatches in a swatch exchange (.ase) file - /// are loaded, and anything else is placed on the spread in view (a parent spread while parents - /// are being edited). A file without an extension opens when its content is a DesignCraft or - /// IDML document. + /// are loaded, an image loads into the place cursor, and anything else is placed on the spread + /// in view (a parent spread while parents are being edited). A file without an extension + /// opens when its content is a DesignCraft or IDML document. pub fn open_dropped(&mut self, path: &str) -> Result { self.open_dropped_at(path, None) } @@ -273,18 +274,48 @@ impl DesignApp { /// window's coordinates. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn open_dropped_files(&mut self, files: &[egui::DroppedFileHandle], pointer: Option) { + let drops = files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).map(|p| (p, pointer)).collect(); + self.open_dropped_batch(drops); + } + + /// Files dropped on the window together, each with the pointer it was dropped at, handled in + /// the order they came. Each that isn't an image opens or is placed as + /// [`Self::open_dropped_at`] does at its own pointer. One image is dropped as that does too + /// (filling the frame under the pointer, else loading the place cursor); several load the place + /// cursor, each run of them where it came in the order, after any images it already holds, + /// whatever is under the pointer: click to place them one by one, drag with the arrow keys for + /// a grid. The rest of the drop is cancelled once a file opens a dialog (a multi-page PDF's + /// Import Options, an alert), as File › Place with several chosen is. + pub fn open_dropped_batch(&mut self, drops: Vec<(String, Option)>) { + let several = drops.iter().filter(|(p, _)| is_cursor_image(p)).count() > 1; + // Only the first graphic of a drop fills the frame under the pointer. let mut frame_free = true; - for f in files { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - let _ = self.place_dropped(&p, pointer, &mut frame_free); + let mut images = Vec::new(); + // `None` at the end flushes the images left. + for drop in drops.into_iter().map(Some).chain([None]) { + if several + && let Some((path, _)) = &drop + && is_cursor_image(path) + { + images.push(path.clone()); + continue; } + // A file that can't be placed has its alert, which cancels the rest below. + if !images.is_empty() { + let _ = self.place_paths(std::mem::take(&mut images)); + } + let Some((path, pointer)) = drop else { break }; + if self.ui.dialog.is_some() { + self.status(crate::i18n::tr(&self.ui.language, "Place cancelled while a dialog is open")); + return; + } + let _ = self.place_dropped(&path, pointer, &mut frame_free); } } /// [`Self::open_dropped`] at the pointer: a graphic dropped on an empty or graphic frame fills - /// it, elsewhere its top left lands where the file was dropped on the canvas; text goes to a - /// new frame on the document page there. + /// it; elsewhere an image loads into the place cursor, and another graphic's top left lands + /// where the file was dropped on the canvas; text goes to a new frame on the document page there. pub fn open_dropped_at(&mut self, path: &str, pointer: Option) -> Result { self.place_dropped(path, pointer, &mut true) } @@ -303,13 +334,22 @@ impl DesignApp { if document { return self.open_file("file.open", json!({"path": path})); } - let mut params = json!({"path": path}); - // A graphic dropped on an empty or graphic frame fills it, as File > Place does. Text keeps + // A graphic dropped on a frame that takes one fills it, as File > Place does. Text keeps // getting a frame of its own, so it never replaces the image in a graphic frame. let frame = if *frame_free && !designcraft_textimport::is_text_file(path) { canvas::drop_frame(self, pointer) } else { None }; - if let (Some(frame), Some(o)) = (frame, params.as_object_mut()) { - o.insert("frame".into(), json!(frame.0)); + if let Some(frame) = frame { + let placed = self.open_file("file.place", json!({"path": path, "frame": frame.0})); + if placed.is_ok() { + *frame_free = false; + } + return placed; } + // An image dropped anywhere else waits in the place cursor, after any it already holds. + if is_cursor_image(path) { + let append = self.session.loaded.is_some(); + return self.open_file("place.load", json!({"path": path, "append": append})); + } + let mut params = json!({"path": path}); if let (Some((sr, at)), Some(o)) = (canvas::drop_point(self, pointer), params.as_object_mut()) { o.insert("spread".into(), serde_json::to_value(sr).unwrap_or(Value::Null)); o.insert("x".into(), json!(at.x)); @@ -319,11 +359,7 @@ impl DesignApp { o.insert("page".into(), json!(st.doc.first_page_of_spread(si) + pi + 1)); } } - let placed = self.open_file("file.place", params); - if frame.is_some() && placed.is_ok() { - *frame_free = false; - } - placed + self.open_file("file.place", params) } /// Queue files the operating system asked the app to open. They can arrive before the window @@ -908,6 +944,23 @@ pub(crate) mod browser_import_tests { assert_eq!(loaded_names(&app), ["a.png", "one.pdf", "b.png"], "in the order dropped, all in the cursor"); } + /// A browser drop of one image (it has no position or frame under it) loads the place + /// cursor; images dropped together load it at once, after what it holds. + #[test] + fn browser_dropped_images_load_the_place_cursor() { + let mut app = app(); + let request = app.import_request("drop"); + deliver(&mut app, request, "one.png", png(200)); + assert_eq!(app.session.tool_id(), "placeGun"); + assert!(app.session.doc().unwrap().doc.spreads[0].items.is_empty(), "nothing placed yet"); + let request = app.import_request("drop"); + for (name, v) in [("two.png", 90), ("three.png", 60)] { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request: request.clone(), name: name.into(), bytes: png(v) }); + } + app.drain_inbox(); + assert_eq!(loaded_names(&app), ["one.png", "two.png", "three.png"]); + } + #[test] fn batch_cancel_status_uses_the_interface_language() { let mut app = app(); diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 2682478c4..2281d3643 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -154,13 +154,10 @@ impl DesignApp { if drops.is_empty() { return; } - // Each file keeps the pointer it was dropped at; the system is asked once, for those - // that came without one. + // Dropped together, they go together: several images load the place cursor at once. Each + // file keeps the pointer it was dropped at. let system = drops.iter().any(|d| d.pointer.is_none()).then(|| self.system_pointer(ctx)).flatten(); - let mut frame_free = true; - for drop in drops { - let _ = self.place_dropped(&drop.path, drop.pointer.or(system), &mut frame_free); - } + self.open_dropped_batch(drops.into_iter().map(|d| (d.path, d.pointer.or(system))).collect()); } /// Where the pointer is in the window, asked of the system ([`crate::Services::pointer`]), diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index e53e603a1..1f8cedf68 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -884,10 +884,20 @@ fn a_graphic_dropped_on_a_frame_fills_it() { let r = app.open_dropped_at(&path.to_string_lossy(), Some(inside)); let _ = std::fs::remove_file(&path); assert_eq!(r.unwrap()["id"].as_u64(), Some(frame), "the frame under the pointer is filled"); + assert!(app.session.loaded.is_none(), "nothing waits in the place cursor"); let d = &app.session.doc().unwrap().doc; assert_eq!(d.spreads[0].items.len(), 1, "no new object"); assert!(matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Graphic(_))); assert!(canvas::drop_frame(&app, Some(outside)).is_none(), "an empty spot on the page is no frame"); + // Dropped again, it replaces the image there rather than loading the place cursor. + let path = dropped_png("dropframe-again"); + app.open_dropped_at(&path.to_string_lossy(), Some(inside)).unwrap(); + let _ = std::fs::remove_file(&path); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.spreads[0].items.len(), 1, "still no new object"); + let designcraft_doc::Content::Graphic(g) = &d.item(designcraft_doc::ItemId(frame)).unwrap().content else { panic!("a graphic frame") }; + assert!(d.assets[&g.asset].name.contains("dropframe-again"), "the frame shows the new image"); + assert!(app.session.loaded.is_none(), "nothing waits in the place cursor"); // Text dropped there gets a frame of its own and leaves the image in place. let text = std::env::temp_dir().join(format!("designcraft-ui-{}-dropframe.txt", std::process::id())); std::fs::write(&text, "Dropped words").unwrap(); @@ -920,6 +930,7 @@ fn a_file_dropped_without_pointer_events_fills_the_frame_under_the_system_pointe ctx.run_ui(raw, |ui| app.take_pending_drops(ui.ctx())).textures_delta.clear(); let _ = std::fs::remove_file(&path); assert!(app.pending_drops.is_empty()); + assert!(app.session.loaded.is_none(), "nothing waits in the place cursor at {zoom}"); let d = &app.session.doc().unwrap().doc; assert_eq!(d.spreads[0].items.len(), 1, "no new object at {zoom}"); let designcraft_doc::Content::Graphic(g) = &d.item(designcraft_doc::ItemId(frame)).unwrap().content else { @@ -940,7 +951,8 @@ fn a_locked_frame_is_not_a_drop_target() { r.unwrap(); let d = &app.session.doc().unwrap().doc; assert!(matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned), "the locked frame stays empty"); - assert_eq!(d.spreads[0].items.len(), 2, "the graphic became a new object"); + assert_eq!(d.spreads[0].items.len(), 1, "no new object yet"); + assert!(app.session.loaded.is_some(), "the image waits in the place cursor"); } #[test] @@ -954,8 +966,10 @@ fn only_the_first_graphic_of_a_drop_fills_the_frame() { let _ = (std::fs::remove_file(&first), std::fs::remove_file(&second)); assert_eq!(a.unwrap()["id"].as_u64(), Some(frame)); assert_ne!(b.unwrap()["id"].as_u64(), Some(frame), "the second graphic doesn't replace the first"); + // An image dropped off a frame that takes it waits in the place cursor. + assert!(app.session.loaded.is_some(), "the second image waits in the place cursor"); let d = &app.session.doc().unwrap().doc; - assert_eq!(d.spreads[0].items.len(), 2, "the second graphic is a new object"); + assert_eq!(d.spreads[0].items.len(), 1, "no new object"); assert!(matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Graphic(_))); } @@ -971,11 +985,121 @@ fn a_file_dropped_while_a_parent_is_open_lands_on_the_parent() { let r = app.open_dropped(&path.to_string_lossy()); let _ = std::fs::remove_file(&path); r.unwrap(); + // The image waits in the place cursor; a click on the parent spread places it there. + assert!(app.session.loaded.is_some()); + let st = app.session.active().unwrap(); + let at = designcraft_tools::CanvasLayout::new(&st.doc, st.editing_parents) + .slot(designcraft_doc::SpreadRef::Parent(0)) + .unwrap() + .to_canvas(designcraft_geom::Point::new(50.0, 50.0)); + let v = designcraft_engine::ViewInfo::at_zoom(1.0); + for kind in [designcraft_tools::PointerKind::Down, designcraft_tools::PointerKind::Up] { + app.session.pointer(&designcraft_tools::PointerEvent::new(kind, at.x, at.y), v).unwrap(); + } let d = &app.session.doc().unwrap().doc; assert_eq!(d.parents[0].items.len(), 1, "the graphic is on the parent spread"); assert!(d.spreads.iter().all(|s| s.items.is_empty()), "no document page gets it"); } +/// The names of the images in the place cursor, the one a click places first. +fn loaded_names(app: &DesignApp) -> Vec { + let doc = &app.session.doc().unwrap().doc; + app.session.loaded.iter().chain(app.session.loaded_queue.iter()).map(|g| doc.assets[&g.asset].name.clone()).collect() +} + +/// One image dropped where no frame can take it loads the place cursor instead of being placed: +/// the next click places it. A frame selected doesn't take it, and another image dropped +/// meanwhile queues after it. +#[test] +fn a_single_image_dropped_off_a_frame_loads_the_place_cursor() { + let (mut app, frame, _, outside) = app_with_drop_frame(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let first = dropped_png("drop-loads-a"); + let second = dropped_png("drop-loads-b"); + app.open_dropped_at(&first.to_string_lossy(), Some(outside)).unwrap(); + assert_eq!(app.session.tool_id(), "placeGun"); + assert_eq!(loaded_names(&app).len(), 1); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), 1, "nothing placed yet"); + app.run("selection.set", json!({"ids": [frame]})).unwrap(); + app.open_dropped_at(&second.to_string_lossy(), Some(outside)).unwrap(); + let _ = (std::fs::remove_file(&first), std::fs::remove_file(&second)); + let names = loaded_names(&app); + assert_eq!(names.len(), 2, "{names:?}"); + assert!(names[0].contains("drop-loads-a") && names[1].contains("drop-loads-b"), "{names:?}"); + let d = &app.session.doc().unwrap().doc; + assert!(matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned)); + app.run("place.drop", json!({"x": 300, "y": 300})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), 2, "the click placed the first"); + assert_eq!(app.session.tool_id(), "placeGun", "the second is still loaded"); +} + +/// Several images dropped together load the place cursor in the order they came, even over a +/// frame; a file that isn't an image keeps its own drop behaviour (text gets a frame). +#[test] +fn several_dropped_images_load_the_place_cursor_in_order() { + let (mut app, frame, inside, _) = app_with_drop_frame(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let a = dropped_png("drop-many-a"); + let b = std::env::temp_dir().join(format!("designcraft-ui-{}-drop-many-b.svg", std::process::id())); + std::fs::write(&b, br##""##).unwrap(); + let c = dropped_png("drop-many-c"); + let text = std::env::temp_dir().join(format!("designcraft-ui-{}-drop-many.txt", std::process::id())); + std::fs::write(&text, "Dropped words").unwrap(); + let paths = [&a, &text, &b, &c].map(|p| p.to_string_lossy().to_string()); + app.open_dropped_batch(paths.iter().map(|p| (p.clone(), Some(inside))).collect()); + for p in [&a, &b, &c, &text] { + let _ = std::fs::remove_file(p); + } + let names = loaded_names(&app); + assert_eq!(names.len(), 3, "{names:?}"); + for (name, want) in names.iter().zip(["drop-many-a", "drop-many-b", "drop-many-c"]) { + assert!(name.contains(want), "{names:?}"); + } + assert_eq!(app.session.tool_id(), "placeGun"); + let d = &app.session.doc().unwrap().doc; + assert!(matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned), "the frame isn't filled"); + assert_eq!(d.spreads[0].items.len(), 2, "the text got a frame of its own"); + // Clicks place them one by one; Escape unloads the rest. + app.run("place.drop", json!({"x": 300, "y": 300})).unwrap(); + assert_eq!(app.session.loaded_queue.len(), 1); + let mods = designcraft_tools::Mods::default(); + app.session.tool_key(designcraft_tools::ToolKey::Escape, mods, designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap(); + assert!(app.session.loaded.is_none() && app.session.loaded_queue.is_empty()); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// A drop is handled in the order its files came, each at its own pointer: an image dropped on a +/// frame before a document fills that frame in the document that was open, not in the one the +/// drop opens after it. +#[test] +fn a_mixed_drop_keeps_its_order() { + let (mut app, frame, inside, outside) = app_with_drop_frame(); + let mut other = DesignApp::new(Session::new(), Services::default()); + other.run("file.new", json!({})).unwrap(); + let document = std::env::temp_dir().join(format!("designcraft-ui-{}-drop-mixed.designcraft", std::process::id())); + other.run("file.saveAs", json!({"path": document})).unwrap(); + let image = dropped_png("drop-mixed"); + let text = std::env::temp_dir().join(format!("designcraft-ui-{}-drop-mixed.txt", std::process::id())); + std::fs::write(&text, "Dropped words").unwrap(); + let drops = vec![ + (image.to_string_lossy().to_string(), Some(inside)), + (text.to_string_lossy().to_string(), Some(outside)), + (document.to_string_lossy().to_string(), Some(outside)), + ]; + app.open_dropped_batch(drops); + for p in [&image, &text, &document] { + let _ = std::fs::remove_file(p); + } + assert_eq!(app.session.documents().len(), 2, "the document opened last"); + let d = &app.session.documents()[0].doc; + assert!( + matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Graphic(_)), + "the image filled the frame in the document open before the drop" + ); + assert_eq!(d.spreads[0].items.len(), 2, "the text got a frame of its own"); + assert!(app.session.loaded.is_none(), "nothing waits in the place cursor"); +} + #[test] fn file_open_failures_show_an_alert() { let path = unreadable_file("open.designcraft"); diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index 997414bcd..6281de637 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -1,6 +1,6 @@ # Multiple images -Choose **File > Place** (Cmd+D on macOS) and select several images. They load into a placement cursor in the order supplied by the file picker. The cursor shows the next image's thumbnail and, beside it, how many are loaded ("(6)"). +Choose **File > Place** (Cmd+D on macOS) and select several images, or drop a selection of images from your file manager onto the document. Multiple images load into a placement cursor in the order supplied by the file picker (or the drop). The cursor shows the next image's thumbnail and, beside it, how many are loaded ("(6)"). - Click to place the next image at its natural size. - Drag to draw its frame: it keeps the image's proportions, and shows the scale beside the pointer. @@ -10,6 +10,14 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. Image batches support up to 1000 images. Rows and columns each range from 1 to 100. A batch containing an unreadable image fails without replacing the existing queue or changing the document. An invalid grid area also leaves the document and queue unchanged. +## Dropping files + +- One image dropped on an empty or graphic frame fills it, or replaces the image in it. +- One image dropped anywhere else loads into the place cursor, as File > Place does with nothing selected: the next click or drag places it. +- Several images dropped together load into the place cursor in the order they came, wherever they are dropped. +- Images dropped while the cursor already holds some queue after them. +- DesignCraft, IDML and InDesign documents open; text files (.txt, .docx, .rtf, .md) and spreadsheets go into a new frame where they are dropped; a PDF is placed where it is dropped (or fills the frame under it); swatch exchange (.ase) files load their swatches. + Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. ## Automation