Merge pr-2302 (place cursor queue) into multi-place

This commit is contained in:
aiongg committed 2026-11-14 05:49:14 +00:00
commit 2ee301bd4b
21 files changed
+1339 -72

No files matched your search

+1
View File
@@ -433,6 +433,7 @@ Newest first.
- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change.
- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change.
- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change.
- **2026-10-30** · 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).
+11 -4
View File
@@ -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();
});
})),
+294 -21
View File
@@ -109,8 +109,11 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
(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()?;
@@ -124,8 +127,8 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
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));
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());
@@ -254,36 +257,105 @@ 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<Value> {
let (bytes, name, link, link_fingerprint) = read_linked_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, link_fingerprint, data: designcraft_doc::AssetData::embedded(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, link_fingerprint) = read_linked_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, link_fingerprint, data: designcraft_doc::AssetData::embedded(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")));
}
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, link_fingerprint) = read_linked_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, link_fingerprint, data: designcraft_doc::AssetData::embedded(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<Value> {
/// 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()))
}
/// 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<Value> {
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, _| {
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<Value> {
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.unload_place_cursor();
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<Value> {
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");
@@ -341,11 +413,121 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
*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<usize> {
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<Vec<Rect>> {
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<ItemId> {
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<Value> {
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::<Vec<_>>(), "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, bytes: &[u8], format: Option<craft_file_formats::Format>) -> Result<Value> {
@@ -415,6 +597,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()];
@@ -1014,3 +1210,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", &params).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);
}
}
+18 -3
View File
@@ -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};
@@ -137,17 +137,32 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+6
View File
@@ -427,6 +427,8 @@ pub struct Session {
pub ui_requests: Vec<UiRequest>,
/// Graphic loaded in the place cursor.
pub loaded: Option<LoadedGraphic>,
/// Remaining graphics, in source-selection order, after `loaded`.
pub loaded_queue: std::collections::VecDeque<LoadedGraphic>,
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
@@ -490,6 +492,7 @@ impl Session {
link_checks: Default::default(),
ui_requests: vec![],
loaded: None,
loaded_queue: Default::default(),
transforms: Default::default(),
text_drag: false,
text_unit: None,
@@ -565,6 +568,7 @@ impl Session {
}
self.reset_find_scope();
self.stop_data_preview();
self.unload_place_cursor();
self.active = Some(i);
}
}
@@ -581,6 +585,7 @@ impl Session {
}
}
self.stop_data_preview();
self.unload_place_cursor();
self.docs.push(d);
self.active = Some(self.docs.len() - 1);
self.docs.len() - 1
@@ -603,6 +608,7 @@ impl Session {
recovery::discard_document(self, i, recovery_dir.as_deref());
}
if i < self.docs.len() {
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)) };
+144
View File
@@ -2388,6 +2388,150 @@ fn every_line_beside_a_wrap_stays_within_its_segment() {
assert!(beside > 20, "lines on both sides of the object: {beside}");
}
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, &params).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());
}
}
/// 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]
+11
View File
@@ -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"
@@ -86,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),
+83 -11
View File
@@ -112,6 +112,8 @@ impl Tool for ZoomTool {
pub struct PlaceGun {
start: Option<Point>,
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,20 @@ 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. 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, "gutter": PLACE_GRID_GUTTER}),
)];
}
// 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 +168,51 @@ 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<Action> {
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<crate::Overlay> {
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);
// A grid draws its cells; one too small for them falls back to the single frame, as the
// 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() {
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 {
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 +229,6 @@ Y: {pct}"
},
]
}
fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> {
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 +239,34 @@ Y: {pct}"
}
}
/// 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
/// `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<designcraft_geom::Rect> {
let (columns, rows) = (columns.clamp(1, 100), rows.clamp(1, 100));
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![];
}
(0..rows)
.flat_map(|row| {
(0..columns).map(move |column| {
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()
}
/// 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;
+85
View File
@@ -2509,3 +2509,88 @@ fn gap_tool_follows_snapping_settings() {
assert!(t2.overlays(&cx2).is_empty(), "expected no guide overlay without snap match");
t2.pointer(&cx2, &PointerEvent::new(PointerKind::Up, q0.x - 60.0, q0.y));
}
/// 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());
}
/// 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:?}");
}
/// 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 && 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;
let expected: Vec<Overlay> = [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:?}");
}
+1
View File
@@ -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"))]
+40
View File
@@ -16,6 +16,10 @@ use super::{DesignApp, Value, json};
#[cfg(not(target_arch = "wasm32"))]
pub(crate) type Then = Box<dyn FnOnce(&mut DesignApp, String) -> Result<Value, String>>;
/// 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<dyn FnOnce(&mut DesignApp, Vec<String>) -> Result<Value, String>>;
/// 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 (or a folder): run `then` with the file or folder 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<String>, 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<String>) -> Result<Value, String> + 'static,
) -> Result<Value, String> {
#[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 for a folder, the dialog titled `title` (File › Export Layouts and Versions). `then`
/// runs with its path on a later frame; Cancel runs nothing.
#[track_caller]
@@ -257,6 +288,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<String> = 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());
+524 -27
View File
@@ -16,29 +16,92 @@ 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<Value, String> {
// Image Import Options: a multi-page PDF asks which page.
if purpose == "place" && craft_file_formats::detect(&path, &[]) == Some(craft_file_formats::Format::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);
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}));
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<Value, String> {
// Image Import Options: a multi-page PDF asks which page.
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);
if pages > 1 {
self.ui.dialog =
Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target, "append": append})));
return Ok(Value::Null);
}
}
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. 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<String>) -> 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();
loop {
// `None` at the end loads the images left.
let path = paths.next();
if let Some(p) = &path
&& is_cursor_image(p)
{
images.push(json!({"path": p}));
continue;
}
if path.is_none() && images.is_empty() {
return Ok(());
}
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);
}
let Some(p) = path else { return Ok(()) };
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::<Vec<_>>()));
return 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<Value>, append: bool) -> Result<Value, String> {
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`,
/// `character` or empty for both. A native dialog's answer opens the dialog that chooses
/// among the file's styles; in a browser they all load when the file arrives.
@@ -77,14 +140,28 @@ impl DesignApp {
}
pub(crate) fn cancel_pdf_import(&mut self) {
self.pending_pdf = None;
self.release_pending_pdf();
}
pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result<Value, String> {
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. 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<Value, String> {
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});
if file.request.purpose == "place" { self.place_file(params) } else { self.open_file("file.place", params) }
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" {
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
@@ -95,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;
@@ -106,11 +183,58 @@ 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 {
// 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<ImportedFile>, 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())
};
// 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" {
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 +243,22 @@ impl DesignApp {
continue;
}
let name = file.name.clone();
if let Err(e) = self.import_file(file) {
self.status(format!("{name}: {e}"));
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);
}
}
}
}
fn import_file(&mut self, file: ImportedFile) -> Result<Value, String> {
/// Imports `file`; a placed one is added to the place cursor when `append`.
fn import_file(&mut self, file: ImportedFile, append: bool) -> Result<Value, String> {
let format = craft_file_formats::detect(&file.name, &file.bytes);
if let Some(kind) = file.request.purpose.strip_prefix("styles") {
self.activate_import_target(&file.request)?;
@@ -150,8 +283,10 @@ impl DesignApp {
if place && craft_file_formats::detect(&file.name, &file.bytes) == Some(craft_file_formats::Format::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);
}
@@ -160,7 +295,7 @@ impl DesignApp {
if format == Some(craft_file_formats::Format::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 {
@@ -277,7 +412,7 @@ impl DesignApp {
pub fn open_named_file(&mut self, cmd: &str, params: Value, file: String) -> Result<Value, String> {
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();
}
@@ -318,6 +453,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 {
@@ -675,7 +818,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());
}
@@ -770,6 +913,360 @@ 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<u8> {
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<String> {
let doc = &app.session.doc().unwrap().doc;
app.session.loaded.iter().chain(app.session.loaded_queue.iter()).map(|g| doc.assets[&g.asset].name.clone()).collect()
}
#[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");
}
/// 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() {
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"]);
}
/// 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() {
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();
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"]);
}
/// 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]
fn place_dialog_with_several_files_loads_them_all() {
let dir = tempfile::tempdir().unwrap();
let paths: Vec<std::path::PathBuf> = ["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<String> = 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)]
+2
View File
@@ -20,6 +20,7 @@ mod guides;
mod ime;
mod input;
mod page_decor;
mod place_cursor;
mod render;
mod rulers;
mod scroll;
@@ -426,6 +427,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.
+34
View File
@@ -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<usize>) -> Option<String> {
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);
}
}
+16 -2
View File
@@ -1819,6 +1819,9 @@ fn text_frame_targets(app: &DesignApp, d: &Dialog) -> Result<Option<Value>, &'st
Ok(Some(ids))
}
/// 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 {
@@ -5059,12 +5062,23 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
},
};
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 }}))
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<String> = 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 };
+2
View File
@@ -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…", "დოკუმენტის პარამეტრები…"),
+20
View File
@@ -175,6 +175,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…", "ドキュメント設定…", "文档设置…"]),
+2
View File
@@ -138,6 +138,8 @@ struct ShotRequest {
pub struct DesignApp {
pending_pdf: Option<ImportedFile>,
/// The files of the batch after `pending_pdf`, which wait for its Import Options.
pending_pdf_rest: Vec<ImportedFile>,
/// 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"))]
+12 -3
View File
@@ -141,9 +141,18 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
.unwrap_or_default();
app.open_named_file("file.openRecent", json!({"index": index}), name)
}
"app.placeDialog" => 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::<Option<Vec<_>>>() {
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.
+6 -1
View File
@@ -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"),
+27
View File
@@ -0,0 +1,27 @@
# 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. 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. 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.
## 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.