Merge branch 'pr-2309' into multi-place

This commit is contained in:
aiongg committed 2026-11-14 05:49:14 +00:00
commit 62f70cc03c
11 files changed
+442 -72

No files matched your search

+1
View File
@@ -433,6 +433,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`). Switching, opening or closing a document unloads it too, back to the Selection tool. 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}"),
}
});
}
+28 -1
View File
@@ -11,6 +11,33 @@ pub fn screen_position() -> Option<egui::Pos2> {
// AppKit's origin is the bottom left of the first screen, the one with the menu bar.
let height = NSScreen::screens(mtm).firstObject()?.frame().size.height;
let at = NSEvent::mouseLocation();
let (x, y) = (at.x, height - at.y);
from_appkit(at.x, at.y, height)
}
/// A point in AppKit's global coordinates (origin at the bottom left of the main display, the one
/// with the menu bar, y up) in winit's (origin at its top left, y down), given that display's
/// height. Only the main display's height: winit flips window positions the same way
/// (`flip_window_screen_coordinates`, against `CGMainDisplayID`), whatever display the window is
/// on, so a display left of or above the main one gives negative coordinates in both.
fn from_appkit(x: f64, y: f64, main_height: f64) -> Option<egui::Pos2> {
let y = main_height - y;
(x.is_finite() && y.is_finite()).then(|| egui::pos2(x as f32, y as f32))
}
#[cfg(test)]
mod tests {
use super::from_appkit;
#[test]
fn appkit_points_flip_against_the_main_display_only() {
// The main display is 1000 points high: its top left is (0, 0), its bottom left (0, 1000).
assert_eq!(from_appkit(0.0, 1000.0, 1000.0), Some(egui::pos2(0.0, 0.0)));
assert_eq!(from_appkit(200.0, 100.0, 1000.0), Some(egui::pos2(200.0, 900.0)));
// A second display above the main one, and one to its left: negative, as winit reports a
// window there.
assert_eq!(from_appkit(300.0, 1200.0, 1000.0), Some(egui::pos2(300.0, -200.0)));
assert_eq!(from_appkit(-800.0, 500.0, 1000.0), Some(egui::pos2(-800.0, 500.0)));
assert_eq!(from_appkit(f64::NAN, 0.0, 1000.0), None);
assert_eq!(from_appkit(0.0, 0.0, f64::INFINITY), None);
}
}
+1 -1
View File
@@ -212,7 +212,7 @@ impl DesignApp {
// must not replay shortcuts or dropped files from stale input.
if ctx.current_pass_index() == 0 {
#[cfg(not(target_arch = "wasm32"))]
self.take_pending_drops(&ctx);
self.take_pending_drops();
menus::shortcuts(self, &ctx);
}
let t0 = now_ms();
+75 -17
View File
@@ -195,7 +195,7 @@ impl DesignApp {
/// the batch.
fn import_delivered(&mut self, files: Vec<ImportedFile>, mut append: bool) {
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())
};
@@ -206,7 +206,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) =
@@ -215,8 +215,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;
@@ -304,9 +305,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)
}
@@ -315,18 +316,49 @@ 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): unlike File › Place with several chosen, nothing waits for a
/// PDF's options to be confirmed.
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)
}
@@ -346,14 +378,23 @@ 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 is_text = designcraft_textimport::is_text_file(path);
let frame = if *frame_free && !is_text { 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));
@@ -956,6 +997,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"]);
}
/// A graphic frame, holding a picture and selected, with an empty place cursor; the frame's id.
fn select_a_graphic_frame(app: &mut DesignApp) -> u64 {
let frame = app.run("frame.create", json!({"rect": [10, 10, 100, 100]})).unwrap()["id"].as_u64().unwrap();
+26 -21
View File
@@ -59,10 +59,14 @@ impl DesignApp {
_ => None,
});
let generation = self.input_generation();
// A drop without pointer events (dragged in from another application) asks the
// system now, while the pointer is still where the files were let go.
let system = if pointer.is_none() && !raw.dropped_files.is_empty() { self.system_pointer(ctx, raw) } else { None };
let batch = self.pending_drops.last().map_or(0, |d| d.batch.wrapping_add(1));
for file in std::mem::take(&mut raw.dropped_files) {
let path = file.path().to_string_lossy().to_string();
if !path.is_empty() {
self.pending_drops.push(PendingDrop { path, pointer, generation });
self.pending_drops.push(PendingDrop { path, pointer, system, batch, generation });
}
}
}
@@ -144,38 +148,30 @@ impl DesignApp {
/// Open or place the files dropped since the last UI pass, at the pointer. Files dropped
/// without pointer events (dragged in from another application) are placed where the system
/// says the pointer is, so one dropped on a frame still fills it.
/// said the pointer was when they arrived, so one dropped on a frame still fills it.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn take_pending_drops(&mut self, ctx: &egui::Context) {
pub(crate) fn take_pending_drops(&mut self) {
// Sample the generation once for the whole batch: placing the first valid file must not
// suppress later files that belong to the same captured batch.
let current = self.input_generation();
let drops: Vec<_> = std::mem::take(&mut self.pending_drops).into_iter().filter(|d| d.generation == current).collect();
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.
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);
// Dropped together, they go together: several images load the place cursor at once. Each
// file keeps the pointer it was dropped at, or the system's from when it arrived. Separate
// drops stay separate, so a single image still fills the frame it was dropped on.
for batch in drops.chunk_by(|a, b| a.batch == b.batch) {
self.open_dropped_batch(batch.iter().map(|d| (d.path.clone(), d.pointer.or(d.system))).collect());
}
}
/// Where the pointer is in the window, asked of the system ([`crate::Services::pointer`]),
/// for files dropped without pointer events: dragged in from another application, they bring
/// none. `None` when the system doesn't say or the window's place isn't known.
/// none. Converted with the window's place in `raw` (that input's own viewport info, else the
/// last pass's). `None` when the system doesn't say or the window's place isn't known.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn system_pointer(&self, ctx: &egui::Context) -> Option<egui::Pos2> {
fn system_pointer(&self, ctx: &egui::Context, raw: &egui::RawInput) -> Option<egui::Pos2> {
let at = (self.services.pointer.as_ref()?)()?;
// The system's points are unscaled; egui's (the window's rect too) are divided by the
// UI scale.
let zoom = ctx.zoom_factor();
let at = if zoom.is_finite() && zoom > 0.0 { at / zoom } else { at };
let inner = ctx.input(|i| i.viewport().inner_rect)?;
let local = at - inner.min.to_vec2();
(local.x.is_finite() && local.y.is_finite()).then_some(local)
let inner = raw.viewports.get(&raw.viewport_id).and_then(|v| v.inner_rect).or_else(|| ctx.input(|i| i.viewport().inner_rect))?;
screen_to_window(at, ctx.zoom_factor(), inner)
}
/// The current state an injected batch belongs to.
@@ -225,3 +221,12 @@ impl DesignApp {
self.control_input_frame.store(false, std::sync::atomic::Ordering::Relaxed);
}
}
/// A point on the screen in the system's unscaled points, in the window whose content is `inner`
/// (egui's points, divided by the UI scale `zoom`). `None` when the result isn't finite.
#[cfg(not(target_arch = "wasm32"))]
fn screen_to_window(at: egui::Pos2, zoom: f32, inner: egui::Rect) -> Option<egui::Pos2> {
let at = if zoom.is_finite() && zoom > 0.0 { at / zoom } else { at };
let local = at - inner.min.to_vec2();
(local.x.is_finite() && local.y.is_finite()).then_some(local)
}
+7
View File
@@ -119,6 +119,13 @@ struct InputGeneration {
struct PendingDrop {
path: String,
pointer: Option<egui::Pos2>,
/// Where the system said the pointer was in the window when this drop's raw input arrived,
/// for a drop that brought no pointer events ([`crate::Services::pointer`]). Each drop keeps
/// its own, converted with the window's place and the UI scale of that input.
system: Option<egui::Pos2>,
/// Which drop this file came with: files of one raw input share it, so drops that arrive
/// before the same UI pass stay separate.
batch: u64,
generation: InputGeneration,
}
+2 -1
View File
@@ -85,6 +85,7 @@ pub struct Services {
/// The pointer's place on the screen, asked when a file is dropped: a file dragged in from
/// another application brings no pointer events with it (winit reports none during a drag on
/// macOS), so without it a drop can't tell which frame it landed on. `None`: a drop without
/// pointer events lands in the middle of the view.
/// pointer events fills no frame: an image loads the place cursor, and other files land in the
/// middle of the view (`canvas::drop_point`).
pub pointer: Option<PointerFn>,
}
+238 -23
View File
@@ -961,10 +961,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();
@@ -976,6 +986,15 @@ fn a_graphic_dropped_on_a_frame_fills_it() {
assert!(matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Graphic(_)));
}
/// The raw input of a drop without pointer events, with the window's content at `inner`.
#[cfg(not(target_arch = "wasm32"))]
fn pointerless_drop(path: &std::path::Path, inner: egui::Rect) -> egui::RawInput {
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, inner.size())), ..Default::default() };
raw.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { inner_rect: Some(inner), ..Default::default() });
raw.dropped_files.push(dropped_file(path));
raw
}
/// A file dragged in from the Finder brings no pointer events (winit sends none during a drag on
/// macOS): the drop asks the system where the pointer is, so one dropped on a frame fills it.
#[cfg(not(target_arch = "wasm32"))]
@@ -988,15 +1007,16 @@ fn a_file_dropped_without_pointer_events_fills_the_frame_under_the_system_pointe
let window = egui::vec2(300.0, 200.0);
app.services.pointer = Some(Box::new(move || Some(((inside + window).to_vec2() * zoom).to_pos2())));
let path = dropped_png("drop-system-pointer");
app.pending_drops.push(crate::PendingDrop { path: path.to_string_lossy().into_owned(), pointer: None, generation: app.input_generation() });
let ctx = egui::Context::default();
ctx.set_zoom_factor(zoom);
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO + window, egui::vec2(1400.0, 900.0));
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, rect.size())), ..Default::default() };
raw.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { inner_rect: Some(rect), ..Default::default() });
ctx.run_ui(raw, |ui| app.take_pending_drops(ui.ctx())).textures_delta.clear();
// The zoom takes effect from the next pass, as it has by the time the system drops a file.
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
let mut raw = pointerless_drop(&path, egui::Rect::from_min_size(egui::Pos2::ZERO + window, egui::vec2(1400.0, 900.0)));
app.raw_input_hook(&ctx, &mut raw);
ctx.run_ui(raw, |_| app.take_pending_drops()).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 {
@@ -1006,9 +1026,98 @@ fn a_file_dropped_without_pointer_events_fills_the_frame_under_the_system_pointe
}
}
/// A line encloses no area, so a graphic dropped on it gets a frame of its own at the drop point
/// and the line stays a line, as with the place cursor (#2290). A second graphic of the same drop
/// gets a frame of its own too, instead of replacing the first in the frame the first one got.
/// A drop without pointer events keeps the place the system gave when its input arrived,
/// converted with that input's window: the pointer and the window moving before the UI pass that
/// places it don't move the drop.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_pointerless_drop_keeps_the_system_pointer_of_its_own_input() {
let (mut app, frame, inside, outside) = app_with_drop_frame();
let at = std::rc::Rc::new(std::cell::Cell::new(inside));
let reported = at.clone();
app.services.pointer = Some(Box::new(move || Some(reported.get())));
let ctx = egui::Context::default();
let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
let path = dropped_png("drop-own-input");
let mut raw = pointerless_drop(&path, window);
app.raw_input_hook(&ctx, &mut raw);
// Before the pass that places it, the window moves and the pointer leaves the frame; a later
// input with no drop doesn't ask the system again.
let moved = window.translate(egui::vec2(50.0, 40.0));
at.set(outside + moved.min.to_vec2());
let mut later = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, moved.size())), ..Default::default() };
later.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { inner_rect: Some(moved), ..Default::default() });
app.raw_input_hook(&ctx, &mut later);
ctx.run_ui(later, |_| app.take_pending_drops()).textures_delta.clear();
let _ = std::fs::remove_file(&path);
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.spreads[0].items.len(), 1, "no new object");
let designcraft_doc::Content::Graphic(g) = &d.item(designcraft_doc::ItemId(frame)).unwrap().content else {
panic!("the file fills the frame it was dropped on")
};
assert!(d.assets[&g.asset].name.contains("drop-own-input"));
}
/// Two drops that arrive before one UI pass stay separate: the first still fills the frame it was
/// dropped on, and the second, off any frame, loads the place cursor. Merged, they'd both queue.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn separate_drops_before_one_pass_are_not_one_batch() {
let (mut app, frame, inside, outside) = app_with_drop_frame();
let at = std::rc::Rc::new(std::cell::Cell::new(inside));
let reported = at.clone();
app.services.pointer = Some(Box::new(move || Some(reported.get())));
let ctx = egui::Context::default();
let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
let (first, second) = (dropped_png("separate-drop-a"), dropped_png("separate-drop-b"));
let mut raw = pointerless_drop(&first, window);
app.raw_input_hook(&ctx, &mut raw);
at.set(outside);
let mut raw = pointerless_drop(&second, window);
app.raw_input_hook(&ctx, &mut raw);
assert_eq!(app.pending_drops.len(), 2);
ctx.run_ui(raw, |_| app.take_pending_drops()).textures_delta.clear();
let _ = std::fs::remove_file(&first);
let _ = std::fs::remove_file(&second);
let d = &app.session.doc().unwrap().doc;
let designcraft_doc::Content::Graphic(g) = &d.item(designcraft_doc::ItemId(frame)).unwrap().content else {
panic!("the first drop fills the frame it was dropped on")
};
assert!(d.assets[&g.asset].name.contains("separate-drop-a"));
let loaded = app.session.loaded.as_ref().expect("the second drop loads the place cursor");
assert!(d.assets[&loaded.asset].name.contains("separate-drop-b"));
assert!(app.session.loaded_queue.is_empty());
}
/// A filled compound path (Object > Compound Paths > Make) is a frame a dropped image fills, as
/// in InDesign, through its ring and not its hole.
#[test]
fn a_graphic_dropped_on_a_compound_path_fills_it() {
let (mut app, frame, _, _) = app_with_drop_frame();
let hole = app.run("frame.create", json!({"rect": [66, 66, 106, 106]})).unwrap()["id"].as_u64().unwrap();
assert_eq!(app.run("object.makeCompoundPath", json!({"ids": [frame, hole]})).unwrap()["id"].as_u64(), Some(frame));
app.run("object.fill", json!({"ids": [frame], "swatch": "[Black]"})).unwrap();
let (view, rect) = (app.view().unwrap(), app.canvas_rect.unwrap());
let st = app.session.active().unwrap();
let layout = designcraft_tools::CanvasLayout::new(&st.doc, false);
let slot = layout.slot(designcraft_doc::SpreadRef::Doc(0)).unwrap();
let screen = |x, y| canvas::Xf::new(rect, view).to_screen(slot.to_canvas(designcraft_geom::Point::new(x, y)));
// On the ring, between the outer edge (36) and the hole (66); the hole's middle is no frame.
let ring = screen(50.0, 50.0);
assert!(canvas::drop_frame(&app, Some(screen(86.0, 86.0))).is_none(), "the hole");
let path = dropped_png("drop-compound");
let r = app.open_dropped_at(&path.to_string_lossy(), Some(ring));
let _ = std::fs::remove_file(&path);
assert_eq!(r.unwrap()["id"].as_u64(), Some(frame), "the compound path under the pointer is filled");
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.spreads[0].items.len(), 1, "no new object");
let it = d.item(designcraft_doc::ItemId(frame)).unwrap();
assert!(matches!(it.content, designcraft_doc::Content::Graphic(_)));
assert_eq!(it.path.subpaths.len(), 2, "it keeps its hole");
}
/// A line encloses no area, so graphics dropped on it don't fill it (#2290): the line stays a line
/// and, as on any spot that isn't a frame, the images wait in the place cursor in drop order.
#[test]
fn graphics_dropped_on_a_line_leave_the_line_alone() {
let (mut app, line, on_line, _) = app_with_drop_item("line.create", json!({"a": [36, 80], "b": [136, 80]}));
@@ -1017,22 +1126,15 @@ fn graphics_dropped_on_a_line_leave_the_line_alone() {
assert!(canvas::drop_frame(&app, Some(on_line)).is_none(), "a line is no frame");
let (first, second) = (dropped_png("drop-on-line-first"), dropped_png("drop-on-line-second"));
let mut frame_free = true;
let a = app.place_dropped(&first.to_string_lossy(), Some(on_line), &mut frame_free);
assert!(!frame_free, "the first graphic is under the pointer now");
let b = app.place_dropped(&second.to_string_lossy(), Some(on_line), &mut frame_free);
app.place_dropped(&first.to_string_lossy(), Some(on_line), &mut frame_free).unwrap();
app.place_dropped(&second.to_string_lossy(), Some(on_line), &mut frame_free).unwrap();
let _ = (std::fs::remove_file(&first), std::fs::remove_file(&second));
let a = designcraft_doc::ItemId(a.unwrap()["id"].as_u64().unwrap());
let b = designcraft_doc::ItemId(b.unwrap()["id"].as_u64().unwrap());
assert!(a != id && b != id && a != b, "each graphic has a new frame: {a:?} {b:?}");
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.spreads[0].items.len(), 3);
assert_eq!(d.spreads[0].items.len(), 1, "no new object");
assert_eq!(serde_json::to_value(d.item(id).unwrap()).unwrap(), before, "the line is unchanged");
for (placed, name) in [(a, "drop-on-line-first"), (b, "drop-on-line-second")] {
let it = d.item(placed).unwrap();
let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("{name} is in its frame") };
assert!(d.assets[&g.asset].name.contains(name), "{name}");
assert!(it.path.encloses_area() && it.bounds().height() > 1.0, "a real frame, not a zero-height one: {:?}", it.bounds());
}
let loaded = app.session.loaded.as_ref().expect("the first image loads the place cursor");
assert!(d.assets[&loaded.asset].name.contains("drop-on-line-first"));
assert_eq!(app.session.loaded_queue.len(), 1, "the second waits behind it");
}
#[test]
@@ -1046,7 +1148,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]
@@ -1060,8 +1163,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(_)));
}
@@ -1077,11 +1182,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.
@@ -11,6 +11,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. Several files go to the place cursor whatever is selected, even with a PDF first. A multi-page PDF in a batch pauses the batch at its import options; once confirmed, it queues after the images loaded before it and the rest of the batch follows. Cancelling them ends the batch, and the images already loaded stay. The place cursor holds up to 1000 images, counting those already in it when a batch adds to them. 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