mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
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:
1 parent
4d34f83926
commit
a10e288a58
7 files changed
+269
-38
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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])));
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user