From b34b9dadcc07010817c77733151487b02233ace3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 14:49:32 -0700 Subject: [PATCH] Artboards: Fit to Artwork Bounds and Fit to Selected Art resize the active artboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art ran artboard.fitToArt and artboard.fitToSelection without an index, so they resized artboard 1 whichever artboard was active. The Artboards panel menu's Fit to Artwork Bounds already passed the active artboard. The app now passes its active artboard as the index of the two commands when a call names none, as it passes the artboard to Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back. That covers the menu items, keyboard shortcuts, the command palette and MCP's run_command in remote mode. A batch passes its artboard as the index to their steps that name none, and a recorded action leaves the index out, so an action resizes the artboard that is active when it plays. The params texts of the two commands and of command.batch describe where the index comes from. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/batch.rs | 14 +++++++++---- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/layer.rs | 12 ++++++++++-- crates/engine/src/lib.rs | 13 ++++++++++--- crates/engine/src/tests_journal.rs | 27 ++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 10 ++++++++++ crates/ui-egui/src/panels/artboards.rs | 21 ++++++++++++++++++++ docs/mcp.md | 8 ++++++++ docs/target-app-parity.md | 1 + 9 files changed, 98 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 99d166676..9ff293dfb 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -55,7 +55,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { /// starts (one for all the steps in that document), so a step may open, switch or close documents. /// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets /// `artboard`, as the Artboards panel shows it in the document the step runs in: past that -/// document's last artboard, the last one. +/// document's last artboard, the last one. A step that resizes the active artboard +/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`. fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Value]) -> Result> { let mut results = Vec::with_capacity(cmds.len()); for c in cmds { @@ -67,13 +68,18 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Va s.begin_interaction(label)?; } let mut params = c.get("params").cloned().unwrap_or(json!({})); + let key = if crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str()) { + Some("artboard") + } else { + crate::FIT_ACTIVE_ARTBOARD.contains(&id.as_str()).then_some("index") + }; if let Some(a) = artboard - && crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str()) - && params.get("artboard").is_none() + && let Some(key) = key + && params.get(key).is_none() && let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1)) && let Some(o) = params.as_object_mut() { - o.insert("artboard".into(), json!(usize::try_from(a).map_or(last, |a| a.min(last)))); + o.insert(key.into(), json!(usize::try_from(a).map_or(last, |a| a.min(last)))); } let (v, noted) = s.execute_step(&id, ¶ms).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 1bcc49ced..ca6a0a5b9 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -142,7 +142,7 @@ pub fn specs() -> Vec { "Batch", [], None, - "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", + "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch ), diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index e8ec7d521..116fd9e0a 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -152,11 +152,19 @@ pub fn specs() -> Vec { "Fit to Artwork Bounds", ["Object", "Artboards"], None, - "{index?} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)", + "{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)", has_doc, artboard_fit_art ), - cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel), + cmd!( + "artboard.fitToSelection", + "Fit to Selected Art", + ["Object", "Artboards"], + None, + "{index?: n (0-based; the app passes the active artboard; default: the first)}", + has_selection, + artboard_fit_sel + ), cmd!( "layer.clippingMask.toggle", "Make/Release Clipping Mask", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 51f73a116..8cac922ea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -563,11 +563,16 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [ "select.allOnArtboard", ]; +/// The commands that resize the active artboard, which the app passes as their `index`: Fit to +/// Artwork Bounds and Fit to Selected Art (Object › Artboards). A `command.batch` given `artboard` +/// passes it on, as `index`, to each of their steps that names none. +pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"]; + /// `(command, param)` pairs the journal records only so a replay reproduces the original run /// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the -/// [`ON_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves them out -/// ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 11] = [ +/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves +/// them out ([`Session::journal_for_action`]). +const REPLAY_ONLY: [(&str, &str); 13] = [ ("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified"), @@ -577,6 +582,8 @@ const REPLAY_ONLY: [(&str, &str); 11] = [ ("object.align", "artboard"), ("object.distribute", "artboard"), ("object.distributeSpacing", "artboard"), + ("artboard.fitToArt", "index"), + ("artboard.fitToSelection", "index"), ("select.allOnArtboard", "artboard"), ("command.batch", "artboard"), ]; diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index ee311b662..f039110f5 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -128,6 +128,33 @@ fn a_batch_passes_its_artboard_to_each_step_when_it_runs() { assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "in the new document"); } +/// A batch passes its `artboard` to each Fit to Artwork Bounds and Fit to Selected Art step that +/// names no `index`, as their `index` (past the document's last artboard, the last one), and a +/// step's own `index` is kept. A recorded action leaves out the `index` the app passes them, so +/// the action resizes the artboard active when it plays. +#[test] +fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 30})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let (was, art) = (boards(&s), vectorcraft_geom::Rect::new(10.0, 10.0, 30.0, 40.0)); + let step = |command: &str, params: Value| json!({"command": command, "params": params}); + s.execute("command.batch", &json!({"artboard": 9, "commands": [step("artboard.fitToArt", json!({}))]})).unwrap(); + assert_eq!(boards(&s), [was[0], was[1], art], "artboard 3, the last one, for a batch's artboard past it"); + s.execute("command.batch", &json!({"artboard": 1, "commands": [step("artboard.fitToSelection", json!({}))]})).unwrap(); + assert_eq!(boards(&s), [was[0], art, art], "the batch's artboard 2"); + s.execute("command.batch", &json!({"artboard": 2, "commands": [step("artboard.fitToArt", json!({"index": 0}))]})).unwrap(); + assert_eq!(boards(&s)[0], art, "an index the step names is kept"); + let start = s.journal.len(); + s.execute("artboard.fitToArt", &json!({"index": 1})).unwrap(); + s.execute("artboard.fitToSelection", &json!({"index": 1})).unwrap(); + assert!(s.journal.since(start).iter().all(|(_, p)| p["index"] == json!(1)), "the journal keeps it: {:?}", s.journal.since(start)); + let action = s.journal_for_action(start); + assert_eq!(action, [("artboard.fitToArt".to_string(), json!({})), ("artboard.fitToSelection".to_string(), json!({}))]); +} + #[test] fn a_save_records_its_date_and_replays_it() { let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6c4f40830..abc9521d6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -686,6 +686,16 @@ impl VectorcraftApp { { p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n))); } + // Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art resize the active + // artboard, which the app passes as their `index`. + if vectorcraft_engine::FIT_ACTIVE_ARTBOARD.contains(&id) + && params.get("index").is_none() + && self.view().is_some() + && let Some(n) = self.session.active().map(|d| d.doc.artboards.len()) + && let Some(p) = params.as_object_mut() + { + p.insert("index".into(), serde_json::json!(panels::artboards::selected(self, n))); + } if id.starts_with("edit.paste") { if let Err(e) = self.adopt_system_clipboard() { self.ui.status = e.clone(); diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 710cf0bea..9f6b86782 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -312,6 +312,27 @@ mod tests { assert_eq!(picked(&mut app, json!({})), json!([a])); assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b])); } + + /// Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art resize the artboard made + /// active here, as the panel menu's Fit to Artwork Bounds does, and leave artboard 1 as it was; + /// an explicit index still wins. + #[test] + fn fit_to_artwork_bounds_and_selected_art_resize_the_active_artboard() { + for id in ["artboard.fitToArt", "artboard.fitToSelection"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 130, "y": 10, "width": 20, "height": 30})).unwrap(); + app.session.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let (first, art) = (boards(&app)[0], vectorcraft_geom::Rect::new(130.0, 10.0, 150.0, 40.0)); + select(&mut app, 1); + crate::menus::invoke(&mut app, id, json!({})); + assert_eq!(boards(&app), [first, art], "{id} resizes Artboard 2, the active one"); + crate::menus::invoke(&mut app, id, json!({"index": 0})); + assert_eq!(boards(&app), [art, art], "{id} with an index resizes that artboard"); + } + } + /// With the Artboard tool, Shift-click on a row adds its artboard to the tool's selection or /// takes it out (#1139), and every selected row is highlighted. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 428917488..dc4bb994a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1774,6 +1774,14 @@ Direct Selection or Group Selection tool inside an artboard makes that artboard tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. Choosing the Artboard tool (`tool.select {tool: "artboard"}`) starts it on the active artboard: its `active` option is that artboard's index. +`artboard.fitToArt {index?}` (Object › Artboards › Fit to Artwork Bounds) and `artboard.fitToSelection {index?}` +(Object › Artboards › Fit to Selected Art) resize artboard `index` (a 0-based index), or the first artboard when the +call gives none. The desktop app adds its active artboard as `index` to the calls of these two commands that it runs +without `index`, MCP's `run_command` calls in remote mode included. A batch passes its `artboard` on to each of their +steps that names no `index`, as the step's `index`. When the document a step runs in has no artboard at that index, +the step gets that document's last artboard. An action recorded in the Actions panel leaves `index` out of these +steps, and they resize the artboard that is active when the action plays. + ```json {"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","to":"artboard","artboard":1}}} {"name":"run_command","arguments":{"command":"object.distribute","params":{"horizontal":"left","to":"artboard","artboard":1}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1b378d0cb..de97a6210 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -495,6 +495,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. With Align to Artboard chosen, the Align panel's Distribute Objects and Distribute Spacing buttons use the active artboard (`object.distribute` and `object.distributeSpacing` with `to: "artboard"` and `artboard`). The first object's left or top edge goes to the artboard's left or top edge, and the last object's right or bottom edge goes to the artboard's right or bottom edge. Distribute Objects spaces the other objects evenly by its button's line, and two objects are enough. Distribute Spacing makes the gaps between the objects equal. With anchor points selected, the buttons space the points among themselves and do not use the artboard. When an action plays, its Distribute Objects and Distribute Spacing steps with Align to Artboard use the artboard that is active then. Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it. + Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art resize the active artboard, as the Artboards panel menu's Fit to Artwork Bounds does (`artboard.fitToArt {index}`, `artboard.fitToSelection {index}`). They used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then. The canvas draws the active artboard's edge in black, as the public [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html) page describes. It draws the other artboards' edges in gray. With several artboards selected by the Artboard tool, the canvas draws the black edge around the active one, which the tool shows with handles and its name. It used to draw artboard 1's edge in black whichever artboard was active. View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle). When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then.