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.
This commit is contained in:
texjer committed 2026-11-03 23:05:47 -05:00
1 parent 4d34f83926
commit a10e288a58
7 files changed
+269 -38

No files matched your search

+1
View File
@@ -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).
+38 -1
View File
@@ -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<T, R, E, F, Fut>(files: impl IntoIterator<Item = T>, mut read: F) -> Result<Vec<R>, E>
where
F: FnMut(T) -> Fut,
Fut: std::future::Future<Output = Result<R, E>>,
{
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])));
}
}
+17 -6
View File
@@ -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}"),
}
});
}
+75 -22
View File
@@ -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<Value, String> {
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<egui::Pos2>) {
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<egui::Pos2>)>) {
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<egui::Pos2>) -> Result<Value, String> {
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();
+3 -6
View File
@@ -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`]),
+126 -2
View File
@@ -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<String> {
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##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"##).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");
+9 -1
View File
@@ -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