Edit: Paste in Place, in Front and in Back put a copy on each selected artboard

With the Artboard tool selecting several artboards, Paste in Place, Paste
in Front and Paste in Back put a copy of the copied art on each, where it
was on the artboard it was copied from, in one undo step. The three
commands take `artboards: [index...]`; the app passes the Artboard tool's
selection there, and without it the engine uses that selection while the
tool is chosen and records it in the journal. Art copied from no artboard
keeps its place on the first artboard and its offset to it on the others.
Copied artboards still paste once, where they were.

An `artboard` or `artboards` value that is not an artboard index of the
document, a repeated index or an empty list is now an error, and nothing
is pasted. An out-of-range `artboard` used to paste the art where it was.
A batch keeps the `artboards` a paste step names, and a paste step that
names neither pastes on the Artboard tool's selection while that tool is
chosen with several artboards selected.

Help page: https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html
This commit is contained in:
dotCooCoo
2026-11-11 17:00:44 -08:00
parent 7f3a046b18
commit 9998f020ac
9 changed files with 319 additions and 34 deletions
+84 -13
View File
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste in Front",
["Edit"],
Some("Cmd+F"),
"{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
"{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one), artboards?: [index…] (a copy on each of these artboards, placed as on `artboard`, the copies in artboard order; `artboard` wins when both are given; default while the Artboard tool is chosen with several artboards selected: those; art copied from no artboard keeps its place on the first and its offset to it on the others; an index the document lacks, a repeated one or an empty list is an error)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Front)
),
@@ -193,7 +193,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste in Back",
["Edit"],
Some("Cmd+B"),
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
"{swatchConflict?, artboard?: index, artboards?: [index…] (both as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Back)
),
@@ -202,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste in Place",
["Edit"],
Some("Cmd+Shift+V"),
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}",
"{swatchConflict?, artboard?: index, artboards?: [index…] (both as edit.pasteInFront)} paste where the objects were copied (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}",
has_clipboard,
|s, p| paste(s, p, PasteMode::InPlace)
),
@@ -1246,15 +1246,79 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_
paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices)
}
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
/// artboard), where they were.
fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
let target = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| doc.artboards.get(i));
match (target, clip.source_artboard) {
(Some(t), Some(src)) => Affine::translate(t.rect.origin() - src.origin()),
_ => Affine::IDENTITY,
/// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard`
/// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several
/// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same
/// ones); `None` when none is named. A named index that is not an artboard of the document, a
/// repeated one or an empty list is an error.
fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result<Option<Vec<usize>>> {
let n = s.doc()?.doc.artboards.len();
let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n);
let single = match p.get("artboard") {
Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?),
None => None,
};
let listed = match p.get("artboards") {
Some(v) => {
let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?;
let mut all = list
.iter()
.map(one)
.collect::<Option<Vec<usize>>>()
.ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?;
all.sort_unstable();
if all.windows(2).any(|w| w[0] == w[1]) {
return Err(bad(cmd, "`artboards` names an artboard twice"));
}
Some(all)
}
None => None,
};
if let Some(i) = single {
return Ok(Some(vec![i]));
}
if listed.is_some() {
return Ok(listed);
}
let picked = artboard_tool_picks(s);
if picked.is_empty() {
return Ok(None);
}
s.note_journal("artboards", json!(picked));
Ok(Some(picked))
}
/// The artboards of the active document the Artboard tool has selected, in artboard order, while
/// that tool is chosen with two or more of them selected; otherwise none. Indices the document
/// does not have are left out.
pub(crate) fn artboard_tool_picks(s: &Session) -> Vec<usize> {
if s.tool_id() != "artboard" {
return Vec::new();
}
let n = s.active().map_or(0, |d| d.doc.artboards.len());
let mut picked: Vec<usize> = s.tool_options_of("artboard")["selected"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok()))
.filter(|i| *i < n)
.collect();
picked.sort_unstable();
picked.dedup();
if picked.len() < 2 {
picked.clear();
}
picked
}
/// Paste in Place, in Front or in Back onto artboards `targets`: one copy on each, where the objects
/// were on the artboard they were copied from. A copy from no artboard keeps its place on the first
/// target and its offset to that one on the others; without targets, the objects go where they were.
fn onto_artboards(doc: &Document, targets: Option<&[usize]>, clip: &Clipboard) -> Vec<Affine> {
let rects: Vec<_> = targets.unwrap_or_default().iter().filter_map(|i| doc.artboards.get(*i)).map(|a| a.rect.origin()).collect();
let Some(first) = rects.first().copied() else { return vec![Affine::IDENTITY] };
let src = clip.source_artboard.map_or(first, |r| r.origin());
rects.iter().map(|o| Affine::translate(*o - src)).collect()
}
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
@@ -1268,6 +1332,12 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
),
_ => None,
};
let targets = match mode {
PasteMode::Front => paste_targets(s, p, "edit.pasteInFront")?,
PasteMode::Back => paste_targets(s, p, "edit.pasteInBack")?,
PasteMode::InPlace => paste_targets(s, p, "edit.pasteInPlace")?,
_ => None,
};
let st = s.doc()?;
let same_doc = clip.source_doc == Some(st.uid);
let parent = st.target_parent_for_path()?;
@@ -1321,8 +1391,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default();
st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect()
}
// In place, in front, in back: onto the active artboard when the UI names it.
_ => vec![onto_artboard(&st.doc, p, clip)],
// In place, in front, in back: onto the active artboard when the UI names it, or onto each
// artboard the Artboard tool has selected.
_ => onto_artboards(&st.doc, targets.as_deref(), clip),
};
// Guides alone don't follow a plain paste's offset or the view centre: they stay where they
// were. With objects they move as the objects do.
+8 -1
View File
@@ -76,7 +76,9 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
/// 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. A step that resizes the active artboard
/// document's last artboard, the last one; a Paste in Place, in Front or in Back step that names
/// `artboards` keeps them, and one that names neither, while the Artboard tool is chosen with
/// several artboards selected, pastes on those. A step that resizes the active artboard
/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`.
/// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them.
fn run_steps(
@@ -104,9 +106,14 @@ fn run_steps(
} else {
crate::FIT_ACTIVE_ARTBOARD.contains(&id.as_str()).then_some("index")
};
// A paste step that names its `artboards` keeps them; one that names neither key, while the
// Artboard tool is chosen with several artboards selected, pastes on those.
let names_artboards = id.starts_with("edit.pasteIn")
&& (params.get("artboards").is_some() || (params.get("artboard").is_none() && !crate::cmd::edit::artboard_tool_picks(s).is_empty()));
if let Some(a) = artboard
&& let Some(key) = key
&& params.get(key).is_none()
&& !names_artboards
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
&& let Some(o) = params.as_object_mut()
{
+5 -2
View File
@@ -25,10 +25,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the
/// Artboard tool had selected for its commands; the artboards and art Cut and Copy took with that
/// Artboard tool had selected for its commands and for Paste in Place, in Front and in Back; the artboards and art Cut and Copy took with that
/// tool chosen; the view's center the app passes to Edit Symbol); an action leaves them out
/// ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 26] = [
const REPLAY_ONLY: [(&str, &str); 29] = [
("file.new", "created"),
("edit.newDocumentFromState", "created"),
("document.save", "modified"),
@@ -36,6 +36,9 @@ const REPLAY_ONLY: [(&str, &str); 26] = [
("edit.pasteInPlace", "artboard"),
("edit.pasteInFront", "artboard"),
("edit.pasteInBack", "artboard"),
("edit.pasteInPlace", "artboards"),
("edit.pasteInFront", "artboards"),
("edit.pasteInBack", "artboards"),
("object.align", "artboard"),
("object.distribute", "artboard"),
("object.distributeSpacing", "artboard"),
+124 -3
View File
@@ -710,12 +710,133 @@ fn in_place_pastes_go_onto_the_artboard_named() {
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 15.0), "{cmd}");
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} without one");
// An artboard that isn't there: where they were.
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 9})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} on no artboard");
// An artboard that isn't there, or a value that isn't an index: an error, and nothing pasted.
let before = doc(&s).clone();
for bad in [json!(9), json!(-1), json!(1.5), json!("1")] {
assert!(s.execute(cmd, &json!({"artboard": bad})).is_err(), "{cmd} with artboard {bad}");
}
assert_eq!(doc(&s), &before, "{cmd}");
}
}
/// Paste in Place, in Front and in Back onto several artboards (the Artboard tool's selection, which
/// the app passes as `artboards`): a copy on each, where the objects were on the artboard they were
/// copied from, the copies in artboard order and in their own paint order, all selected, in one undo
/// step.
#[test]
fn in_place_pastes_put_a_copy_on_each_artboard_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 4})).unwrap();
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
let a = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 15.0, 20.0, 20.0);
let b = rect(&mut s, boards[1].x0 + 50.0, boards[1].y0 + 5.0, 10.0, 10.0);
copy(&mut s, &[a, b]);
for cmd in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
let undo = s.doc().unwrap().history.undo.len();
let before = doc(&s).clone();
let ids = ids_of(&s.execute(cmd, &json!({"artboards": [3, 0]})).unwrap());
assert_eq!(ids.len(), 4, "{cmd}: two objects on each of two artboards");
for (pair, board) in ids.chunks(2).zip([boards[0], boards[3]]) {
assert_eq!(doc(&s).bounds_of(&pair[..1], false).unwrap().origin(), Point::new(board.x0 + 10.0, board.y0 + 15.0), "{cmd}");
assert_eq!(doc(&s).bounds_of(&pair[1..], false).unwrap().origin(), Point::new(board.x0 + 50.0, board.y0 + 5.0), "{cmd}");
}
let layer: Vec<NodeId> = doc(&s).layers[0].children().into_iter().flatten().map(|n| n.id).collect();
let at: Vec<usize> = ids.iter().map(|id| layer.iter().position(|n| n == id).unwrap()).collect();
assert!(at.windows(2).all(|w| w[0] < w[1]), "{cmd}: in paint order, artboard by artboard: {at:?}");
assert_eq!(s.doc().unwrap().selection.in_paint_order(doc(&s)), ids, "{cmd}: the copies are selected");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd}: one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc(&s), &before, "{cmd}: undo takes every copy away");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(doc(&s).layers[0].children().into_iter().flatten().count(), layer.len(), "{cmd}: redo");
// `artboard` wins over `artboards`.
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2, "artboards": [0, 3]})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 5.0), "{cmd}");
// A list that is empty, names an artboard the document lacks or names one twice, or isn't a
// list: an error, and nothing pasted.
let before = doc(&s).clone();
for bad in [json!([]), json!([0, 4]), json!([1, 1]), json!([0, -1]), json!(2), json!("0,1")] {
assert!(s.execute(cmd, &json!({"artboards": bad})).is_err(), "{cmd} with artboards {bad}");
}
assert!(s.execute(cmd, &json!({"artboard": 1, "artboards": [7]})).is_err(), "{cmd}: a bad list with `artboard`");
assert_eq!(doc(&s), &before, "{cmd}");
}
}
/// With the Artboard tool chosen and several artboards selected, Paste in Place, in Front and in
/// Back put a copy on each without naming them; the journal notes them, so a replay pastes on the
/// same artboards, and a recorded action leaves them out. With one selected, or another tool, the
/// objects go where they were.
#[test]
fn in_place_pastes_follow_the_artboard_tool_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
let r = rect(&mut s, boards[0].x0 + 10.0, boards[0].y0 + 15.0, 20.0, 20.0);
copy(&mut s, &[r]);
let origins = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| doc(s).bounds_of(&[*id], false).unwrap().origin()).collect::<Vec<_>>();
let on = |b: &[usize]| b.iter().map(|i| Point::new(boards[*i].x0 + 10.0, boards[*i].y0 + 15.0)).collect::<Vec<_>>();
s.select_tool("artboard", ViewInfo::default()).unwrap();
for cmd in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
s.set_tool_option("selected", &json!([2, 1]));
let start = s.journal.len();
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
assert_eq!(origins(&s, &ids), on(&[1, 2]), "{cmd}");
assert_eq!(s.journal.since(start).last().map(|(_, p)| p["artboards"].clone()), Some(json!([1, 2])), "{cmd}: journaled");
assert!(s.journal_for_action(start).iter().all(|(_, p)| p.get("artboards").is_none()), "{cmd}: not in an action");
// A stale index in the tool's selection is passed over.
s.set_tool_option("selected", &json!([0, 9]));
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
assert_eq!(origins(&s, &ids), on(&[0]), "{cmd}: one left");
s.set_tool_option("selected", &json!([1]));
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
assert_eq!(origins(&s, &ids), on(&[0]), "{cmd}: one selected, where it was");
}
s.set_tool_option("selected", &json!([1, 2]));
s.select_tool("selection", ViewInfo::default()).unwrap();
let ids = ids_of(&s.execute("edit.pasteInPlace", &json!({})).unwrap());
assert_eq!(origins(&s, &ids), on(&[0]), "another tool: where it was");
}
/// Art copied from no artboard onto several: it keeps its place on the first and its offset to the
/// first on the others. Guides alone go on each artboard named.
#[test]
fn in_place_pastes_onto_several_artboards_of_art_off_them_and_guides() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
let r = rect(&mut s, boards[0].x0 - 100.0, boards[0].y0 + 15.0, 20.0, 20.0);
copy(&mut s, &[r]);
assert_eq!(s.clipboard.source_artboard, None);
let ids = ids_of(&s.execute("edit.pasteInPlace", &json!({"artboards": [1, 2]})).unwrap());
let at: Vec<Point> = ids.iter().map(|id| doc(&s).bounds_of(&[*id], false).unwrap().origin()).collect();
let first = Point::new(boards[0].x0 - 100.0, boards[0].y0 + 15.0);
assert_eq!(at, [first, first + (boards[2].origin() - boards[1].origin())]);
// Guides alone, copied from artboard 1: one on each artboard named, 30 pt into it.
s.execute("guide.add", &json!({"vertical": true, "pos": boards[0].x0 + 30.0})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let r = s.execute("edit.pasteInFront", &json!({"artboards": [1, 2]})).unwrap();
assert_eq!(r["guides"], 2);
let mut guides: Vec<f64> = doc(&s).ruler_guides().iter().map(|g| g.pos).collect();
guides.sort_by(f64::total_cmp);
assert_eq!(guides, [boards[0].x0 + 30.0, boards[1].x0 + 30.0, boards[2].x0 + 30.0]);
}
/// Artboards on the clipboard come back once, where they were, whichever artboards are named.
#[test]
fn in_place_pastes_of_copied_artboards_ignore_the_artboards_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
rect(&mut s, 10.0, 10.0, 20.0, 20.0);
s.execute("artboard.copy", &json!({"index": 0, "art": true})).unwrap();
let r = s.execute("edit.pasteInPlace", &json!({"artboards": [1, 2]})).unwrap();
assert_eq!(r["artboards"], json!([3]));
assert_eq!(ids_of(&r).len(), 1);
assert_eq!(doc(&s).artboards[3].rect, doc(&s).artboards[0].rect);
}
/// Art copied from beside the artboards goes back where it was, whichever artboard is active (#1924).
#[test]
fn in_place_pastes_of_art_off_the_artboards_stay_where_it_was() {
+33
View File
@@ -128,6 +128,39 @@ 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 Paste in Place, in Front or in Back step that names `artboards` keeps them inside a batch
/// given `artboard`, and pastes a copy on each; one that names neither takes the batch's artboard,
/// unless the Artboard tool is chosen with several artboards selected: then it pastes on those.
#[test]
fn a_batch_keeps_the_artboards_a_paste_step_names() {
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();
s.execute("shape.rectangle", &json!({"x": boards[0].x0 + 10.0, "y": boards[0].y0 + 10.0, "width": 20, "height": 20})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let step = |command: &str, params: Value| json!({"command": command, "params": params});
for id in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
let origins = |r: &Value, s: &Session| {
let ids = r["results"][0]["ids"].as_array().unwrap().iter().map(|i| vectorcraft_doc::NodeId(i.as_u64().unwrap())).collect::<Vec<_>>();
ids.iter().map(|i| s.doc().unwrap().doc.bounds_of(&[*i], false).unwrap().origin().x).collect::<Vec<_>>()
};
let r = s.execute("command.batch", &json!({"artboard": 1, "commands": [step(id, json!({"artboards": [0, 2]}))]})).unwrap();
assert_eq!(origins(&r, &s), [boards[0].x0 + 10.0, boards[2].x0 + 10.0], "{id}");
let r = s.execute("command.batch", &json!({"artboard": 1, "commands": [step(id, json!({}))]})).unwrap();
assert_eq!(origins(&r, &s), [boards[1].x0 + 10.0], "{id}: the batch's artboard");
}
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
s.set_tool_option("selected", &json!([2, 0]));
for id in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
let r = s.execute("command.batch", &json!({"artboard": 1, "commands": [step(id, json!({}))]})).unwrap();
let ids = r["results"][0]["ids"].as_array().unwrap().iter().map(|i| vectorcraft_doc::NodeId(i.as_u64().unwrap())).collect::<Vec<_>>();
let xs: Vec<f64> = ids.iter().map(|i| s.doc().unwrap().doc.bounds_of(&[*i], false).unwrap().origin().x).collect();
assert_eq!(xs, [boards[0].x0 + 10.0, boards[2].x0 + 10.0], "{id}: the Artboard tool's selection");
let r = s.execute("command.batch", &json!({"artboard": 1, "commands": [step(id, json!({"artboard": 1}))]})).unwrap();
assert_eq!(r["results"][0]["ids"].as_array().map(Vec::len), Some(1), "{id}: a step's own `artboard` wins");
}
}
/// 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` or `indices` the app
+25 -12
View File
@@ -109,6 +109,30 @@ impl VectorcraftApp {
}
}
/// Paste in place, in front and in back (#693), Select › All on Active Artboard (#1006), and Align,
/// Distribute Objects and Distribute Spacing with Align to Artboard, and Edit Symbol from the Symbols
/// panel act on the active artboard, the view's, which the app adds to command `id`'s `params` as
/// `artboard`. A batch passes it on to those steps. While the Artboard tool has several artboards
/// selected, the paste commands get those as `artboards` instead, and paste a copy on each; in a
/// document without artboards they get neither.
fn add_active_artboard(&self, id: &str, params: &mut Value) {
let paste = matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack");
if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch")
&& params.get("artboard").is_none()
&& !(paste && params.get("artboards").is_some())
&& self.view().is_some()
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len()).filter(|n| *n > 0 || !paste)
&& let Some(p) = params.as_object_mut()
{
let picked = panels::artboards::picked(self, n);
if paste && picked.len() > 1 {
p.insert("artboards".into(), serde_json::json!(picked));
} else {
p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n)));
}
}
}
/// [`Self::run`] it, inside what asks for file dialogs.
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
@@ -129,18 +153,7 @@ impl VectorcraftApp {
return r;
}
let mut params = params;
// Paste in place, in front and in back (#693), Select › All on Active Artboard (#1006), and Align,
// Distribute Objects and Distribute Spacing with Align to Artboard, and Edit Symbol from the Symbols
// panel act on the active artboard, the view's, which the app passes as `artboard`. 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())
&& let Some(p) = params.as_object_mut()
{
p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n)));
}
self.add_active_artboard(id, &mut params);
// Edit Symbol from the Symbols panel in a document without artboards centers the symbol's
// art in the view, whose center the app passes as `viewCenter`, to a batch's symbol.edit
// steps too.
@@ -382,6 +382,32 @@ fn paste_and_cut_with_the_artboard_tool_make_its_artboard_active() {
assert_eq!(active(&app), (1, json!(1)), "Cut Artboard 3: Artboard 2");
}
/// With the Artboard tool selecting several artboards, Paste in Place, in Front and in Back put a
/// copy of the copied object on each, where it was on the artboard it was copied from; with one
/// selected, on the active one.
#[test]
fn in_place_pastes_put_a_copy_on_each_artboard_the_artboard_tool_selects() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap();
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.origin()).collect();
let r = app.run("shape.rectangle", json!({"x": boards[0].x + 10.0, "y": boards[0].y + 20.0, "width": 5, "height": 5})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [r]})).unwrap();
app.run("edit.copy", json!({})).unwrap();
app.select_tool("artboard");
let offset = vectorcraft_geom::Vec2::new(10.0, 20.0);
let pasted = |app: &mut VectorcraftApp, id: &str| -> Vec<vectorcraft_geom::Point> {
let r = app.run(id, json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
r["ids"].as_array().unwrap().iter().map(|i| doc.bounds_of(&[vectorcraft_doc::NodeId(i.as_u64().unwrap())], false).unwrap().origin()).collect()
};
for id in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
app.run("tool.setOption", json!({"key": "selected", "value": [2, 0]})).unwrap();
assert_eq!(pasted(&mut app, id), [boards[0] + offset, boards[2] + offset], "{id}");
app.run("tool.setOption", json!({"key": "selected", "value": [1]})).unwrap();
assert_eq!(pasted(&mut app, id), [boards[1] + offset], "{id}: the active artboard");
}
}
/// `tool.setOption` with the Artboard tool's `selected` or `active` makes the artboard the tool
/// then has active the active one.
#[test]
+13 -3
View File
@@ -4422,9 +4422,12 @@ page describes saving the list from the Keyboard Shortcuts dialog as a text file
The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align`,
`object.distribute`, `object.distributeSpacing` 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
for `edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack` (while the Artboard tool has several artboards
selected, it adds those to these three as `artboards` instead; in a document without artboards it adds neither), 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
none, when the step runs; a paste step that names `artboards` keeps them, and one that names neither, while the
Artboard tool is chosen with several artboards selected, pastes a copy on each of those. 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`, omitting steps whose playback checkmarks are
@@ -5099,7 +5102,14 @@ its resources as they are now and raises no conflict.
Placement: `edit.paste {center}` centres the objects on a point (the app passes the view centre), else offsets them
by `dx`/`dy` (the Paste Offset preference). With nothing selected, `edit.pasteInFront` / `edit.pasteInBack` put them
on top / at the bottom of the current layer. `edit.pasteOnAllArtboards` keeps their offset to the artboard they were
copied from. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`)
copied from. `edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack` take `artboard` (one artboard) or
`artboards: [index…]` (a copy on each, placed where the objects were on the artboard they were copied from, the
copies in artboard order); `artboard` wins when both are given. Without either, while the Artboard tool is chosen with
several artboards selected, they paste a copy on each of those, and the journal records them as `artboards`; a
recorded action leaves both out. Art copied from no artboard keeps its place on the first artboard named and its
offset to that one on the others. An index the document does not have, a repeated index, a value that is not an index
or an empty list fails, and nothing is pasted. Copied artboards come back once, where they were, whichever artboards
are named. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`)
pastes objects back into the layers they came from, by name, making missing ones. Drawing modes (`view.drawMode`) apply
too: in Draw Inside, `edit.paste`, `edit.pasteInPlace` and `edit.pasteOnAllArtboards` put the objects inside the
object drawn into (its clip group, made on the first paste), whatever Paste Remembers Layers says; in Draw Behind,
+1
View File
@@ -1064,6 +1064,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It 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.
Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document.
The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only.
With the Artboard tool selecting several artboards, Paste in Place, Paste in Front and Paste in Back put a copy of the copied art on each, where it was on the artboard it was copied from, in one undo step, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes (`artboards` on the three commands). Art copied from no artboard keeps its place on the first selected artboard and its offset to it on the others, and an `artboard` or `artboards` index the document does not have is an error; these rules are VectorCraft's own. The three commands used to paste one copy, on the active artboard, and an `artboard` index the document did not have pasted the art where it was.
A replay of the journal repeats a Cut or Copy made with the Artboard tool on the artboards it took (`edit.cut` and `edit.copy` record `selectedArtboards` and `art`), whichever tool is chosen in the new session and whatever is selected there. A recorded action follows the tool chosen when it plays. The replay used to act on the objects selected then, or stop with nothing selected. This rule is VectorCraft's own.
With the Artboard tool, the canvas context menu lists the entries of the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page in its order: Cut, Copy, Paste at the pointer, Undo, Redo, New Artboard, Duplicate Artboards, Rename (`artboard.rename`), Delete Artboards, Delete Empty Artboards, Select All Artboards (`artboard.selectAll`), Lock Artboards and Unlock Artboards, Rearrange, Fit to Artwork Bounds, Switch Orientation (`artboard.switchOrientation`), Artboard Options and Export. A right-click on an artboard that is not selected selects it first.
The Artboards panel menu's New Artboard adds the artboard in one undo step and makes it the active one, as the panel's button, the Control bar and Properties do; with the Artboard tool it is then the only one selected. This is VectorCraft's own rule. The menu item used to leave the active artboard where it was.