From d890289a92be8cbb60a0bd20c4b20b85a74b568b Mon Sep 17 00:00:00 2001 From: texjer Date: Tue, 3 Nov 2026 21:43:44 -0500 Subject: [PATCH 01/10] Place: several images load the place cursor, with a thumbnail, count and grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #2073. Port of #1307, #1392 (grid) and #1432 (thumbnail and count). - File › Place takes several files; images load the place cursor in the order chosen (`place.load` `files`, `append`), after the ones it holds. A multi-page PDF or an alert stops the batch, and a file that can't be placed returns its error to `app.placeDialog`. - Each click places the next image; arrow keys while dragging lay out a grid (`place.grid`) and are handled even at a limit; Escape unloads the rest (`place.cancel`). Undo of a loaded image's asset cancels the queue. - The cursor shows how many images are loaded. - On the web, Place picks several files and the inbox loads consecutive images for the same document as one batch. --- ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 15 +- crates/engine/src/cmd/file/place.rs | 221 ++++++++++++++++-- crates/engine/src/cmd/file/registry.rs | 21 +- crates/engine/src/lib.rs | 9 + crates/engine/src/tests.rs | 117 ++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/nav.rs | 75 +++++- crates/tools/src/tests.rs | 17 ++ crates/ui-egui/src/app/file_dialogs.rs | 40 ++++ crates/ui-egui/src/app/files.rs | 267 ++++++++++++++++++++-- crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/canvas/place_cursor.rs | 34 +++ crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/table.rs | 20 ++ crates/ui-egui/src/menus/run.rs | 15 +- crates/ui-egui/src/menus/ui_commands.rs | 7 +- docs/multi-image-placement.md | 26 +++ 18 files changed, 836 insertions(+), 54 deletions(-) create mode 100644 crates/ui-egui/src/canvas/place_cursor.rs create mode 100644 docs/multi-image-placement.md diff --git a/ROADMAP.md b/ROADMAP.md index a13912f7c..57c143340 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -405,6 +405,7 @@ Newest first. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. - **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. +- **2026-10-30** · 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** · 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). - **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 19bc3a122..8b23ef6b7 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -402,11 +402,18 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { rfd::AsyncFileDialog::new().add_filter("DesignCraft, IDML or InDesign", DOC_EXTS) }; wasm_bindgen_futures::spawn_local(async move { - let Some(file) = dialog.pick_file().await else { - return; + // Place takes several files, loaded into the place cursor in the order chosen. + let handles = if request.purpose == "place" { + dialog.pick_files().await.unwrap_or_default() + } else { + dialog.pick_file().await.into_iter().collect() }; - let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name: file.file_name(), bytes }); + let mut files = Vec::with_capacity(handles.len()); + for file in handles { + let bytes = file.read().await; + files.push(ImportedFile { request: request.clone(), name: file.file_name(), bytes }); + } + inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(files); ctx.request_repaint(); }); })), diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index a3ae11df1..ba9e0827a 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -116,7 +116,7 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { - return load_cursor(s, asset, (bw, bh)); + return load_cursor(s, asset, (bw, bh), p.get("append").and_then(Value::as_bool) == Some(true)); } s.edit(|d, sel| { let fresh = AssetId(d.alloc()); @@ -235,35 +235,96 @@ pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, comm Ok(()) } +/// Decode and validate the complete batch before replacing the placement cursor. pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { - let (bytes, name, link) = read_source(p)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; - let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; - load_cursor(s, asset, natural) + let append = p.get("append").and_then(Value::as_bool) == Some(true); + if p.get("files").is_none() { + let (bytes, name, link) = read_source(p)?; + let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; + let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; + return load_cursor(s, asset, natural, append); + } + let sources = p.get("files").and_then(Value::as_array).ok_or_else(|| bad("place.load", "files must be an array"))?; + if sources.is_empty() || sources.len() > MAX_LOADED_IMAGES { + return Err(bad("place.load", format!("select between 1 and {MAX_LOADED_IMAGES} images"))); + } + let mut images = Vec::with_capacity(sources.len()); + for source in sources { + let (bytes, name, link) = read_source(source)?; + let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", format!("unsupported or corrupt image: {name}")))?; + let size = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); + validate_placement(size, Rect::new(0.0, 0.0, size.0, size.1), Affine::IDENTITY, "place.load")?; + let mime = designcraft_render::image_mime(&bytes).to_string(); + images.push((Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }, size)); + } + let queue = s.edit(|d, _| { + let mut queue = std::collections::VecDeque::with_capacity(images.len()); + for (mut asset, size) in images { + asset.id = AssetId(d.alloc()); + queue.push_back(crate::LoadedGraphic { asset: d.add_asset(asset), size }); + } + Ok(queue) + })?; + if append && s.loaded.is_some() { + s.loaded_queue.extend(queue); + } else { + s.loaded_queue = queue; + s.loaded = s.loaded_queue.pop_front(); + } + s.set_tool("placeGun"); + Ok(json!({"remaining": loaded_count(s), "loaded": true})) } -/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. -fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result { +/// Most images one `place.load` takes. +const MAX_LOADED_IMAGES: usize = 1000; + +/// How many images the place cursor holds: the current one and those queued after it. +fn loaded_count(s: &Session) -> usize { + s.loaded_queue.len().saturating_add(usize::from(s.loaded.is_some())) +} + +/// Add a decoded asset with a fresh id, replacing the previous placement queue (or, with +/// `append` and an image already loaded, queued after it). +fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64), append: bool) -> Result { let name = asset.name.clone(); let (pw, ph) = asset.pixels.unwrap_or_default(); let aid = s.edit(|d, _| { asset.id = AssetId(d.alloc()); Ok(d.add_asset(asset)) })?; - s.loaded = Some(crate::LoadedGraphic { asset: aid, size }); + let image = crate::LoadedGraphic { asset: aid, size }; + if append && s.loaded.is_some() { + s.loaded_queue.push_back(image); + } else { + s.loaded_queue.clear(); + s.loaded = Some(image); + } s.set_tool("placeGun"); Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) } -pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { - let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - // Undo can remove the loaded graphic's asset. - if !s.doc()?.doc.assets.contains_key(&aid) { - s.loaded = None; - return Err(bad("place.drop", "the loaded graphic is no longer in the document")); +/// Cancel every pending image if Undo removed an asset used by the cursor. +fn validate_place_cursor(s: &mut Session, command: &str) -> Result<()> { + // An empty cursor is its own error, and leaves the tool as it is. + if s.loaded.is_none() { + return Err(bad(command, "nothing loaded in the place cursor")); } + let valid = s.loaded.iter().chain(s.loaded_queue.iter()).all(|image| s.doc().is_ok_and(|d| d.doc.assets.contains_key(&image.asset))); + if !valid { + s.loaded = None; + s.loaded_queue.clear(); + s.set_tool("selection"); + return Err(bad(command, "the loaded graphic is no longer in the document")); + } + Ok(()) +} + +/// Place the current image and advance the cursor only after success. +pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { + validate_place_cursor(s, "place.drop")?; + let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; let sr = crate::cmd::spread_param(p, "place.drop")?; let lid = s.doc()?.active_layer; let frame = crate::cmd::id_param(p, "frame"); @@ -321,11 +382,121 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { *sel = Selection::items(vec![id]); Ok(json!({"id": id.0})) })?; - s.loaded = None; - s.set_tool("selection"); + advance_place_cursor(s); Ok(r) } +/// Advance in file order, returning to selection when the queue is exhausted. +fn advance_place_cursor(s: &mut Session) { + s.loaded = s.loaded_queue.pop_front(); + if s.loaded.is_none() { + s.set_tool("selection"); + } +} + +/// Validate bounded grid dimensions and a finite nonnegative gutter. +fn grid_dimensions(p: &Value, command: &str, default_columns: usize, default_rows: usize) -> Result<(usize, usize, f64)> { + let dimension = |key, default| -> Result { + match p.get(key) { + None => Ok(default), + Some(v) => v + .as_u64() + .filter(|n| (1..=100).contains(n)) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| bad(command, "rows and columns must be integers between 1 and 100")), + } + }; + let gutter = crate::cmd::f64_or(p, "gutter", 6.0); + if !gutter.is_finite() || gutter < 0.0 { + return Err(bad(command, "gutter must be finite and nonnegative")); + } + Ok((dimension("columns", default_columns)?, dimension("rows", default_rows)?, gutter)) +} + +/// Calculate row-major cells, rejecting gutters that leave no usable space. +fn grid_cells(rect: Rect, columns: usize, rows: usize, gutter: f64, command: &str) -> Result> { + let (columns, rows) = (columns.max(1), rows.max(1)); + let w = (rect.width() - gutter * columns.saturating_sub(1) as f64) / columns as f64; + let h = (rect.height() - gutter * rows.saturating_sub(1) as f64) / rows as f64; + if ![rect.x0, rect.y0, rect.x1, rect.y1, w, h].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 { + return Err(bad(command, "the grid area is too small for its rows, columns and gutter")); + } + Ok((0..rows) + .flat_map(|row| { + (0..columns).map(move |column| { + let x = rect.x0 + column as f64 * (w + gutter); + let y = rect.y0 + row as f64 * (h + gutter); + Rect::new(x, y, x + w, y + h) + }) + }) + .collect()) +} + +/// Insert a proportionally fitted image into one grid cell. +fn insert_grid_image( + d: &mut designcraft_doc::Document, + spread: designcraft_doc::SpreadRef, + layer: designcraft_doc::LayerId, + image: crate::LoadedGraphic, + rect: Rect, + command: &str, +) -> Result { + let crate::LoadedGraphic { asset, size: (nw, nh) } = image; + if !d.assets.contains_key(&asset) { + return Err(bad(command, "a loaded image is no longer in the document")); + } + let k = (rect.width() / nw).min(rect.height() / nh); + let xf = Affine::translate((rect.x0 + (rect.width() - nw * k) / 2.0, rect.y0 + (rect.height() - nh * k) / 2.0)) * Affine::scale(k); + validate_placement((nw, nh), rect, xf, command)?; + let id = ItemId(d.alloc()); + let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); + item.object_style = d.styles.default_graphic_frame.clone(); + if let Some(effects) = d.styles.object_effects(&item.object_style) { + item.effects = effects.clone(); + } + item.content = Content::Graphic(Graphic { + layer_options: None, + asset, + size: (nw, nh), + xf, + auto_fit: designcraft_doc::Fitting::FitProportionally, + fit_align: 4, + crop: [0.0; 4], + clip: None, + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(spread, item, None)?; + Ok(id) +} + +/// Place a row-major grid as one undoable edit. +pub(super) fn place_grid(s: &mut Session, p: &Value) -> Result { + const ID: &str = "place.grid"; + validate_place_cursor(s, ID)?; + let (columns, rows, gutter) = grid_dimensions(p, ID, 1, 1)?; + let rect = crate::cmd::rect_param(p, "rect").ok_or_else(|| bad(ID, "missing rect"))?; + let cells = grid_cells(rect, columns, rows, gutter, ID)?; + let images: Vec<_> = s.loaded.iter().chain(s.loaded_queue.iter()).copied().take(cells.len()).collect(); + if images.is_empty() { + return Err(bad(ID, "nothing loaded in the place cursor")); + } + let layer = s.doc()?.active_layer; + let spread = crate::cmd::spread_param(p, ID)?; + let ids = s.edit(|d, sel| { + let mut ids = Vec::with_capacity(images.len()); + for (image, rect) in images.iter().zip(cells) { + ids.push(insert_grid_image(d, spread, layer, *image, rect, ID)?); + } + *sel = Selection::items(ids.clone()); + Ok(ids) + })?; + for _ in &ids { + advance_place_cursor(s); + } + Ok(json!({"ids": ids.iter().map(|id| id.0).collect::>(), "remaining": loaded_count(s)})) +} + /// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, /// 1-based) grouped, top-left at x/y (default: the source position on the first page). fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { @@ -395,6 +566,20 @@ pub(super) mod image_place_tests { Ok(file) } + /// With nothing in the place cursor, placing says so and leaves the tool as it is (the + /// cursor's graphic gone from the document is another error). + #[test] + fn an_empty_place_cursor_says_so_and_keeps_the_tool() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.set_tool("rectangleFrame"); + for (cmd, p) in [("place.drop", json!({"x": 10, "y": 10})), ("place.grid", json!({"rect": [0, 0, 100, 100]}))] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("nothing loaded"), "{cmd}: {e}"); + assert_eq!(s.tool_id(), "rectangleFrame", "{cmd}"); + } + } + #[test] fn consecutive_path_placements_create_distinct_graphics() { let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs index c1f522956..57aa1a5a6 100644 --- a/crates/engine/src/cmd/file/registry.rs +++ b/crates/engine/src/cmd/file/registry.rs @@ -3,7 +3,7 @@ use super::EngineError; use super::bytes::{base64_encode, to_bytes, to_bytes_with_preview, with_document_extension}; use super::new::{file_new, file_sample, save_preset}; use super::open::{file_open, file_open_bytes, file_open_recent, has_recent, recent_entry}; -use super::place::{file_place, place_drop, place_load, read_source}; +use super::place::{file_place, place_drop, place_grid, place_load, read_source}; use super::save::{FolderAccess, can_revert, file_revert, file_save, recovery_dir}; use super::snippets::{snippet_export, snippet_place}; use super::{CommandSpec, PRESETS, Value, always, bad, cmd, has_doc, json, ok, str_param}; @@ -123,17 +123,32 @@ pub fn specs() -> Vec { "Load Place Cursor", [], None, - "{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)", + "{path | base64, name?, files?: [{path | base64, name?}], append? (keep the images already loaded and queue these after them)} — load up to 1000 images into the place cursor, in order (then click/drag with the placeGun tool)", has_doc, place_load ), cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop), + cmd!( + "place.grid", + "Place Image Grid", + [], + None, + "{spread?, rect: [x0,y0,x1,y1], columns?: 1, rows?: 1, gutter?: 6} — place queued images in row order", + has_doc, + place_grid + ), + cmd!(noundo "place.cancel", "Cancel Image Placement", [], None, "{}", has_doc, |s, _| { + s.loaded = None; + s.loaded_queue.clear(); + s.set_tool("selection"); + ok() + }), cmd!( "file.place", "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), append? (with loadCursor: queue the image after those already loaded instead of replacing them), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", has_doc, file_place ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 19c50b7c0..397be3203 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -411,6 +411,8 @@ pub struct Session { pub ui_requests: Vec, /// Graphic loaded in the place cursor. pub loaded: Option, + /// Remaining graphics, in source-selection order, after `loaded`. + pub loaded_queue: std::collections::VecDeque, pub(crate) untitled: u32, /// Transforms applied to the current selection, oldest first (Transform Again / Sequence /// Again), with the selection they were applied to and whether the last one came from a @@ -474,6 +476,7 @@ impl Session { image_link_checks: Default::default(), ui_requests: vec![], loaded: None, + loaded_queue: Default::default(), transforms: Default::default(), text_drag: false, text_unit: None, @@ -549,6 +552,8 @@ impl Session { } self.reset_find_scope(); self.stop_data_preview(); + self.loaded = None; + self.loaded_queue.clear(); self.active = Some(i); } } @@ -558,6 +563,8 @@ impl Session { } self.reset_find_scope(); self.stop_data_preview(); + self.loaded = None; + self.loaded_queue.clear(); self.docs.push(d); self.active = Some(self.docs.len() - 1); self.docs.len() - 1 @@ -580,6 +587,8 @@ impl Session { recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { + self.loaded = None; + self.loaded_queue.clear(); self.reset_find_scope(); self.docs.remove(i); self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7cdcd5e02..564e42605 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2010,6 +2010,123 @@ fn direct_selection_on_a_selected_frame_deletes_only_its_image() { assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection); } +fn load_test_images(s: &mut Session, count: usize) { + let sources: Vec<_> = (0..count) + .map(|i| { + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![(i % 255) as u8; 40 * 20 * 4] }.to_png(); + json!({"base64": cmd::base64_encode(&png), "name": format!("{i}.png")}) + }) + .collect(); + s.execute("place.load", &json!({"files": sources})).unwrap(); +} + +#[test] +fn image_queue_places_in_order_and_retains_remainder() { + let mut s = session(); + load_test_images(&mut s, 5); + let first = s.loaded.unwrap().asset; + s.execute("place.drop", &json!({"x": 10, "y": 20})).unwrap(); + assert_eq!(s.tool_id(), "placeGun"); + let item = s.doc().unwrap().doc.spreads[0].items.last().unwrap(); + assert!(matches!(&item.content, designcraft_doc::Content::Graphic(g) if g.asset == first)); + let r = s.execute("place.grid", &json!({"rect": [0, 0, 206, 50], "columns": 2, "rows": 1})).unwrap(); + assert_eq!(r["remaining"], 2); + let id = designcraft_doc::ItemId(r["ids"][1].as_u64().unwrap()); + let item = s.doc().unwrap().doc.item(id).unwrap(); + assert_eq!(item.bounds(), designcraft_geom::Rect::new(106.0, 0.0, 206.0, 50.0)); + if let designcraft_doc::Content::Graphic(g) = &item.content { + assert_eq!(s.doc().unwrap().doc.assets[&g.asset].name, "2.png"); + } else { + panic!("expected graphic"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.item(id).is_none(), "the whole grid is one undo step"); + s.execute("place.cancel", &json!({})).unwrap(); + assert!(s.loaded.is_none() && s.loaded_queue.is_empty()); + assert_eq!(s.tool_id(), "selection"); +} + +#[test] +fn image_grid_and_load_fail_atomically() { + let mut s = session(); + load_test_images(&mut s, 3); + let before = s.doc().unwrap().doc.clone(); + let first = s.loaded.unwrap().asset; + assert!(s.execute("place.load", &json!({"files": [{"base64": "bad"}]})).is_err()); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.loaded.unwrap().asset, first); + for params in [json!({"columns": 0}), json!({"columns": 101}), json!({"rows": 1.5}), json!({"gutter": -1}), json!({"columns": 3, "gutter": 1000})] + { + let mut p = params; + p["rect"] = json!([0, 0, 100, 100]); + assert!(s.execute("place.grid", &p).is_err()); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.loaded_queue.len(), 2); + } +} + +#[test] +fn place_drag_arrows_make_three_by_four_grid_and_escape_cancels() { + use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + load_test_images(&mut s, 13); + let v = ViewInfo::at_zoom(1.0); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + for key in [ToolKey::Right, ToolKey::Right, ToolKey::Up, ToolKey::Up, ToolKey::Up] { + s.tool_key(key, Mods::default(), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Drag, 500.0, 600.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 600.0), v).unwrap(); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), 12); + assert!(s.loaded.is_some() && s.loaded_queue.is_empty()); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert!(s.loaded.is_none()); +} + +/// With several images loaded, a release under the drag threshold is still a click: arrows set +/// no grid then, and the next queued image lands at actual size where the press was. +#[test] +fn a_place_click_with_a_queue_places_the_next_image_without_a_grid() { + use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + load_test_images(&mut s, 3); + let v = ViewInfo::at_zoom(1.0); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 101.0, 501.0), v).unwrap(); + assert!(s.overlays(v).is_empty(), "no frame or grid under the threshold"); + assert_eq!(s.cursor(designcraft_geom::Point::new(101.0, 501.0), Mods::default(), v), Cursor::LoadedGraphic); + s.pointer(&PointerEvent::new(PointerKind::Up, 101.0, 501.0), v).unwrap(); + let items = &s.doc().unwrap().doc.spreads[0].items; + assert_eq!(items.len(), 1); + assert_eq!(items[0].bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); + assert_eq!(s.loaded_queue.len(), 1, "the next image is loaded"); + // Past the threshold the arrows' grid shows, one cell per column. + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 306.0, 150.0), v).unwrap(); + assert_eq!(s.overlays(v).iter().filter(|o| matches!(o, Overlay::Marquee(_))).count(), 2); + assert_eq!(s.cursor(designcraft_geom::Point::new(306.0, 150.0), Mods::default(), v), Cursor::Crosshair); +} + +#[test] +fn undoing_loaded_assets_cancels_every_placement_cursor() { + for command in ["place.drop", "place.grid"] { + let mut s = session(); + load_test_images(&mut s, 3); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let params = json!({"rect": [0, 0, 200, 200]}); + assert!(s.execute(command, ¶ms).is_err(), "{command}"); + assert!(s.loaded.is_none(), "{command}"); + assert!(s.loaded_queue.is_empty(), "{command}"); + assert_eq!(s.tool_id(), "selection"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + load_test_images(&mut s, 2); + assert!(s.execute("place.drop", &json!({"x": 0, "y": 0})).is_ok()); + } +} + /// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key /// object, and Align to Key Object leaves it in place. A second click on it drops the key. #[test] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 2badc3a17..6dfd23703 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -74,6 +74,7 @@ impl Session { // Any other tool unloads the place cursor. if id != "placeGun" { self.loaded = None; + self.loaded_queue.clear(); } // Leaving the Type tool keeps the text frame selected. if id != "type" diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 1748c9cfb..2b84010d1 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -112,6 +112,8 @@ impl Tool for ZoomTool { pub struct PlaceGun { start: Option, cur: Point, + columns: usize, + rows: usize, } impl Tool for PlaceGun { @@ -122,6 +124,8 @@ impl Tool for PlaceGun { match ev.kind { PointerKind::Down => { self.start = Some(ev.pos); + self.columns = 1; + self.rows = 1; self.cur = ev.pos; vec![] } @@ -140,10 +144,19 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } - // Under the drag threshold it's a click, as the overlay shows: actual size there. + // Under the drag threshold it's a click, as the overlay shows: actual size there + // (the next queued image, with several loaded). if click { return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))]; } + // Arrow keys during the drag set a grid: the queued images fill its cells. + if self.columns > 1 || self.rows > 1 { + let r = designcraft_geom::Rect::from_points(sa, b); + return vec![Action::Exec( + "place.grid".into(), + json!({"spread": crate::spread_json(sr), "rect": [r.x0, r.y0, r.x1, r.y1], "columns": self.columns, "rows": self.rows}), + )]; + } // The frame the drag drew: at the graphic's proportions from where it started. let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( @@ -154,14 +167,41 @@ impl Tool for PlaceGun { _ => vec![], } } + fn busy(&self) -> bool { + self.start.is_some() + } + fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + use crate::ToolKey; + if key == ToolKey::Escape { + self.start = None; + return vec![Action::Exec("place.cancel".into(), json!({}))]; + } + // During a drag the arrow keys change the grid, and are handled even when it is already + // at its limit, so they don't go on to nudge the selection. + if self.start.is_some() { + match key { + ToolKey::Right => self.columns = (self.columns + 1).min(100), + ToolKey::Left => self.columns = self.columns.saturating_sub(1).max(1), + ToolKey::Up => self.rows = (self.rows + 1).min(100), + ToolKey::Down => self.rows = self.rows.saturating_sub(1).max(1), + _ => return vec![], + } + return vec![Action::Handled]; + } + vec![] + } fn overlays(&self, cx: &ToolContext) -> Vec { let Some(a) = self.start else { return vec![] }; + // Under the drag threshold the release is a click (actual size where it was pressed): + // no frame is drawn yet, the loaded cursor shows what lands. + let click = (self.cur - a).hypot() < cx.tol(3.0); + if !click && (self.columns > 1 || self.rows > 1) { + return grid_overlays(designcraft_geom::Rect::from_points(a, self.cur), self.columns, self.rows); + } let Some(size) = cx.loaded else { return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; }; - // Under the drag threshold the release is a click (actual size where it was pressed): - // no frame is drawn yet, the loaded cursor shows what lands. - if (self.cur - a).hypot() < cx.tol(3.0) { + if click { return vec![]; } // As InDesign: the frame the graphic will fill, and its scale beside the pointer. @@ -178,13 +218,6 @@ Y: {pct}" }, ] } - fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { - match key { - // Escape unloads the place cursor. - crate::ToolKey::Escape => vec![Action::SwitchTool("selection".into())], - _ => vec![], - } - } fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). // Under the drag threshold no frame is drawn yet (the release is a click), so the @@ -195,6 +228,26 @@ Y: {pct}" } } +/// The cells of a `columns` × `rows` place grid drawn over `rect`, 6 pt apart, as `place.grid` +/// lays them out; the whole rect when the cells would vanish. +fn grid_overlays(rect: designcraft_geom::Rect, columns: usize, rows: usize) -> Vec { + let (columns, rows) = (columns.clamp(1, 100), rows.clamp(1, 100)); + let w = (rect.width() - 6.0 * (columns - 1) as f64) / columns as f64; + let h = (rect.height() - 6.0 * (rows - 1) as f64) / rows as f64; + if !(w > 0.0 && h > 0.0) { + return vec![crate::Overlay::Marquee(rect)]; + } + (0..rows) + .flat_map(|row| { + (0..columns).map(move |column| { + let x = rect.x0 + column as f64 * (w + 6.0); + let y = rect.y0 + row as f64 * (h + 6.0); + crate::Overlay::Marquee(designcraft_geom::Rect::new(x, y, x + w, y + h)) + }) + }) + .collect() +} + /// A dragged frame `place.drop` takes only when it is wider and taller than this, in points; /// a thinner one places the graphic at its natural size instead. pub const PLACE_DRAG_MIN: f64 = 2.0; diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 3970f1910..28cfecf01 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2352,3 +2352,20 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}"); assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}"); } + +/// While the place cursor drags out a grid, the arrow keys change it and are handled, even at a +/// limit, so the session doesn't try them elsewhere; other keys and keys before a drag aren't. +#[test] +fn place_gun_grid_arrow_keys_are_handled_during_a_drag() { + let d = Document::new(&NewDocument::default()); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("placeGun"); + assert!(t.key(&cx, ToolKey::Right, Mods::default()).is_empty(), "no drag yet"); + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 60.0)); + for key in [ToolKey::Right, ToolKey::Up, ToolKey::Left, ToolKey::Left, ToolKey::Down, ToolKey::Down] { + assert_eq!(t.key(&cx, key, Mods::default()), vec![Action::Handled], "{key:?}"); + } + assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty()); +} diff --git a/crates/ui-egui/src/app/file_dialogs.rs b/crates/ui-egui/src/app/file_dialogs.rs index 9f1271389..42a47fe32 100644 --- a/crates/ui-egui/src/app/file_dialogs.rs +++ b/crates/ui-egui/src/app/file_dialogs.rs @@ -16,6 +16,10 @@ use super::{DesignApp, Value, json}; #[cfg(not(target_arch = "wasm32"))] pub(crate) type Then = Box Result>; +/// What runs with the files chosen in a dialog that takes several, in the order chosen. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type ThenMany = Box) -> Result>; + /// Whether an answer still needs the document that was active when the dialog was asked for. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Bound { @@ -64,6 +68,8 @@ pub(crate) struct Held { enum Step { /// Open: run `then` with the file chosen. Open(Then), + /// Open several: run `then` with the files chosen. + OpenMany(ThenMany), /// Save: `ext` is added to a name typed without it, then `then` runs, after the replace-file /// question when the added extension names a file that exists. Save { ext: Option, then: Then }, @@ -118,6 +124,31 @@ impl DesignApp { } } + /// [`Self::ask_open`] for a dialog that takes several files: `then` runs with their paths, + /// in the order chosen. + #[track_caller] + pub(crate) fn ask_open_many( + &mut self, + purpose: &str, + bound: Bound, + then: impl FnOnce(&mut DesignApp, Vec) -> Result + 'static, + ) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let mut dialog = craft_dialogs::FileDialog::open_many(); + for filter in open_filters(purpose) { + dialog = dialog.filter(filter.name, filter.extensions); + } + self.ask_file(dialog, bound, Step::OpenMany(Box::new(then))); + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (purpose, bound, then); + Err(NO_PICKER.into()) + } + } + /// Ask where to save `name`: beside the active document once it has been saved, else in the /// folder of the last file saved or exported (#1653). `then` runs with the path on a later /// frame; Cancel runs nothing. In a browser the name is the download's and `then` runs now. @@ -232,6 +263,15 @@ impl DesignApp { self.continue_in(Held { doc, dialog, then, path: path.to_string_lossy().to_string() }); } } + (Step::OpenMany(then), Answer::Paths(paths)) => { + let paths: Vec = paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect(); + if let Some(first) = paths.first().cloned() { + // Held as one answer: it waits for a dialog in its way and returns to its + // document as a single file's does. + let then: Then = Box::new(move |app, _| then(app, paths)); + self.continue_in(Held { doc, dialog, then, path: first }); + } + } (Step::Save { ext, then }, Answer::Paths(paths)) => { let Some(picked) = paths.into_iter().next() else { continue }; let path = picked_save_path(picked.clone(), ext.as_deref()); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3c90d8c59..3df85b504 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -16,27 +16,82 @@ impl DesignApp { open(request); return Ok(Value::Null); } - let bound = if purpose == "place" { file_dialogs::Bound::Document } else { file_dialogs::Bound::App }; + // File › Place takes several files: they load the place cursor in the order chosen. + if purpose == "place" { + return self.ask_open_many(purpose, file_dialogs::Bound::Document, |app, paths| match paths.as_slice() { + [path] => app.open_picked("place", path.clone()), + _ => app.place_paths(paths).map(|()| Value::Null), + }); + } let purpose_owned = purpose.to_string(); - self.ask_open(purpose, bound, move |app, path| app.open_picked(&purpose_owned, path)) + self.ask_open(purpose, file_dialogs::Bound::App, move |app, path| app.open_picked(&purpose_owned, path)) } /// Open (`open`) or place (`place`) the file at `path`, chosen in a dialog or passed by a script. pub(crate) fn open_picked(&mut self, purpose: &str, path: String) -> Result { + if purpose == "place" { + return self.place_path(&path, false); + } + self.open_file("file.open", json!({"path": path})) + } + + /// File › Place of the file at `path`, asking a multi-page PDF's Image Import Options first. + /// With `append`, an image that loads the cursor queues after the ones already loaded rather + /// than replacing them. + fn place_path(&mut self, path: &str, append: bool) -> Result { // Image Import Options: a multi-page PDF asks which page. - if purpose == "place" && path.to_ascii_lowercase().ends_with(".pdf") { + if path.to_ascii_lowercase().ends_with(".pdf") { let target = self.session.active().map(|d| d.uid); - let pages = self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); + let pages = self.services.read.as_mut().and_then(|r| r(path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); if pages > 1 { self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); return Ok(Value::Null); } - return self.place_file(json!({"path": path})); } - if purpose == "place" { - return self.place_file(json!({"path": path})); + self.place_file(if append { json!({"path": path, "append": true}) } else { json!({"path": path}) }) + } + + /// Place an ordered batch of files, as File › Place with several chosen. The batch stops when + /// a file opens a dialog (a multi-page PDF's Import Options, an alert), as the inbox's does; + /// every file that loads the cursor adds to what the earlier ones loaded, in the order the + /// files were chosen (images in a row load together, before the file after them). A file that + /// can't be placed gets an alert and stops the batch; its error is returned, so automation + /// doesn't carry on as if the batch had loaded. + pub(crate) fn place_paths(&mut self, paths: Vec) -> Result<(), String> { + let mut images = Vec::new(); + // Images already in the place cursor stay: the batch queues after them. + let mut loaded = self.session.loaded.is_some(); + // `None` at the end loads the images left. + for path in paths.into_iter().map(Some).chain([None]) { + if let Some(p) = &path + && is_cursor_image(p) + { + images.push(json!({"path": p})); + continue; + } + if path.is_none() && images.is_empty() { + break; + } + if self.ui.dialog.is_some() { + self.status(crate::i18n::tr(&self.ui.language, "Place cancelled while a dialog is open")); + return Ok(()); + } + if !images.is_empty() { + let r = self.load_place_cursor(std::mem::take(&mut images), loaded)?; + loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); + } + if let Some(p) = path { + let r = self.place_path(&p, loaded)?; + loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); + } } - self.open_file("file.open", json!({"path": path})) + Ok(()) + } + + /// `place.load` of `files` (`{path}` or `{name, base64}` each), queued after the images + /// already loaded when `append`. An image that can't be read gets an alert, as a placed one does. + fn load_place_cursor(&mut self, files: Vec, append: bool) -> Result { + self.open_named_file("place.load", json!({"files": files, "append": append}), String::new()) } /// Load Styles: ask the host for a document whose styles to load; `kind` is `paragraph`, @@ -106,11 +161,49 @@ 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)); - for file in files { + let cursor_image = |file: &ImportedFile| { + file.request.purpose == "place" + && !file.name.to_ascii_lowercase().ends_with(".pdf") + && (is_cursor_image(&file.name) || designcraft_render::image_size(&file.bytes).is_some()) + }; + // In the order they came: consecutive images for the same document load the place + // cursor together; after the first placement into it, the later ones are added to it. + let mut append = false; + let mut files = files.into_iter().peekable(); + while let Some(file) = files.next() { if file.request.purpose == "shortcuts" { shortcut_import::import_delivered(self, &file.bytes); continue; } + let placing = file.request.purpose == "place"; + let mut group = vec![]; + if cursor_image(&file) { + while let Some(next) = + files.next_if(|n| cursor_image(n) && n.request.target == file.request.target && n.request.purpose == file.request.purpose) + { + group.push(next); + } + } + // Several images: into the place cursor. + if !group.is_empty() { + if self.ui.dialog.is_some() { + self.status(crate::i18n::tr(&self.ui.language, "Image import cancelled while a dialog is open")); + continue; + } + if self.activate_import_target(&file.request).is_err() { + continue; + } + let sources: Vec<_> = std::iter::once(file) + .chain(group) + .map(|f| json!({"name": f.name, "base64": designcraft_engine::cmd::base64_encode(&f.bytes)})) + .collect(); + // They queue after images already in the cursor, as a native batch's do. + let queue_after = append || self.session.loaded.is_some(); + if self.load_place_cursor(sources, queue_after).is_ok() { + append = true; + } + continue; + } if self.ui.dialog.is_some() { self.status( crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again") @@ -119,13 +212,16 @@ impl DesignApp { continue; } let name = file.name.clone(); - if let Err(e) = self.import_file(file) { - self.status(format!("{name}: {e}")); + match self.import_file(file, append) { + Ok(_) if placing => append = true, + Ok(_) => {} + Err(e) => self.status(format!("{name}: {e}")), } } } - fn import_file(&mut self, file: ImportedFile) -> Result { + /// Imports `file`; a placed one is added to the place cursor when `append`. + fn import_file(&mut self, file: ImportedFile, append: bool) -> Result { let lower = file.name.to_ascii_lowercase(); if let Some(kind) = file.request.purpose.strip_prefix("styles") { self.activate_import_target(&file.request)?; @@ -157,7 +253,7 @@ impl DesignApp { if lower.ends_with(".ase") || file.request.purpose == "swatches" { self.run("swatch.load", json!({"base64": b64})) } else if file.request.purpose == "place" { - self.place_file(json!({"name": file.name, "base64": b64})) + self.place_file(json!({"name": file.name, "base64": b64, "append": append})) } else if place { self.open_file("file.place", json!({"name": file.name, "base64": b64})) } else { @@ -270,7 +366,7 @@ impl DesignApp { pub fn open_named_file(&mut self, cmd: &str, params: Value, file: String) -> Result { let r = self.run(cmd, params); if let Err(e) = &r { - let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; + let title = if cmd == "file.place" || cmd == "place.load" { "Can't Place the File" } else { "Can't Open the File" }; if self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { self.session.reset_find_scope(); } @@ -311,6 +407,14 @@ impl DesignApp { } } +/// Graphics that can wait in the place cursor, several at a time: raster images, SVG, PSD and +/// EPS. A PDF can't (it may ask which page), nor can text, media or documents. +pub(crate) fn is_cursor_image(name: &str) -> bool { + std::path::Path::new(name).extension().and_then(|e| e.to_str()).is_some_and(|e| { + matches!(e.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "psd" | "eps") + }) +} + /// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft, /// IDML, and InDesign documents and templates (converted to IDML on open). pub fn opens_as_document(name: &str) -> bool { @@ -761,6 +865,141 @@ pub(crate) mod browser_import_tests { assert!(app.pending_pdf.is_none()); assert!(app.session.doc().unwrap().doc.assets.is_empty()); } + + fn png(v: u8) -> Vec { + designcraft_render::Rendered { width: 10, height: 10, pixels: vec![v; 400] }.to_png() + } + + /// The names of the images in the place cursor, the one a click places first. + fn loaded_names(app: &DesignApp) -> Vec { + let doc = &app.session.doc().unwrap().doc; + app.session.loaded.iter().chain(app.session.loaded_queue.iter()).map(|g| doc.assets[&g.asset].name.clone()).collect() + } + + #[test] + fn multi_image_inbox_loads_one_queue_on_its_original_document() { + let mut app = app(); + let request = app.import_request("place"); + app.run("file.new", json!({"title": "B"})).unwrap(); + for i in 0..3 { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { + request: request.clone(), + name: format!("{i}.png"), + bytes: png(200), + }); + } + app.drain_inbox(); + assert_eq!(app.session.doc().unwrap().uid, request.target.unwrap()); + assert_eq!(app.session.tool_id(), "placeGun"); + assert_eq!(app.session.loaded_queue.len(), 2); + assert!(app.session.documents()[1].doc.assets.is_empty()); + } + + /// Files dropped together are placed in the order they came: images around a PDF go into the + /// place cursor before and after it, and each adds to the cursor rather than replacing it. + #[test] + fn inbox_places_images_and_a_pdf_in_their_order_into_one_cursor() { + let mut app = app(); + let request = app.import_request("place"); + for (name, bytes) in [("a.png", png(200)), ("one.pdf", pdf_pages(1)), ("b.png", png(90))] { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request: request.clone(), name: name.into(), bytes }); + } + app.drain_inbox(); + assert_eq!(loaded_names(&app), ["a.png", "one.pdf", "b.png"], "in the order dropped, all in the cursor"); + } + + #[test] + fn batch_cancel_status_uses_the_interface_language() { + let mut app = app(); + app.ui.language = "de".into(); + app.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": "t", "message": "m"}))); + let request = app.import_request("place"); + for name in ["one.png", "two.png"] { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request: request.clone(), name: name.into(), bytes: png(200) }); + } + app.drain_inbox(); + assert_eq!(app.ui.status, "Bildimport bei geöffnetem Dialog abgebrochen"); + assert!(app.ui.dialog.is_some()); + assert!(app.session.loaded.is_none()); + } + + #[test] + fn native_batch_stops_when_a_dialog_is_open_and_appends_to_the_cursor() { + let mut app = app(); + app.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": "t", "message": "m"}))); + let _ = app.place_paths(vec!["one.pdf".into(), "two.png".into()]); + assert_eq!(app.ui.status, "Place cancelled while a dialog is open"); + assert!(app.session.loaded.is_none()); + app.ui.dialog = None; + let b64 = designcraft_engine::cmd::base64_encode(&image()); + app.run("place.load", json!({"base64": b64, "name": "a.svg"})).unwrap(); + let first = app.session.loaded.as_ref().unwrap().asset; + let files = json!([{"base64": b64, "name": "b.svg"}, {"base64": b64, "name": "c.svg"}]); + app.run("place.load", json!({"files": files, "append": true})).unwrap(); + assert_eq!(app.session.loaded.as_ref().unwrap().asset, first); + assert_eq!(app.session.loaded_queue.len(), 2); + app.run("place.load", json!({"files": files})).unwrap(); + assert_eq!(app.session.loaded_queue.len(), 1); + } + + /// Images around a single-page PDF load into the cursor in the order they were chosen. + #[test] + fn native_batch_keeps_the_chosen_order() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + std::fs::write(path("a.svg"), image()).unwrap(); + std::fs::write(path("one.pdf"), pdf_pages(1)).unwrap(); + std::fs::write(path("b.svg"), image()).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + app.place_paths(vec![path("a.svg"), path("one.pdf"), path("b.svg")]).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg", "one.pdf", "b.svg"]); + // A second batch queues after what the cursor still holds. + std::fs::write(path("c.svg"), image()).unwrap(); + app.place_paths(vec![path("c.svg"), path("a.svg")]).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg", "one.pdf", "b.svg", "c.svg", "a.svg"]); + } + + /// File › Place with several files chosen in the dialog loads them all into the place + /// cursor, in the order chosen; with one, it is placed as before. + #[test] + fn place_dialog_with_several_files_loads_them_all() { + let dir = tempfile::tempdir().unwrap(); + let paths: Vec = ["b.svg", "a.svg", "c.svg"].iter().map(|n| dir.path().join(n)).collect(); + for p in &paths { + std::fs::write(p, image()).unwrap(); + } + let mut app = app(); + let answer = paths.clone(); + app.answer_dialogs(move |_| craft_dialogs::Answer::Paths(answer.clone())); + app.run("app.placeDialog", json!({})).unwrap(); + app.settle_dialogs(); + assert_eq!(loaded_names(&app), ["b.svg", "a.svg", "c.svg"]); + assert_eq!(app.session.tool_id(), "placeGun"); + // The same through the command's `paths`, as the control channel gives them. + let mut scripted = self::app(); + let paths: Vec = paths.iter().map(|p| p.to_string_lossy().to_string()).collect(); + scripted.run("app.placeDialog", json!({"paths": paths})).unwrap(); + assert_eq!(loaded_names(&scripted), ["b.svg", "a.svg", "c.svg"]); + } + + #[test] + fn multi_image_inbox_cancels_when_original_document_closed() { + let mut app = app(); + let request = app.import_request("place"); + app.run("file.new", json!({"title": "B"})).unwrap(); + app.run("file.close", json!({"index": 0})).unwrap(); + for i in 0..2 { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { + request: request.clone(), + name: format!("{i}.png"), + bytes: png(200), + }); + } + app.drain_inbox(); + assert!(app.session.loaded.is_none()); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f544f5bf5..72132250c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -20,6 +20,7 @@ mod guides; mod ime; mod input; mod page_decor; +mod place_cursor; mod render; mod rulers; mod scroll; @@ -413,6 +414,7 @@ fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: & vec![pos2(inner.min.x, inner.max.y), pos2(inner.min.x + 3.5, inner.min.y + 2.5), pos2(inner.max.x, inner.max.y)], ink, )); + place_cursor::draw_loaded_count(app, painter, badge); } /// The loaded graphic's thumbnail texture, cached per image data. diff --git a/crates/ui-egui/src/canvas/place_cursor.rs b/crates/ui-egui/src/canvas/place_cursor.rs new file mode 100644 index 000000000..69b17571c --- /dev/null +++ b/crates/ui-egui/src/canvas/place_cursor.rs @@ -0,0 +1,34 @@ +//! The loaded place cursor's image count, drawn beside the thumbnail's badge. + +use super::{Color32, DesignApp, Pos2, Rect, pos2, vec2}; + +/// Several images loaded: how many, beside the thumbnail's image `badge` ("(6)"), navy with a +/// white rim so it reads over any picture. +pub(super) fn draw_loaded_count(app: &DesignApp, painter: &egui::Painter, badge: Rect) { + let loaded = app.session.loaded.is_some().then(|| app.session.loaded_queue.len().saturating_add(1)); + let Some(label) = loaded_count_label(loaded) else { return }; + let at: Pos2 = pos2(badge.max.x + 6.0, badge.center().y); + let font = crate::theme::semibold(15.0); + for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] { + painter.text(at + d, egui::Align2::LEFT_CENTER, &label, font.clone(), Color32::WHITE); + } + painter.text(at, egui::Align2::LEFT_CENTER, label, font, Color32::from_rgb(25, 35, 100)); +} + +/// The count the loaded place cursor shows, as InDesign's "(6)": only when more than one image +/// is loaded. +pub(super) fn loaded_count_label(loaded: Option) -> Option { + loaded.filter(|n| *n > 1).map(|n| format!("({n})")) +} + +#[cfg(test)] +mod tests { + /// The loaded cursor counts the images as InDesign's "(6)", and shows no count for the last + /// one or with nothing loaded. + #[test] + fn the_loaded_cursor_counts_several_images() { + assert_eq!(super::loaded_count_label(Some(6)).as_deref(), Some("(6)")); + assert_eq!(super::loaded_count_label(Some(1)), None); + assert_eq!(super::loaded_count_label(None), None); + } +} diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b89840044..87fae4392 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -25,6 +25,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save a Copy…", "ასლის შენახვა…"), ("Revert", "წინა ვერსიაზე დაბრუნება"), ("Place…", "განთავსება…"), + ("Image import cancelled while a dialog is open", "გამოსახულებების იმპორტი გაუქმდა, რადგან დიალოგი გახსნილია"), + ("Place cancelled while a dialog is open", "განთავსება გაუქმდა, რადგან დიალოგი ღიაა"), ("Export PDF…", "PDF ექსპორტი…"), ("Print…", "დაბეჭდვა…"), ("Document Setup…", "დოკუმენტის პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 27f5cf389..7e8ccdddc 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -146,6 +146,26 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Save a Copy…", ["Kopie speichern …", "Enregistrer une copie…", "Guardar una copia…", "コピーを保存…", "保存副本…"]), ("Revert", ["Zurück zur letzten Version", "Version précédente", "Volver a la versión guardada", "復帰", "恢复到上次保存"]), ("Place…", ["Platzieren …", "Importer…", "Colocar…", "配置…", "置入…"]), + ( + "Image import cancelled while a dialog is open", + [ + "Bildimport bei geöffnetem Dialog abgebrochen", + "Importation des images annulée car une boîte de dialogue est ouverte", + "Importación de imágenes cancelada mientras hay un diálogo abierto", + "ダイアログが開いているため画像の読み込みをキャンセルしました", + "对话框打开时已取消图像导入", + ], + ), + ( + "Place cancelled while a dialog is open", + [ + "Platzieren bei geöffnetem Dialog abgebrochen", + "Importation annulée car une boîte de dialogue est ouverte", + "Colocación cancelada mientras hay un diálogo abierto", + "ダイアログが開いているため配置をキャンセルしました", + "对话框打开时已取消置入", + ], + ), ("Export PDF…", ["PDF exportieren …", "Exporter en PDF…", "Exportar PDF…", "PDF を書き出し…", "导出PDF…"]), ("Print…", ["Drucken …", "Imprimer…", "Imprimir…", "プリント…", "打印…"]), ("Document Setup…", ["Dokument einrichten …", "Format de document…", "Ajustar documento…", "ドキュメント設定…", "文档设置…"]), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 717e1332e..b49b8c7f4 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -135,9 +135,18 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option match p.get("path").and_then(Value::as_str) { - Some(path) => app.open_picked("place", path.to_string()), - None => app.pick_and_open("place"), + "app.placeDialog" => match (p.get("path").and_then(Value::as_str), p.get("paths").and_then(Value::as_array)) { + (Some(path), _) => app.open_picked("place", path.to_string()), + // Several files, as chosen together in the dialog: in their order. One is placed as the + // dialog places one (filling the selected frame). Every entry must be a path. + (None, Some(paths)) => match paths.iter().map(|v| v.as_str().map(str::to_string)).collect::>>() { + None => Err("app.placeDialog: every entry of paths must be a string".into()), + Some(paths) => match <[String; 1]>::try_from(paths) { + Ok([path]) => app.open_picked("place", path), + Err(paths) => app.place_paths(paths).map(|()| Value::Null), + }, + }, + (None, None) => app.pick_and_open("place"), }, "app.placeWithOptions" => { // Word/RTF Import Options: style mapping and conflicts before the text lands. diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f24d1d548..0fbdaca45 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -15,7 +15,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.newDocumentDialog", "New Document…", Some("Cmd+N"), "{}"), ("app.openDialog", "Open…", Some("Cmd+O"), "{path?} — without a path, choose the file in a dialog"), ("app.openRecent", "Open Recent File", None, "{index} — file.openRecent, with an alert when the file can't be opened"), - ("app.placeDialog", "Place…", Some("Cmd+D"), "{path?} — without a path, choose the file in a dialog"), + ( + "app.placeDialog", + "Place…", + Some("Cmd+D"), + "{path?, paths?: [path] (several, loaded into the place cursor in order)} — without either, choose the files in a dialog", + ), ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{path?} — without a path, choose where in a dialog"), ("app.save", "Save", Some("Cmd+S"), "{}"), ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md new file mode 100644 index 000000000..997414bcd --- /dev/null +++ b/docs/multi-image-placement.md @@ -0,0 +1,26 @@ +# 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)"). + +- 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. +- Click an existing graphic frame to replace its contents. +- While drawing, Right/Left add/remove columns and Up/Down add/remove rows. Release to place a grid in row order with 6-point gutters. Extra images remain in the cursor; unused grid cells create no empty frames. +- Press Escape or select another tool to cancel the remaining placement. + +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. + +Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. + +## Automation + +All placement behavior is available through the engine, CLI, control channel and MCP: + +```json +{"command":"place.load","params":{"files":[{"path":"/images/one.png"},{"path":"/images/two.jpg"}]}} +{"command":"place.load","params":{"files":[{"path":"/images/three.png"}],"append":true}} +{"command":"place.grid","params":{"rect":[36,36,576,756],"columns":3,"rows":4,"gutter":6}} +{"command":"place.cancel","params":{}} +``` + +Each source can instead supply `base64` and `name`, including on the web. `append` queues the images after those already loaded instead of replacing them. `place.grid` consumes only the images that fit. Undo removes placed frames together, while the cursor remains at its current position in the queue. The UI command `app.placeDialog` takes `paths` for a File > Place of several files without the dialog. From cb97fcb0bdcccea9a192b5eeedfc6b533bb4f667 Mon Sep 17 00:00:00 2001 From: texjer Date: Wed, 4 Nov 2026 10:31:22 -0500 Subject: [PATCH 02/10] Place: a grid drag too small for its cells previews and places one frame The place cursor's grid preview fell back to the raw drag rectangle when the cells had no positive size, while the release still sent `place.grid`, which rejected the area. Both now use the same cell calculation (`grid_cells`), and a grid with no room for its cells shows and places the single proportional frame of an ordinary drag. --- crates/tools/src/nav.rs | 41 +++++++++++++++++++++++------------ crates/tools/src/tests.rs | 31 ++++++++++++++++++++++++++ docs/multi-image-placement.md | 2 +- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 2b84010d1..762a4f0f3 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -149,9 +149,10 @@ impl Tool for PlaceGun { if click { return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))]; } - // Arrow keys during the drag set a grid: the queued images fill its cells. - if self.columns > 1 || self.rows > 1 { - let r = designcraft_geom::Rect::from_points(sa, b); + // Arrow keys during the drag set a grid: the queued images fill its cells. A drag + // too small for the grid's cells places one frame instead, as the overlay shows. + let r = designcraft_geom::Rect::from_points(sa, b); + if !grid_cells(r, self.columns, self.rows).is_empty() { return vec![Action::Exec( "place.grid".into(), json!({"spread": crate::spread_json(sr), "rect": [r.x0, r.y0, r.x1, r.y1], "columns": self.columns, "rows": self.rows}), @@ -195,8 +196,13 @@ impl Tool for PlaceGun { // Under the drag threshold the release is a click (actual size where it was pressed): // no frame is drawn yet, the loaded cursor shows what lands. let click = (self.cur - a).hypot() < cx.tol(3.0); - if !click && (self.columns > 1 || self.rows > 1) { - return grid_overlays(designcraft_geom::Rect::from_points(a, self.cur), self.columns, self.rows); + // A grid draws its cells; one too small for them falls back to the single frame, as the + // release does. + if !click { + let cells = grid_cells(designcraft_geom::Rect::from_points(a, self.cur), self.columns, self.rows); + if !cells.is_empty() { + return cells.into_iter().map(crate::Overlay::Marquee).collect(); + } } let Some(size) = cx.loaded else { return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; @@ -228,21 +234,28 @@ Y: {pct}" } } -/// The cells of a `columns` × `rows` place grid drawn over `rect`, 6 pt apart, as `place.grid` -/// lays them out; the whole rect when the cells would vanish. -fn grid_overlays(rect: designcraft_geom::Rect, columns: usize, rows: usize) -> Vec { +/// The gutter between place-grid cells, in points: `place.grid`'s default. +const PLACE_GRID_GUTTER: f64 = 6.0; + +/// The cells of a `columns` × `rows` place grid drawn over `rect`, [`PLACE_GRID_GUTTER`] apart, as +/// `place.grid` lays them out. None for a 1 × 1 grid, or when `rect` is too small for the cells to +/// have a positive size: then the drag places one frame. +fn grid_cells(rect: designcraft_geom::Rect, columns: usize, rows: usize) -> Vec { let (columns, rows) = (columns.clamp(1, 100), rows.clamp(1, 100)); - let w = (rect.width() - 6.0 * (columns - 1) as f64) / columns as f64; - let h = (rect.height() - 6.0 * (rows - 1) as f64) / rows as f64; + if columns == 1 && rows == 1 { + return vec![]; + } + let w = (rect.width() - PLACE_GRID_GUTTER * (columns - 1) as f64) / columns as f64; + let h = (rect.height() - PLACE_GRID_GUTTER * (rows - 1) as f64) / rows as f64; if !(w > 0.0 && h > 0.0) { - return vec![crate::Overlay::Marquee(rect)]; + return vec![]; } (0..rows) .flat_map(|row| { (0..columns).map(move |column| { - let x = rect.x0 + column as f64 * (w + 6.0); - let y = rect.y0 + row as f64 * (h + 6.0); - crate::Overlay::Marquee(designcraft_geom::Rect::new(x, y, x + w, y + h)) + let x = rect.x0 + column as f64 * (w + PLACE_GRID_GUTTER); + let y = rect.y0 + row as f64 * (h + PLACE_GRID_GUTTER); + designcraft_geom::Rect::new(x, y, x + w, y + h) }) }) .collect() diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 28cfecf01..790b3c7f8 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2369,3 +2369,34 @@ fn place_gun_grid_arrow_keys_are_handled_during_a_drag() { } assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty()); } + +/// A place-cursor grid drag too thin for its cells shows and places the single frame an ordinary +/// drag would; one with room shows its cells and places the grid. +#[test] +fn place_gun_grid_too_small_for_its_cells_previews_and_places_one_frame() { + let d = Document::new(&NewDocument::default()); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let mut cx = ctx(&d, &s, &c, &l); + cx.loaded = Some((40.0, 20.0)); + // Drag from (10, 10) to `to`, pressing ↑ (two rows) when `grid`: the overlay before release, + // and the release. + let drag = |to: (f64, f64), grid: bool| { + let mut t = create("placeGun"); + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1)); + if grid { + assert_eq!(t.key(&cx, ToolKey::Up, Mods::default()), vec![Action::Handled]); + } + let shown = t.overlays(&cx); + (shown, t.pointer(&cx, &PointerEvent::new(PointerKind::Up, to.0, to.1))) + }; + // 100 × 8 pt: two rows 6 pt apart leave each a 1 pt cell; 100 × 4 pt leaves none. + let (shown, out) = drag((110.0, 18.0), true); + assert_eq!(shown.len(), 2, "{shown:?}"); + assert!(matches!(out.as_slice(), [Action::Exec(cmd, p)] if cmd == "place.grid" && p["rows"] == 2), "{out:?}"); + let (shown, out) = drag((110.0, 14.0), true); + let (single_shown, single_out) = drag((110.0, 14.0), false); + assert_eq!(shown, single_shown); + assert_eq!(out, single_out); + assert!(matches!(out.as_slice(), [Action::Exec(cmd, _)] if cmd == "place.drop"), "{out:?}"); +} diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index 997414bcd..da8f180bf 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -5,7 +5,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in - 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. - Click an existing graphic frame to replace its contents. -- While drawing, Right/Left add/remove columns and Up/Down add/remove rows. Release to place a grid in row order with 6-point gutters. Extra images remain in the cursor; unused grid cells create no empty frames. +- While drawing, Right/Left add/remove columns and Up/Down add/remove rows. Release to place a grid in row order with 6-point gutters. Extra images remain in the cursor; unused grid cells create no empty frames. A drag too small for the grid's cells shows and places one frame instead, as an ordinary drag does. - Press Escape or select another tool to cancel the remaining placement. 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. From e4f82e243eaa96591f3d88cb00b341a1b42ef0b3 Mon Sep 17 00:00:00 2001 From: texjer Date: Wed, 4 Nov 2026 11:05:45 -0500 Subject: [PATCH 03/10] Place: switching, opening or closing a document leaves the place cursor's tool Review fix for #2309: set_active, add_document and close_document emptied the place cursor but kept the placeGun tool, so the next canvas click failed with 'nothing loaded in the place cursor'. One helper, Session::unload_place_cursor, empties the cursor and its queue and goes back to the Selection tool when the place cursor's tool is active; validate_place_cursor uses it too. Test: changing_documents_unloads_the_place_cursor_and_its_tool. --- ROADMAP.md | 2 +- crates/engine/src/cmd/file/place.rs | 4 +--- crates/engine/src/lib.rs | 9 +++------ crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 10 ++++++++++ docs/multi-image-placement.md | 1 + 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index df9b5fea0..b080dfc9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -408,7 +408,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** · 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** · 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). - **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index ddad5ba96..081e3bd27 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -313,9 +313,7 @@ fn validate_place_cursor(s: &mut Session, command: &str) -> Result<()> { } let valid = s.loaded.iter().chain(s.loaded_queue.iter()).all(|image| s.doc().is_ok_and(|d| d.doc.assets.contains_key(&image.asset))); if !valid { - s.loaded = None; - s.loaded_queue.clear(); - s.set_tool("selection"); + s.unload_place_cursor(); return Err(bad(command, "the loaded graphic is no longer in the document")); } Ok(()) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 397be3203..33af11c42 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -552,8 +552,7 @@ impl Session { } self.reset_find_scope(); self.stop_data_preview(); - self.loaded = None; - self.loaded_queue.clear(); + self.unload_place_cursor(); self.active = Some(i); } } @@ -563,8 +562,7 @@ impl Session { } self.reset_find_scope(); self.stop_data_preview(); - self.loaded = None; - self.loaded_queue.clear(); + self.unload_place_cursor(); self.docs.push(d); self.active = Some(self.docs.len() - 1); self.docs.len() - 1 @@ -587,8 +585,7 @@ impl Session { recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { - self.loaded = None; - self.loaded_queue.clear(); + self.unload_place_cursor(); self.reset_find_scope(); self.docs.remove(i); self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 564e42605..a2751de72 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2127,6 +2127,33 @@ fn undoing_loaded_assets_cancels_every_placement_cursor() { } } +/// Switching, opening or closing a document empties the place cursor and leaves its tool: a click +/// on the canvas then selects, instead of failing with nothing loaded. Another tool stays. +#[test] +fn changing_documents_unloads_the_place_cursor_and_its_tool() { + let changes: [fn(&mut Session); 3] = [ + |s| s.set_active(0), + |s| { + s.execute("file.new", &json!({})).unwrap(); + }, + |s| s.close_document(1), + ]; + for (n, change) in changes.into_iter().enumerate() { + let mut s = session(); + s.execute("file.new", &json!({})).unwrap(); + load_test_images(&mut s, 3); + assert_eq!(s.tool_id(), "placeGun"); + change(&mut s); + assert!(s.loaded.is_none() && s.loaded_queue.is_empty(), "change {n}"); + assert_eq!(s.tool_id(), "selection", "change {n}"); + // Holding another tool, the change keeps it. + s.execute("file.new", &json!({})).unwrap(); + s.set_tool("rectangle"); + s.set_active(0); + assert_eq!(s.tool_id(), "rectangle", "change {n}"); + } +} + /// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key /// object, and Align to Key Object leaves it in place. A second click on it drops the key. #[test] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 6dfd23703..9bde0acfd 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -87,6 +87,16 @@ impl Session { } } + /// Empty the place cursor (its graphic and the ones queued after it). Holding the place + /// cursor's tool, go back to the Selection tool: with nothing loaded, a click would only fail. + pub(crate) fn unload_place_cursor(&mut self) { + self.loaded = None; + self.loaded_queue.clear(); + if self.tool_id() == "placeGun" { + self.set_tool("selection"); + } + } + pub fn layout(&self) -> CanvasLayout { match self.active() { Some(st) => CanvasLayout::new(&st.doc, st.editing_parents), diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index da8f180bf..ca0d38415 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -7,6 +7,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in - Click an existing graphic frame to replace its contents. - While drawing, Right/Left add/remove columns and Up/Down add/remove rows. Release to place a grid in row order with 6-point gutters. Extra images remain in the cursor; unused grid cells create no empty frames. A drag too small for the grid's cells shows and places one frame instead, as an ordinary drag does. - Press Escape or select another tool to cancel the remaining placement. +- Switching to, opening or closing a document also cancels it, and goes back to the Selection tool. 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. From d721ef9f62176f0317ca176b7ae1075e2f5c1c2a Mon Sep 17 00:00:00 2001 From: texjer Date: Wed, 4 Nov 2026 19:36:38 -0500 Subject: [PATCH 04/10] Place: a grid previews the cells it places on a rotated spread The preview divided the dragged rectangle on the canvas and the release divided it on the spread, so on a rotated spread two columns previewed across the canvas and landed down it. The preview now takes the release's cells and shows each where it lands on the canvas. --- crates/tools/src/nav.rs | 13 +++++++++---- crates/tools/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d4925009f..c86fc4fec 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -197,11 +197,16 @@ impl Tool for PlaceGun { // no frame is drawn yet, the loaded cursor shows what lands. let click = (self.cur - a).hypot() < cx.tol(3.0); // A grid draws its cells; one too small for them falls back to the single frame, as the - // release does. - if !click { - let cells = grid_cells(designcraft_geom::Rect::from_points(a, self.cur), self.columns, self.rows); + // release does. The cells are the release's, worked out on the spread: on a rotated spread + // its columns don't run across the canvas. + if !click && let Some((sr, sa)) = cx.layout.spread_at(a) { + let cells = grid_cells(designcraft_geom::Rect::from_points(sa, cx.layout.to_spread(sr, self.cur)), self.columns, self.rows); if !cells.is_empty() { - return cells.into_iter().map(crate::Overlay::Marquee).collect(); + let on_canvas = |x: f64, y: f64| cx.layout.to_canvas(sr, Point::new(x, y)); + return cells + .into_iter() + .map(|c| crate::Overlay::Marquee(designcraft_geom::Rect::from_points(on_canvas(c.x0, c.y0), on_canvas(c.x1, c.y1)))) + .collect(); } } let Some(size) = cx.loaded else { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6fb3a6af7..9fd67fdd8 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2441,3 +2441,40 @@ fn place_gun_grid_too_small_for_its_cells_previews_and_places_one_frame() { assert_eq!(out, single_out); assert!(matches!(out.as_slice(), [Action::Exec(cmd, _)] if cmd == "place.drop"), "{out:?}"); } + +/// On a rotated spread a place-cursor grid previews the cells it places: worked out on the spread, +/// then shown on the canvas, so two columns on the spread don't preview as two across the canvas. +#[test] +fn place_gun_grid_preview_matches_the_release_on_a_rotated_spread() { + let mut d = Document::new(&NewDocument::default()); + std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let (start, end) = (Point::new(10.0, 10.0), Point::new(210.0, 110.0)); + let (spread, a) = l.spread_at(start).unwrap(); + let b = l.to_spread(spread, end); + let mut t = create("placeGun"); + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, start.x, start.y)); + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, end.x, end.y)); + assert_eq!(t.key(&cx, ToolKey::Right, Mods::default()), vec![Action::Handled]); + let shown = t.overlays(&cx); + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, end.x, end.y)); + assert!(matches!(out.as_slice(), [Action::Exec(cmd, p)] if cmd == "place.grid" && p["columns"] == 2), "{out:?}"); + // Two columns on the spread, 6 pt apart, each shown where its corners land on the canvas. + let r = Rect::from_points(a, b); + let w = (r.width() - 6.0) / 2.0; + let expected: Vec = [r.x0, r.x0 + w + 6.0] + .into_iter() + .map(|x| Overlay::Marquee(Rect::from_points(l.to_canvas(spread, Point::new(x, r.y0)), l.to_canvas(spread, Point::new(x + w, r.y1))))) + .collect(); + assert_eq!(shown.len(), 2, "{shown:?}"); + for (got, want) in shown.iter().zip(&expected) { + let (Overlay::Marquee(got), Overlay::Marquee(want)) = (got, want) else { panic!("{got:?}") }; + for (g, w) in [(got.x0, want.x0), (got.y0, want.y0), (got.x1, want.x1), (got.y1, want.y1)] { + assert!((g - w).abs() < 1e-6, "{shown:?} against {expected:?}"); + } + } + // A quarter turn: the columns stack down the canvas. + let (Overlay::Marquee(first), Overlay::Marquee(second)) = (&shown[0], &shown[1]) else { panic!("{shown:?}") }; + assert!((first.x0 - second.x0).abs() < 1e-6, "{shown:?}"); +} From 92d3e74a3979598137c0dad4d59bf9827aef10a4 Mon Sep 17 00:00:00 2001 From: texjer Date: Wed, 4 Nov 2026 19:36:38 -0500 Subject: [PATCH 05/10] Place: a multi-page PDF in a batch keeps the images loaded before it Confirming the PDF's Import Options placed it without append, so in a batch such as [a.png, b.png, multi.pdf] it replaced the images already in the cursor. The dialog now carries append, on both the native and the delivered-file paths. A single multi-page PDF still replaces what the cursor holds. --- crates/ui-egui/src/app/files.rs | 62 +++++++++++++++++++++++++++++---- crates/ui-egui/src/dialogs.rs | 6 ++-- docs/multi-image-placement.md | 2 +- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3df85b504..8b1682d83 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -44,7 +44,8 @@ impl DesignApp { let target = self.session.active().map(|d| d.uid); let pages = self.services.read.as_mut().and_then(|r| r(path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); if pages > 1 { - self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); + self.ui.dialog = + Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target, "append": append}))); return Ok(Value::Null); } } @@ -135,10 +136,12 @@ impl DesignApp { self.pending_pdf = None; } - pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result { + /// Import Options confirmed for the delivered PDF; a placed one is added to the place cursor + /// when `append`, so the images loaded before it stay. + pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String, append: bool) -> Result { let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?; self.activate_import_target(&file.request)?; - let params = json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}); + let params = json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop, "append": append}); if file.request.purpose == "place" { self.place_file(params) } else { self.open_file("file.place", params) } } @@ -243,8 +246,10 @@ impl DesignApp { if place && lower.ends_with(".pdf") { let pages = designcraft_render::pdf_page_count(&file.bytes).unwrap_or(1); if pages > 1 { - self.ui.dialog = - Some(dialogs::Dialog::new("pdfImport", json!({"async": true, "name": file.name.clone(), "page": "1", "pages": pages}))); + self.ui.dialog = Some(dialogs::Dialog::new( + "pdfImport", + json!({"async": true, "name": file.name.clone(), "page": "1", "pages": pages, "append": append}), + )); self.pending_pdf = Some(file); return Ok(Value::Null); } @@ -770,7 +775,7 @@ pub(crate) mod browser_import_tests { app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(page.to_string())); // The native dialog clamps zero to one; positive invalid pages must fail. if page == 0 { - assert!(app.confirm_pdf_import(page, "crop".into()).is_err()); + assert!(app.confirm_pdf_import(page, "crop".into(), false).is_err()); } else { assert!(dialogs::confirm(&mut app).is_err()); } @@ -908,6 +913,51 @@ 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 multi-page PDF after images in a native batch asks which page, then queues after them. + #[test] + fn native_batch_keeps_its_images_when_a_multi_page_pdf_is_confirmed() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + std::fs::write(path("a.svg"), image()).unwrap(); + std::fs::write(path("b.svg"), image()).unwrap(); + std::fs::write(path("three.pdf"), pdf_pages(3)).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + app.place_paths(vec![path("a.svg"), path("b.svg"), path("three.pdf")]).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("pdfImport")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg", "b.svg", "three.pdf"]); + } + + /// The same for files delivered by the host (a browser's Place): the images stay. + #[test] + fn inbox_keeps_its_images_when_a_multi_page_pdf_is_confirmed() { + let mut app = app(); + let request = app.import_request("place"); + for (name, bytes) in [("a.png", png(200)), ("b.png", png(90)), ("three.pdf", pdf_pages(3))] { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request: request.clone(), name: name.into(), bytes }); + } + app.drain_inbox(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("pdfImport")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(loaded_names(&app), ["a.png", "b.png", "three.pdf"]); + } + + /// A single multi-page PDF still replaces what the cursor holds, as any single Place does. + #[test] + fn a_single_multi_page_pdf_replaces_the_cursor_when_confirmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("three.pdf").to_string_lossy().to_string(); + std::fs::write(&path, pdf_pages(3)).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let b64 = designcraft_engine::cmd::base64_encode(&image()); + app.run("place.load", json!({"base64": b64, "name": "a.svg"})).unwrap(); + app.open_picked("place", path).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(loaded_names(&app), ["three.pdf"]); + } + #[test] fn batch_cancel_status_uses_the_interface_language() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7662c0cb0..460a4bc71 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4630,12 +4630,14 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { }, }; if d.b("async") { - return app.confirm_pdf_import(page, if crop.is_empty() { "crop".into() } else { crop }); + return app.confirm_pdf_import(page, if crop.is_empty() { "crop".into() } else { crop }, d.b("append")); } if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; } - app.place_file(json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) + app.place_file( + json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }, "append": d.b("append")}), + ) } "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index ca0d38415..cae08a519 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -9,7 +9,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in - Press Escape or select another tool to cancel the remaining placement. - Switching to, opening or closing a document also cancels it, and goes back to the Selection tool. -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. +A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. A multi-page PDF in a batch stops the batch at its import options; once confirmed, it queues after the images loaded before it. 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. Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. From 391e0881b4e75294694916c1a8aa01f1e400bb08 Mon Sep 17 00:00:00 2001 From: texjer Date: Wed, 4 Nov 2026 19:56:42 -0500 Subject: [PATCH 06/10] Place: the grid tool sends the gutter it previews The preview's 6 pt duplicated place.grid's default, so changing one would leave the preview out of step with what lands. The tool now sends its gutter with the command. --- crates/tools/src/nav.rs | 5 +++-- crates/tools/src/tests.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index c86fc4fec..456c73ba9 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -155,7 +155,7 @@ impl Tool for PlaceGun { if !grid_cells(r, self.columns, self.rows).is_empty() { return vec![Action::Exec( "place.grid".into(), - json!({"spread": crate::spread_json(sr), "rect": [r.x0, r.y0, r.x1, r.y1], "columns": self.columns, "rows": self.rows}), + json!({"spread": crate::spread_json(sr), "rect": [r.x0, r.y0, r.x1, r.y1], "columns": self.columns, "rows": self.rows, "gutter": PLACE_GRID_GUTTER}), )]; } // The frame the drag drew: at the graphic's proportions from where it started. @@ -239,7 +239,8 @@ Y: {pct}" } } -/// The gutter between place-grid cells, in points: `place.grid`'s default. +/// The gutter between place-grid cells, in points. The tool sends it with `place.grid`, so the +/// preview and what lands can't disagree if the command's default changes. const PLACE_GRID_GUTTER: f64 = 6.0; /// The cells of a `columns` × `rows` place grid drawn over `rect`, [`PLACE_GRID_GUTTER`] apart, as diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 9fd67fdd8..4949e20e4 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2459,7 +2459,7 @@ fn place_gun_grid_preview_matches_the_release_on_a_rotated_spread() { assert_eq!(t.key(&cx, ToolKey::Right, Mods::default()), vec![Action::Handled]); let shown = t.overlays(&cx); let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, end.x, end.y)); - assert!(matches!(out.as_slice(), [Action::Exec(cmd, p)] if cmd == "place.grid" && p["columns"] == 2), "{out:?}"); + assert!(matches!(out.as_slice(), [Action::Exec(cmd, p)] if cmd == "place.grid" && p["columns"] == 2 && p["gutter"] == 6.0), "{out:?}"); // Two columns on the spread, 6 pt apart, each shown where its corners land on the canvas. let r = Rect::from_points(a, b); let w = (r.width() - 6.0) / 2.0; From 8f2451bc6642d4cf7d8995e4cfa0e25e417f3106 Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 14:13:55 -0500 Subject: [PATCH 07/10] Place: a PDF later in a batch joins the place cursor, whatever is selected With a graphic frame selected, `file.place` chose that frame before it looked at the place cursor, so a PDF confirmed with `append` filled the frame and left the images around it queued. An appended file now skips the selected-frame fallback; an explicit `frame` still applies, and a single placed file still fills the selected frame. --- crates/engine/src/cmd/file/place.rs | 9 ++++++--- crates/ui-egui/src/app/files.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 081e3bd27..5ecd31268 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -100,8 +100,11 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { (nw, nh) } }; + // A file joining the place cursor's queue goes after the ones already there, whatever is selected. + let load_cursor_param = p.get("loadCursor").and_then(Value::as_bool) == Some(true); + let append = p.get("append").and_then(Value::as_bool) == Some(true); let target_frame = crate::cmd::id_param(p, "frame").or_else(|| { - if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + if (load_cursor_param && append) || ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { return None; } let st = s.active()?; @@ -115,8 +118,8 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { let lid = s.doc()?.active_layer; let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); - if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { - return load_cursor(s, asset, (bw, bh), p.get("append").and_then(Value::as_bool) == Some(true)); + if target_frame.is_none() && px.is_none() && py.is_none() && load_cursor_param { + return load_cursor(s, asset, (bw, bh), append); } s.edit(|d, sel| { let fresh = AssetId(d.alloc()); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 8b1682d83..48ca90e26 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1010,6 +1010,27 @@ pub(crate) mod browser_import_tests { assert_eq!(loaded_names(&app), ["a.svg", "one.pdf", "b.svg", "c.svg", "a.svg"]); } + /// A PDF later in a batch joins the place cursor with the images around it, even with a + /// graphic frame selected: it doesn't fill that frame and leave the others queued. + #[test] + fn a_pdf_in_a_batch_queues_rather_than_filling_the_selected_frame() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + std::fs::write(path("a.svg"), image()).unwrap(); + std::fs::write(path("one.pdf"), pdf_pages(1)).unwrap(); + std::fs::write(path("b.svg"), image()).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let f = designcraft_doc::ItemId( + app.run("frame.create", json!({"rect": [200, 200, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(), + ); + assert!(app.session.active().unwrap().selection.items.contains(&f)); + app.place_paths(vec![path("a.svg"), path("one.pdf"), path("b.svg")]).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg", "one.pdf", "b.svg"]); + let frame = app.session.active().unwrap().doc.item(f).unwrap(); + assert!(!matches!(frame.content, designcraft_doc::Content::Graphic(_)), "the selected frame stays empty"); + } + /// File › Place with several files chosen in the dialog loads them all into the place /// cursor, in the order chosen; with one, it is placed as before. #[test] From 5868b6036584aff4d155ee181c9124e5ec12f08b Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 14:54:26 -0500 Subject: [PATCH 08/10] Place: the 1000-image cap holds for the whole place cursor when appending Appending only checked each batch against the cap, and a single image or a PDF added with append wasn't checked at all, so repeated appends could grow the cursor without limit. Refused before decoding a batch or adding an asset. --- crates/engine/src/cmd/file/place.rs | 90 ++++++++++++++++++++++++++++- docs/multi-image-placement.md | 2 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 5ecd31268..5ac886618 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -253,6 +253,7 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { if sources.is_empty() || sources.len() > MAX_LOADED_IMAGES { return Err(bad("place.load", format!("select between 1 and {MAX_LOADED_IMAGES} images"))); } + check_cursor_room(s, sources.len(), append, "place.load")?; let mut images = Vec::with_capacity(sources.len()); for source in sources { let (bytes, name, link) = read_source(source)?; @@ -280,9 +281,18 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { Ok(json!({"remaining": loaded_count(s), "loaded": true})) } -/// Most images one `place.load` takes. +/// Most images the place cursor holds, and so most one `place.load` takes. const MAX_LOADED_IMAGES: usize = 1000; +/// Whether `incoming` images fit in the place cursor: appended, they go after the ones it holds. +fn check_cursor_room(s: &Session, incoming: usize, append: bool, command: &str) -> Result<()> { + let held = if append { loaded_count(s) } else { 0 }; + if held.saturating_add(incoming) > MAX_LOADED_IMAGES { + return Err(bad(command, format!("the place cursor holds at most {MAX_LOADED_IMAGES} images, and it has {held}"))); + } + Ok(()) +} + /// How many images the place cursor holds: the current one and those queued after it. fn loaded_count(s: &Session) -> usize { s.loaded_queue.len().saturating_add(usize::from(s.loaded.is_some())) @@ -291,6 +301,7 @@ fn loaded_count(s: &Session) -> usize { /// Add a decoded asset with a fresh id, replacing the previous placement queue (or, with /// `append` and an image already loaded, queued after it). fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64), append: bool) -> Result { + check_cursor_room(s, 1, append, "place.load")?; let name = asset.name.clone(); let (pw, ph) = asset.pixels.unwrap_or_default(); let aid = s.edit(|d, _| { @@ -1067,3 +1078,80 @@ mod nested_frame_tests { assert_eq!(d.spreads[0].items.len(), 1, "no new object"); } } + +#[cfg(test)] +mod cursor_cap_tests { + use serde_json::{Value, json}; + + use super::MAX_LOADED_IMAGES; + use crate::Session; + + fn png() -> String { + let png = designcraft_render::Rendered { width: 4, height: 4, pixels: [200, 90, 30, 255].repeat(16) }.to_png(); + super::super::base64_encode(&png) + } + + /// A session whose place cursor holds `held` images, loaded without decoding each of them. + fn holding(held: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("place.load", &json!({"base64": png(), "name": "first.png"})).unwrap(); + let first = s.loaded.unwrap(); + s.loaded_queue = std::iter::repeat_n(first, held - 1).collect(); + s + } + + fn count(s: &Session) -> usize { + super::loaded_count(s) + } + + /// Every way of adding to the cursor stops at the cap, and a refused add changes nothing. + #[test] + fn appending_stops_at_the_cap_on_the_whole_cursor() { + let image = png(); + let single = json!({"base64": image, "name": "a.png", "append": true}); + let placed = json!({"base64": image, "name": "a.png", "append": true, "loadCursor": true}); + let two = json!({"files": [{"base64": image, "name": "a.png"}, {"base64": image, "name": "b.png"}], "append": true}); + for (command, params) in [("place.load", &single), ("file.place", &placed), ("place.load", &two)] { + let mut s = holding(MAX_LOADED_IMAGES); + let assets = s.doc().unwrap().doc.assets.len(); + assert!(s.execute(command, params).is_err(), "{command} {params}"); + assert_eq!(count(&s), MAX_LOADED_IMAGES, "{command} {params}"); + assert_eq!(s.doc().unwrap().doc.assets.len(), assets, "{command} {params}"); + } + // One over, as a batch. + let mut s = holding(MAX_LOADED_IMAGES - 1); + assert!(s.execute("place.load", &two).is_err()); + assert_eq!(count(&s), MAX_LOADED_IMAGES - 1); + } + + /// The last places up to the cap load, and an unreadable batch reports the cap before decoding. + #[test] + fn appending_up_to_the_cap_still_loads() { + let image = png(); + for params in + [json!({"base64": image, "name": "a.png", "append": true}), json!({"files": [{"base64": image, "name": "a.png"}], "append": true})] + { + let mut s = holding(MAX_LOADED_IMAGES - 1); + s.execute("place.load", ¶ms).unwrap(); + assert_eq!(count(&s), MAX_LOADED_IMAGES, "{params}"); + } + let mut s = holding(MAX_LOADED_IMAGES); + let error = + s.execute("place.load", &json!({"files": [{"base64": "not an image", "name": "a.png"}], "append": true})).unwrap_err().to_string(); + assert!(error.contains(&MAX_LOADED_IMAGES.to_string()), "{error}"); + } + + /// Without `append` the new images replace the cursor, so what it holds doesn't count. + #[test] + fn replacing_a_full_cursor_is_not_capped() { + let image = png(); + let mut s = holding(MAX_LOADED_IMAGES); + let result: Value = s.execute("place.load", &json!({"base64": image, "name": "a.png"})).unwrap(); + assert_eq!(result["loaded"], true); + assert_eq!(count(&s), 1); + let mut s = holding(MAX_LOADED_IMAGES); + s.execute("place.load", &json!({"files": [{"base64": image, "name": "a.png"}]})).unwrap(); + assert_eq!(count(&s), 1); + } +} diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index cae08a519..bb58b600a 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -9,7 +9,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in - Press Escape or select another tool to cancel the remaining placement. - Switching to, opening or closing a document also cancels it, and goes back to the Selection tool. -A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. A multi-page PDF in a batch stops the batch at its import options; once confirmed, it queues after the images loaded before it. 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. +A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. A multi-page PDF in a batch stops the batch at its import options; once confirmed, it queues after the images loaded before it. 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. Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. From 15eeacc8456af6d9e194647b6d11c9b40c40b907 Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 15:00:33 -0500 Subject: [PATCH 09/10] Place: the files after a multi-page PDF in a batch wait for its options They were left behind in place_paths when the PDF's Import Options opened, so the PDF was placed alone and the rest of what was chosen never loaded. They now wait in the dialog and carry on when it is confirmed; Cancel ends the batch. --- crates/ui-egui/src/app/files.rs | 82 +++++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 16 ++++++- docs/multi-image-placement.md | 2 +- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 48ca90e26..6f5368ad8 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -52,18 +52,21 @@ impl DesignApp { self.place_file(if append { json!({"path": path, "append": true}) } else { json!({"path": path}) }) } - /// Place an ordered batch of files, as File › Place with several chosen. The batch stops when - /// a file opens a dialog (a multi-page PDF's Import Options, an alert), as the inbox's does; - /// every file that loads the cursor adds to what the earlier ones loaded, in the order the - /// files were chosen (images in a row load together, before the file after them). A file that - /// can't be placed gets an alert and stops the batch; its error is returned, so automation - /// doesn't carry on as if the batch had loaded. + /// Place an ordered batch of files, as File › Place with several chosen. A multi-page PDF's + /// Import Options pause the batch: the files after it wait in the dialog and carry on when it + /// is confirmed, and Cancel ends the batch. Any other dialog (an alert, one already open) stops + /// it, as the inbox's does. Every file that loads the cursor adds to what the earlier ones + /// loaded, in the order the files were chosen (images in a row load together, before the file + /// after them). A file that can't be placed gets an alert and stops the batch; its error is + /// returned, so automation doesn't carry on as if the batch had loaded. pub(crate) fn place_paths(&mut self, paths: Vec) -> Result<(), String> { let mut images = Vec::new(); // Images already in the place cursor stay: the batch queues after them. let mut loaded = self.session.loaded.is_some(); - // `None` at the end loads the images left. - for path in paths.into_iter().map(Some).chain([None]) { + let mut paths = paths.into_iter(); + loop { + // `None` at the end loads the images left. + let path = paths.next(); if let Some(p) = &path && is_cursor_image(p) { @@ -71,7 +74,7 @@ impl DesignApp { continue; } if path.is_none() && images.is_empty() { - break; + return Ok(()); } if self.ui.dialog.is_some() { self.status(crate::i18n::tr(&self.ui.language, "Place cancelled while a dialog is open")); @@ -81,12 +84,14 @@ impl DesignApp { let r = self.load_place_cursor(std::mem::take(&mut images), loaded)?; loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); } - if let Some(p) = path { - let r = self.place_path(&p, loaded)?; - loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); + let Some(p) = path else { return Ok(()) }; + let r = self.place_path(&p, loaded)?; + loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); + if let Some(d) = self.ui.dialog.as_mut().filter(|d| d.id == "pdfImport") { + d.fields.insert(dialogs::PDF_IMPORT_REST.into(), json!(paths.collect::>())); + return Ok(()); } } - Ok(()) } /// `place.load` of `files` (`{path}` or `{name, base64}` each), queued after the images @@ -943,6 +948,57 @@ pub(crate) mod browser_import_tests { assert_eq!(loaded_names(&app), ["a.png", "b.png", "three.pdf"]); } + /// The files after a multi-page PDF in a native batch wait for its Import Options, through a + /// second such PDF too, and keep the order chosen. + #[test] + fn native_batch_continues_after_a_multi_page_pdf_is_confirmed() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + for name in ["a.svg", "b.svg", "c.svg", "d.svg"] { + std::fs::write(path(name), image()).unwrap(); + } + std::fs::write(path("three.pdf"), pdf_pages(3)).unwrap(); + std::fs::write(path("two.pdf"), pdf_pages(2)).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let batch = ["a.svg", "three.pdf", "b.svg", "c.svg", "two.pdf", "d.svg"].map(path).to_vec(); + app.place_paths(batch).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg"], "waiting for the first PDF's options"); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("pdfImport"), "the second PDF asks in its turn"); + assert_eq!(loaded_names(&app), ["a.svg", "three.pdf", "b.svg", "c.svg"]); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(loaded_names(&app), ["a.svg", "three.pdf", "b.svg", "c.svg", "two.pdf", "d.svg"]); + } + + /// A refused page number keeps the rest of the batch waiting; Cancel ends the batch, and what + /// loaded before the PDF stays. + #[test] + fn native_batch_waits_through_a_refused_page_and_ends_at_cancel() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + std::fs::write(path("a.svg"), image()).unwrap(); + std::fs::write(path("b.svg"), image()).unwrap(); + std::fs::write(path("three.pdf"), pdf_pages(3)).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let batch = ["a.svg", "three.pdf", "b.svg"].map(path).to_vec(); + app.place_paths(batch.clone()).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("4-5")); + assert!(dialogs::confirm(&mut app).is_err()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(loaded_names(&app), ["a.svg", "three.pdf", "b.svg"]); + + let mut app = self::app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + app.place_paths(batch).unwrap(); + dialogs::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!(loaded_names(&app), ["a.svg"]); + } + /// A single multi-page PDF still replaces what the cursor holds, as any single Place does. #[test] fn a_single_multi_page_pdf_replaces_the_cursor_when_confirmed() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4633620f3..b104a0ae1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1562,6 +1562,9 @@ fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, Ok(p) } +/// In a native batch's `pdfImport` dialog: the paths chosen after its PDF, placed once it is confirmed. +pub(crate) const PDF_IMPORT_REST: &str = "rest"; + /// File › Document Setup, filled from the document. /// The active document's ruler unit (picas without a document). fn doc_unit(app: &DesignApp) -> Unit { @@ -4711,9 +4714,18 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; } - app.place_file( + let placed = app.place_file( json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }, "append": d.b("append")}), - ) + )?; + // The files chosen after this PDF, which waited for its options. + let rest: Vec = d + .fields + .get(PDF_IMPORT_REST) + .and_then(Value::as_array) + .map(|paths| paths.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + app.place_paths(rest)?; + Ok(placed) } "print" => { let pages = if d.s("range") == "range" { json!(d.s("pages")) } else { Value::Null }; diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index bb58b600a..a99b4e144 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -9,7 +9,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in - Press Escape or select another tool to cancel the remaining placement. - Switching to, opening or closing a document also cancels it, and goes back to the Selection tool. -A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. A multi-page PDF in a batch stops the batch at its import options; once confirmed, it queues after the images loaded before it. 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. +A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. 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. Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files. From d5bb8a378c8c3ccb676885c19b2bc1feb49672ca Mon Sep 17 00:00:00 2001 From: texjer Date: Thu, 5 Nov 2026 18:40:41 -0500 Subject: [PATCH 10/10] Place: a browser batch waits for a PDF's options; a PDF first joins the cursor Files delivered after a multi-page PDF were dropped when its Import Options opened, with a status asking to choose them again, and choosing them again replaced the cursor. They now wait with the PDF and carry on when it is confirmed; Cancel ends the batch, as a native one's does. A batch starting with a single-page PDF, or in a browser with one image before a PDF, filled the selected frame instead of entering the place cursor. A browser delivers one Place's files together, so several in one delivery now count as a batch. One file alone still fills the frame. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/files.rs | 145 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 2 + docs/multi-image-placement.md | 2 +- 4 files changed, 142 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b2b433b71..0048a8c4e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -39,6 +39,7 @@ impl DesignApp { let dialog_wake = std::sync::Arc::new(std::sync::OnceLock::new()); DesignApp { pending_pdf: None, + pending_pdf_rest: Vec::new(), #[cfg(not(target_arch = "wasm32"))] file_dialogs: file_dialogs::new_dialogs(dialog_wake.clone()), #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 6f5368ad8..712f2b500 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -61,6 +61,8 @@ impl DesignApp { /// returned, so automation doesn't carry on as if the batch had loaded. pub(crate) fn place_paths(&mut self, paths: Vec) -> Result<(), String> { let mut images = Vec::new(); + // Several files go to the place cursor, even a PDF first with a frame selected. + let batch = paths.len() > 1; // Images already in the place cursor stay: the batch queues after them. let mut loaded = self.session.loaded.is_some(); let mut paths = paths.into_iter(); @@ -85,7 +87,7 @@ impl DesignApp { loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); } let Some(p) = path else { return Ok(()) }; - let r = self.place_path(&p, loaded)?; + let r = self.place_path(&p, batch || loaded)?; loaded |= r.get("loaded").and_then(Value::as_bool) == Some(true); if let Some(d) = self.ui.dialog.as_mut().filter(|d| d.id == "pdfImport") { d.fields.insert(dialogs::PDF_IMPORT_REST.into(), json!(paths.collect::>())); @@ -138,16 +140,28 @@ impl DesignApp { } pub(crate) fn cancel_pdf_import(&mut self) { + self.release_pending_pdf(); + } + + fn release_pending_pdf(&mut self) { self.pending_pdf = None; + self.pending_pdf_rest = Vec::new(); } /// Import Options confirmed for the delivered PDF; a placed one is added to the place cursor - /// when `append`, so the images loaded before it stay. + /// when `append`, so the images loaded before it stay. The files of its batch that waited for + /// the options carry on; if it can't be placed, the batch ends, as a native one does. pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String, append: bool) -> Result { let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?; + let rest = std::mem::take(&mut self.pending_pdf_rest); self.activate_import_target(&file.request)?; let params = json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop, "append": append}); - if file.request.purpose == "place" { self.place_file(params) } else { self.open_file("file.place", params) } + if file.request.purpose != "place" { + return self.open_file("file.place", params); + } + let placed = self.place_file(params)?; + self.import_delivered(rest, true); + Ok(placed) } /// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than @@ -158,7 +172,7 @@ impl DesignApp { let owns_dialog = self.ui.dialog.as_ref().is_some_and(|d| d.id == "pdfImport" && d.fields.get("async").and_then(Value::as_bool) == Some(true)); if !target_exists || !owns_dialog { - self.pending_pdf = None; + self.release_pending_pdf(); if !target_exists { if owns_dialog { self.ui.dialog = None; @@ -169,14 +183,23 @@ 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)); + // A browser delivers one Place's files together. Several go to the place cursor, even a + // PDF first with a frame selected, as a native batch's do. + let batch = files.iter().filter(|f| f.request.purpose == "place").count() > 1; + self.import_delivered(files, batch); + } + + /// Imports `files` in the order they came; placed ones are added to the place cursor when + /// `append`, and after the first that loads it. A multi-page PDF's Import Options pause a + /// Place: the files after it wait with it and carry on when it is confirmed, and Cancel ends + /// the batch. + fn import_delivered(&mut self, files: Vec, mut append: bool) { let cursor_image = |file: &ImportedFile| { file.request.purpose == "place" && !file.name.to_ascii_lowercase().ends_with(".pdf") && (is_cursor_image(&file.name) || designcraft_render::image_size(&file.bytes).is_some()) }; - // In the order they came: consecutive images for the same document load the place - // cursor together; after the first placement into it, the later ones are added to it. - let mut append = false; + // Consecutive images for the same document load the place cursor together. let mut files = files.into_iter().peekable(); while let Some(file) = files.next() { if file.request.purpose == "shortcuts" { @@ -220,11 +243,17 @@ impl DesignApp { continue; } let name = file.name.clone(); + let target = file.request.target; match self.import_file(file, append) { Ok(_) if placing => append = true, Ok(_) => {} Err(e) => self.status(format!("{name}: {e}")), } + if placing && self.pending_pdf.is_some() { + while let Some(next) = files.next_if(|n| n.request.purpose == "place" && n.request.target == target) { + self.pending_pdf_rest.push(next); + } + } } } @@ -918,6 +947,108 @@ 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 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(); + let b64 = designcraft_engine::cmd::base64_encode(&image()); + app.run("file.place", json!({"base64": b64, "name": "old.svg", "frame": frame})).unwrap(); + app.run("selection.set", json!({"ids": [frame]})).unwrap(); + assert!(app.session.loaded.is_none()); + frame + } + + fn frame_asset(app: &DesignApp, frame: u64) -> String { + let doc = &app.session.active().unwrap().doc; + let designcraft_doc::Content::Graphic(g) = &doc.item(designcraft_doc::ItemId(frame)).unwrap().content else { panic!("a graphic") }; + doc.assets[&g.asset].name.clone() + } + + /// A PDF first in a native batch joins the cursor like the rest; the selected frame keeps its + /// picture. A PDF alone still fills it. + #[test] + fn native_batch_starting_with_a_pdf_leaves_the_selected_frame() { + let dir = tempfile::tempdir().unwrap(); + let path = |n: &str| dir.path().join(n).to_string_lossy().to_string(); + std::fs::write(path("one.pdf"), pdf_pages(1)).unwrap(); + std::fs::write(path("a.svg"), image()).unwrap(); + let mut app = app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let frame = select_a_graphic_frame(&mut app); + app.place_paths(vec![path("one.pdf"), path("a.svg")]).unwrap(); + assert_eq!(loaded_names(&app), ["one.pdf", "a.svg"]); + assert_eq!(frame_asset(&app, frame), "old.svg"); + + let mut app = self::app(); + app.services.read = Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))); + let frame = select_a_graphic_frame(&mut app); + app.place_paths(vec![path("one.pdf")]).unwrap(); + assert!(app.session.loaded.is_none()); + assert_eq!(frame_asset(&app, frame), "one.pdf"); + } + + /// The same for files a host delivers (a browser's Place), with a PDF or an image first. + #[test] + fn inbox_batch_leaves_the_selected_frame() { + for names in [["one.pdf", "a.png"], ["a.png", "one.pdf"]] { + let mut app = app(); + let frame = select_a_graphic_frame(&mut app); + let request = app.import_request("place"); + for name in names { + let bytes = if name.ends_with(".pdf") { pdf_pages(1) } else { png(200) }; + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request: request.clone(), name: name.into(), bytes }); + } + app.drain_inbox(); + assert_eq!(loaded_names(&app), names); + assert_eq!(frame_asset(&app, frame), "old.svg"); + } + // One file alone still fills it. + let mut app = app(); + let frame = select_a_graphic_frame(&mut app); + let request = app.import_request("place"); + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: "one.pdf".into(), bytes: pdf_pages(1) }); + app.drain_inbox(); + assert_eq!(frame_asset(&app, frame), "one.pdf"); + } + + /// The files a host delivered after a multi-page PDF wait for its Import Options, through a + /// second such PDF too, keep their order and don't replace what the cursor holds. + #[test] + fn inbox_batch_continues_after_a_multi_page_pdf_is_confirmed() { + let mut app = app(); + let request = app.import_request("place"); + let files = + [("a.png", png(200)), ("three.pdf", pdf_pages(3)), ("b.png", png(90)), ("c.png", png(40)), ("two.pdf", pdf_pages(2)), ("d.png", png(20))]; + for (name, bytes) in files { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request: request.clone(), name: name.into(), bytes }); + } + app.drain_inbox(); + assert_eq!(loaded_names(&app), ["a.png"], "waiting for the first PDF's options"); + assert_eq!(app.pending_pdf_rest.len(), 4); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("pdfImport"), "the second PDF asks in its turn"); + assert_eq!(loaded_names(&app), ["a.png", "three.pdf", "b.png", "c.png"]); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(loaded_names(&app), ["a.png", "three.pdf", "b.png", "c.png", "two.pdf", "d.png"]); + assert!(app.pending_pdf.is_none() && app.pending_pdf_rest.is_empty()); + } + + /// Cancel ends a delivered batch, as a native one's does: what it loaded stays, the waiting + /// files are released. + #[test] + fn inbox_batch_ends_when_a_multi_page_pdf_is_cancelled() { + let mut app = app(); + let request = app.import_request("place"); + for (name, bytes) in [("a.png", png(200)), ("three.pdf", pdf_pages(3)), ("b.png", png(90))] { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request: request.clone(), name: name.into(), bytes }); + } + app.drain_inbox(); + assert_eq!(app.pending_pdf_rest.len(), 1); + dialogs::cancel(&mut app); + assert!(app.pending_pdf.is_none() && app.pending_pdf_rest.is_empty()); + assert_eq!(loaded_names(&app), ["a.png"]); + } + /// A multi-page PDF after images in a native batch asks which page, then queues after them. #[test] fn native_batch_keeps_its_images_when_a_multi_page_pdf_is_confirmed() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6775c7755..ef7b7b0a4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -131,6 +131,8 @@ struct ShotRequest { pub struct DesignApp { pending_pdf: Option, + /// The files of the batch after `pending_pdf`, which wait for its Import Options. + pending_pdf_rest: Vec, /// File and message dialogs: asked for during a frame, shown by the host at its end, answered /// on a later frame (see `app/file_dialogs.rs`). #[cfg(not(target_arch = "wasm32"))] diff --git a/docs/multi-image-placement.md b/docs/multi-image-placement.md index a99b4e144..c6b719584 100644 --- a/docs/multi-image-placement.md +++ b/docs/multi-image-placement.md @@ -9,7 +9,7 @@ Choose **File > Place** (Cmd+D on macOS) and select several images. They load in - Press Escape or select another tool to cancel the remaining placement. - Switching to, opening or closing a document also cancels it, and goes back to the Selection tool. -A single selected file retains the normal Place behavior, including filling a selected frame and PDF import options. 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. +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. Images here are PNG, JPEG, GIF, WebP, BMP, TIFF, SVG, PSD and EPS files.