Align: Align to Artboard uses the active artboard

Align to Artboard aligned the selection to the artboard under its
center, or to the first artboard when no artboard was under the
center. A press with a selection tool inside an artboard makes that
artboard the active one (#693). With the Selection tool, a
Shift-click on an empty spot inside an artboard also makes it active
and keeps the selection. Align to Artboard did not use the active
artboard.

object.align takes artboard, a 0-based artboard index, and uses it
when its to param is "artboard". The app passes its active artboard,
the one the Artboards panel highlights, to each object.align it runs
that names none, as it does for Paste in Place, Paste in Front, Paste
in Back (#693) and Select › All on Active Artboard (#1006). MCP calls
in remote mode also get it. Without artboard (headless MCP and the
CLI), or with an index the document doesn't have, object.align aligns
to the artboard under the center of the selection, or to the first
artboard when no artboard is under the center.

command.batch takes artboard too, and the app passes it the active
artboard. The batch passes it on to each step of these five commands
that names none, when the step runs. When the document a step runs in
has no artboard at that index (for example, after an earlier step
deleted the active artboard when it was the last one), the step gets
that document's last artboard. A step in a document that an earlier
step opened, created or switched to gets the same index, not that
document's own active artboard.

With Align to Selection, one selected object aligned from the Align
panel, the Control bar or the Properties panel is aligned to the
active artboard too, unless it is the key object.

Recorded actions leave out the artboard that the app passes to these
commands and to the batch. The Actions panel plays an action as a
batch. Its Align to Artboard, All on Active Artboard and in-place
paste steps that name no artboard use the artboard that is active
when it plays, and the default Center on Artboard action now centers
on the active artboard. In-place paste steps recorded since #693 and
before this change still paste onto the artboard that was active when
they were recorded.

Tests cover object.align with and without artboard on three artboards;
a Shift-click that makes an artboard active, after which Align to
Artboard aligns to that artboard but keeps an artboard the call names;
a batch that passes its artboard to each step when the step runs,
after a step deletes the last artboard and in a new document; recorded
actions that leave the artboard out; and recorded Align to Artboard
and Paste in Place steps and the default Center on Artboard action,
which play on the artboard that is active when they play.

Docs: docs/mcp.md (Align to Artboard), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo 2026-10-09 17:18:50 -07:00 committed by bflatastic
parent e02c2dc899
commit 8e4158b364
12 changed files with 254 additions and 13 deletions

View File

@ -8,12 +8,14 @@ use crate::{DocState, EngineError};
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
// The active artboard the app passes, for the steps that act on it.
let artboard = p.get("artboard").and_then(Value::as_u64);
// What an error rolls back besides the documents: session-level state a step may change
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
s.batch_stash = Some(vec![]);
let r = run_steps(s, &label, &mut cmds);
let r = run_steps(s, &label, artboard, &mut cmds);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => results,
@ -43,7 +45,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
/// Run the steps → their results. Each runs inside an interaction of the document active when it
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Value>> {
/// 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.
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
let mut results = Vec::with_capacity(cmds.len());
for c in cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string();
@ -53,7 +58,15 @@ fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Val
if s.active().is_some() {
s.begin_interaction(label)?;
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = c.get("params").cloned().unwrap_or(json!({}));
if let Some(a) = artboard
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
&& params.get("artboard").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))));
}
let (v, noted) =
s.execute_step(&id, &params).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
// The step's journal entry records what it took from the preferences or the clock (a

View File

@ -123,7 +123,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
screens::export_for_screens
),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} 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),
cmd!(
query "command.batch",
"Batch",
[],
None,
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align 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",
has_doc,
batch::batch
),
cmd!(
query "document.formats",
"File Formats",

View File

@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}",
has_selection,
align
),
@ -965,8 +965,12 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
match align_to(s, p) {
Some("artboard") => {
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
// it, or with an index the document doesn't have, it uses the artboard under the center
// of the selection, or the first one when no artboard is there.
let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i));
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
Ok(named.or(st.doc.artboards.get(i)).map(|a| a.rect).unwrap_or(b))
}
Some("key") => {
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;

View File

@ -19,7 +19,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
all
),
cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard),
cmd!(
"select.allOnArtboard",
"All on Active Artboard",
["Select"],
Some("Cmd+Alt+A"),
"{artboard?: index (0-based; the app passes the active one; default: the first)}",
has_doc,
all_on_artboard
),
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),

View File

@ -369,10 +369,27 @@ impl DocState {
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
pub const MAX_COORD: f64 = 4.0e6;
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, and All on
/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their
/// steps that names none.
pub const ON_ACTIVE_ARTBOARD: [&str; 5] = ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack", "object.align", "select.allOnArtboard"];
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`); an action leaves them out
/// (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); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")];
const REPLAY_ONLY: [(&str, &str); 9] = [
("file.new", "created"),
("document.save", "modified"),
("file.saveAs", "modified"),
("edit.pasteInPlace", "artboard"),
("edit.pasteInFront", "artboard"),
("edit.pasteInBack", "artboard"),
("object.align", "artboard"),
("select.allOnArtboard", "artboard"),
("command.batch", "artboard"),
];
/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps').
fn strip_replay_only(id: &str, p: &mut Value) {

View File

@ -391,6 +391,37 @@ fn align_left() {
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0);
}
/// Align to Artboard aligns to artboard `artboard` (the app passes the active one). Without it, or
/// with one the document doesn't have, it aligns to the artboard under the center of the
/// selection, or to the first artboard when no artboard is there. Align to Selection does not use
/// `artboard`.
#[test]
fn align_to_artboard_uses_the_artboard_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let a = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 10.0, 20.0, 20.0);
let b = rect(&mut s, boards[1].x0 + 50.0, boards[1].y0 + 40.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 2})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (boards[2].x0, boards[2].x0), "the left edges are on artboard 2's");
for (p, left) in [
(json!({"horizontal": "left", "to": "artboard"}), boards[1].x0),
(json!({"horizontal": "left", "to": "artboard", "artboard": 9}), boards[1].x0),
(json!({"horizontal": "left", "to": "selection", "artboard": 2}), boards[1].x0 + 10.0),
] {
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.align", &p).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (left, left), "{p}");
}
// A selection whose center is on no artboard aligns to the first.
let c = rect(&mut s, boards[1].x0 + 10.0, boards[1].y1 + 50.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert_eq!(x0(&s, c), boards[0].x0, "off every artboard: the first");
}
/// #541: with the Selection tool, a click on one object of the selection makes it the key object:
/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click
/// on the key again lets it go.

View File

@ -66,6 +66,65 @@ fn recorded_actions_leave_out_replay_only_values() {
assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec());
}
/// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to
/// All on Active Artboard and to a batch (its active one) is journaled and left out of recorded
/// actions, so a played action uses the artboard active when it plays.
#[test]
fn recorded_actions_leave_out_the_artboard_the_app_passes() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2, "created": null})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let start = s.journal.len();
let steps = [
("edit.pasteInPlace", json!({"artboard": 1})),
("edit.pasteInFront", json!({"artboard": 1})),
("edit.pasteInBack", json!({"artboard": 1})),
("select.allOnArtboard", json!({"artboard": 1})),
("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})),
("command.batch", json!({"artboard": 1, "commands": [{"command": "select.allOnArtboard", "params": {}}]})),
];
for (id, p) in &steps {
s.execute(id, p).unwrap();
}
assert!(s.journal[start..].iter().all(|(_, p)| p["artboard"] == json!(1)), "the journal keeps it: {:?}", &s.journal[start..]);
let action = s.journal_for_action(start);
assert_eq!(action.len(), steps.len());
for ((id, mut p), (step, recorded)) in steps.into_iter().zip(action) {
p.as_object_mut().unwrap().remove("artboard");
assert_eq!((step.as_str(), recorded), (id, p));
}
}
/// A batch passes its `artboard` (the app's active one) to each step that acts on the active
/// artboard and names none, when the step runs: once a step deletes the last artboard, the new
/// last one, and in a new document with fewer artboards, its last one. A step that names an
/// artboard keeps it.
#[test]
fn a_batch_passes_its_artboard_to_each_step_when_it_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let ids: Vec<u64> = boards
.iter()
.map(|b| {
s.execute("shape.rectangle", &json!({"x": b.x0 + 10.0, "y": b.y0 + 10.0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
})
.collect();
let picked = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
let step = |command: &str, params: Value| json!({"command": command, "params": params});
let all_on_artboard = step("select.allOnArtboard", json!({}));
s.execute("command.batch", &json!({"artboard": 2, "commands": [all_on_artboard]})).unwrap();
assert_eq!(picked(&s), [ids[2]], "the batch's artboard 3");
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("select.allOnArtboard", json!({"artboard": 0}))]})).unwrap();
assert_eq!(picked(&s), [ids[0]], "an artboard the step names is kept");
s.execute("command.batch", &json!({"artboard": 2, "commands": [step("artboard.delete", json!({"index": 2})), all_on_artboard]})).unwrap();
assert_eq!(picked(&s), [ids[1]], "artboard 2, the last one once artboard 3 is deleted");
let new = step("file.new", json!({"width": 100, "height": 100}));
s.execute("command.batch", &json!({"artboard": 2, "commands": [new, all_on_artboard]})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "in the new document");
}
#[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();

View File

@ -905,7 +905,8 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
v.artboard = i;
}
// A press with a selection tool inside an artboard makes it the active one (#693), as the
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it.
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
// Artboard aligns to it, and All on Active Artboard selects on it.
if ev.kind == PointerKind::Down
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
@ -2192,6 +2193,37 @@ mod tests {
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
}
/// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active
/// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the
/// call names another artboard.
#[test]
fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let mut rect = |x: f64, y: f64| {
let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20}));
vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap())
};
let ids = [rect(10.0, 15.0), rect(50.0, 40.0)];
app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.select_tool("selection");
let view = app.view_info();
let shift = Mods { shift: true, ..Default::default() };
for kind in [PointerKind::Down, PointerKind::Up] {
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);
}
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");
let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0);
app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's");
app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap();
assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept");
}
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
#[test]

View File

@ -580,9 +580,9 @@ impl VectorcraftApp {
return r;
}
let mut params = params;
// Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto
// or on the active artboard, the view's.
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard")
// Paste in place, in front, in back (#693), Select › All on Active Artboard (#1006) and Align to
// Artboard: onto, on or to the active artboard, the view's. A batch passes it on to those steps.
if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch")
&& params.get("artboard").is_none()
&& self.view().is_some()
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len())

View File

@ -143,6 +143,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn defaults_are_valid_commands() {
@ -154,4 +155,46 @@ mod tests {
}
}
}
/// Actions recorded with Align to Artboard and Paste in Place, and the default Center on
/// Artboard, play on the artboard active when they play, not on the one active when they were
/// recorded.
#[test]
fn actions_play_on_the_artboard_active_when_they_play() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let r = app.run("shape.rectangle", json!({"x": boards[1].x0 + 10.0, "y": boards[1].y0 + 15.0, "width": 20, "height": 20})).unwrap();
let rect = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap());
let bounds = |app: &VectorcraftApp, ids: &[vectorcraft_doc::NodeId]| app.session.active().unwrap().doc.bounds_of(ids, false).unwrap();
// Each recorded as the panel records an action, with artboard 0 active.
let record = |app: &mut VectorcraftApp, name: &str, id: &str, params: Value| {
let start = app.session.journal.len();
app.run(id, params).unwrap();
let steps = app.session.journal_for_action(start);
app.ui.action_sets[0].actions.push(Action { name: name.into(), steps });
app.ui.action_sets[0].actions.len() - 1
};
app.run("edit.copy", json!({})).unwrap();
let align = record(&mut app, "Align Left", "object.align", json!({"horizontal": "left", "to": "artboard"}));
assert_eq!(bounds(&app, &[rect]).x0, boards[0].x0, "aligned to artboard 0 while recording");
let paste = record(&mut app, "Paste in Place", "edit.pasteInPlace", json!({}));
app.view_mut().unwrap().artboard = 2;
app.run("select.set", json!({"ids": [rect.0]})).unwrap();
play(&mut app, 0, align).unwrap();
assert_eq!(bounds(&app, &[rect]).x0, boards[2].x0, "aligned to artboard 2, active when the action plays");
let ids: Vec<vectorcraft_doc::NodeId> = play(&mut app, 0, paste).unwrap()["results"][0]["ids"]
.as_array()
.unwrap()
.iter()
.map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap()))
.collect();
let b = bounds(&app, &ids);
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0), "pasted onto artboard 2, where it was on artboard 1");
app.view_mut().unwrap().artboard = 1;
app.run("select.set", json!({"ids": [rect.0]})).unwrap();
let center = app.ui.action_sets[0].actions.iter().position(|a| a.name == "Center on Artboard").unwrap();
play(&mut app, 0, center).unwrap();
assert_eq!(bounds(&app, &[rect]).center(), boards[1].center(), "centered on artboard 1, the active one");
}
}

View File

@ -1468,6 +1468,31 @@ one undo step.
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"makeSidesEqual":true}}}
```
## Align to Artboard
`object.align {to: "artboard", artboard?}` aligns the selected objects to artboard `artboard` (a 0-based index).
Without `artboard`, or with an index the document doesn't have, it aligns them to the artboard under the center of the
selection, or to the first artboard when no artboard is under the center. With `to: "selection"` or `"key"`,
`object.align` does not use `artboard`. `select.allOnArtboard {artboard?}` (Select › All on Active Artboard) selects
the objects on artboard `artboard`, or on the first artboard when the call gives none; an index the document doesn't
have is an error.
The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align` and
`select.allOnArtboard` calls it runs without `artboard`, as it does for `edit.pasteInPlace`, `edit.pasteInFront` and
`edit.pasteInBack`, and to each `command.batch`. A batch passes its `artboard` on to each of these steps that names
none, when the step runs. When the document a step runs in has no artboard at that index (for example, after an
earlier step deleted the active artboard when it was the last one), the step gets that document's last artboard. In
remote mode, MCP's `run_command` calls and each step of the `command_batch` tool get the active artboard too. The
Actions panel plays an action as a `command.batch`. An action recorded in the Actions panel leaves the artboard out,
and its steps of these commands get the artboard that is active when the action plays. A press with the Selection,
Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection
tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection.
```json
{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","to":"artboard","artboard":1}}}
{"name":"run_command","arguments":{"command":"select.allOnArtboard","params":{"artboard":1}}}
```
## Use Preview Bounds
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel

View File

@ -418,6 +418,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
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 aligned with Align to Selection is aligned to the active artboard too, unless it is 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.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).