From cdb0bbe315eccaa9ace79cf9c7c34e4352388062 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:55:58 -0700 Subject: [PATCH] Artboards: Duplicate Artboards, Copy and Cut take every selected artboard With several artboards selected, the Artboards panel menu's Duplicate Artboards copied only the active one, and Copy and Cut with the Artboard tool took only the tool's active artboard. artboard.duplicate, artboard.copy and artboard.cut now take `indices`, and without `index` or `indices` they act on the artboards the Artboard tool has selected. Duplicate Artboards passes the selected artboards and selects the copies. Duplicate and Cut are one undo step each, and Paste adds every copied artboard back, with its art, in one undo step. The copies keep their places relative to each other, together right of the last artboard. A cut fails when it names every artboard. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/clipboard.rs | 12 +-- crates/engine/src/cmd/edit.rs | 40 ++++++---- crates/engine/src/cmd/panelcmds.rs | 104 +++++++++++++++++-------- crates/engine/src/tests_panelcmds.rs | 88 ++++++++++++++++++++- crates/ui-egui/src/panels/artboards.rs | 61 ++++++++++++++- docs/mcp.md | 29 +++---- docs/target-app-parity.md | 1 + 7 files changed, 267 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 7fd8cc04f..3cad57ac5 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -80,22 +80,22 @@ pub struct Clipboard { pub para_styles: Vec, /// Brushes the objects' strokes use. pub brushes: Vec, - /// The artboard the objects were copied from (none when they lie on no artboard): Paste on All - /// Artboards keeps their offset to it. + /// The artboard the objects were copied from (none when they lie on no artboard; the first of + /// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it. pub source_artboard: Option, /// Per object: the name of the layer it was copied from (Paste Remembers Layers). pub source_layers: Vec>, /// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its /// own resources as they are now. pub source_doc: Option, - /// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting - /// adds a copy of it with the art. - pub artboard: Option, + /// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art. + /// Pasting adds a copy of each with the art. + pub artboards: Vec, } impl Clipboard { pub fn is_empty(&self) -> bool { - self.nodes.is_empty() && self.artboard.is_none() + self.nodes.is_empty() && self.artboards.is_empty() } /// Objects `roots` of `st`'s document with their resources, source artboard and layers. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7555d7809..943df0542 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", + "{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset) ), @@ -515,16 +515,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let st = s.doc()?; let same_doc = clip.source_doc == Some(st.uid); let parent = st.target_parent()?; - // A copied artboard comes back with its art: right of the last artboard, or where it was - // (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art. - let board = clip - .artboard - .as_ref() + // Copied artboards come back with their art: together right of the last artboard, as they lay, + // or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the + // art. + let boards = clip + .artboards + .iter() + .map(|a| a.rect) + .reduce(|a, b| a.union(b)) .filter(|_| mode != PasteMode::AllArtboards) - .map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } else { Vec2::ZERO })); + .map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO })); // Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art // always goes back into its layers. - let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); + let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); // Front/back: relative to the selection (top-most / bottom-most selected object); with // nothing selected, the top / bottom of the current layer. let anchor = match mode { @@ -535,7 +538,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho } _ => None, }; - let placements: Vec = match (mode, board) { + let placements: Vec = match (mode, boards) { (_, Some((_, dv))) => vec![Affine::translate(dv)], (PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") { Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())), @@ -548,9 +551,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // In place, in front, in back: onto the active artboard when the UI names it. _ => vec![onto_artboard(&st.doc, p, clip)], }; - let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| { + let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| { let imported = clip.import_into(d, choices, same_doc); - let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv)); + let artboards: Vec = boards + .into_iter() + .flat_map(|(all, dv)| all.iter().map(move |a| (a, dv))) + .map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv)) + .collect(); let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new(); // Objects pasted into each parent so far (keeps their order in front and back pastes). let mut placed: BTreeMap, usize> = BTreeMap::new(); @@ -585,15 +592,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are // right away (opening the file later would, but paste must not depend on a reopen). super::typecmd::refresh_bounds_of(d, &new_ids); - Ok((new_ids, imported, artboard)) + Ok((new_ids, imported, artboards)) })?; - // The Artboard tool takes the pasted artboard. - if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") { - s.set_tool_option("active", &json!(i)); + // The Artboard tool selects the pasted artboards, the first one active. + if !artboards.is_empty() && s.tool_id() == "artboard" { + s.set_tool_option("selected", &json!(artboards)); } Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), - "artboard": artboard, + "artboard": artboards.first(), + "artboards": artboards, "added": imported.added, "merged": imported.merged, "renamed": imported.renamed, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 9b55ff8b1..b70da25f6 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n (default: the Artboard tool's artboard), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} copy placed right of the last artboard, with copies of the art fully inside it when `art` (locked and hidden art only with prefs moveLockedWithArtboard) → {index, ids: the art's copies}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", has_doc, artboard_duplicate ), @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Copy Artboard", [], None, - "{index?, art?} (defaults as artboard.duplicate) put the artboard, and the art fully inside it when `art`, on the clipboard: edit.paste adds a copy right of the last artboard, edit.pasteInPlace/InFront/InBack where it was, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, art?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| artboard_copy(s, p, false) ), @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Cut Artboard", [], None, - "{index?, art?} artboard.copy, then delete the artboard and that art in one undo step (not the only artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| artboard_copy(s, p, true) ), @@ -68,16 +68,28 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result { ok() } -/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy -/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option. -/// An `index` that names no artboard is an error. -fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> { +/// The artboards (`index` or `indices`) and whether their art comes along (`art`) for the artboard +/// copy commands: by default the artboards the Artboard tool has selected (its active one when none +/// of them is in the document) and its Move/Copy Artwork with Artboard option. +fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { let t = s.tool_options_of("artboard"); - let i = match p.get("index") { - Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?, - None => t["active"].as_u64().unwrap_or(0) as usize, + let d = &s.doc()?.doc; + let indices = if p.get("index").is_some() || p.get("indices").is_some() { + artboard_indices(d, p, cmd)? + } else { + let mut picked: Vec = t["selected"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len())) + .collect(); + if picked.is_empty() { + picked.push(t["active"].as_u64().unwrap_or(0)); + } + artboard_indices(d, &json!({ "indices": picked }), cmd)? }; - Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))) + Ok((indices, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))) } /// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of @@ -87,8 +99,6 @@ pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into())) } -/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden -/// ones only with prefs moveLockedWithArtboard), in paint order. /// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`, /// in order and once each. pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result> { @@ -104,55 +114,87 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s Ok(indices) } +/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden +/// ones only with prefs moveLockedWithArtboard), in paint order. pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result> { let d = &s.doc()?.doc; let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] }) } +/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object +/// counted once, with the first of them it is on (artboards can overlap). +pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result)>> { + let mut seen = std::collections::HashSet::new(); + let mut arts = vec![]; + for &i in indices { + let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect(); + arts.push((i, art)); + } + Ok(arts) +} + /// How far a copy of `rect` moves to sit right of the last artboard (Duplicate Artboards, Paste). pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec2 { let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max); Vec2::new(right + 20.0 - rect.x0, 0.0) } +/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep +/// their places relative to each other, together right of the last artboard. fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { - let (i, art) = board_and_art(s, p, "artboard.duplicate")?; - let art = artboard_art(s, i, art)?; - let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() }; + let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?; + let arts = artboards_art(s, &indices, art)?; + let all: Vec = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); + let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() }; let (index, ids) = s.edit("Duplicate Artboard", |d, _| { - let dv = d.artboards.get(i).map(|a| beside_artboards(d, a.rect)).unwrap_or_default(); - copy_artboard(d, i, dv, &art, sc) + let dv = arts + .iter() + .filter_map(|(i, _)| d.artboards.get(*i)) + .map(|a| a.rect) + .reduce(|a, b| a.union(b)) + .map(|r| beside_artboards(d, r)) + .unwrap_or_default(); + let (mut index, mut ids) = (vec![], vec![]); + for (i, art) in &arts { + let (at, copies) = copy_artboard(d, *i, dv, art, sc)?; + index.push(at); + ids.extend(copies); + } + Ok((index, ids)) })?; - Ok(json!({"index": index, "ids": ids.iter().map(|i| i.0).collect::>()})) + Ok(json!({"index": index.first(), "indices": index, "ids": ids.iter().map(|i| i.0).collect::>()})) } -/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut -/// then deletes them. +/// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all); +/// a cut then deletes them. pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result { - let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; - let art = artboard_art(s, i, art)?; + let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; + let arts = artboards_art(s, &indices, art)?; let st = s.doc()?; - if cut && st.doc.artboards.len() <= 1 { + if cut && st.doc.artboards.len() <= indices.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); } - let board = st.doc.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?; - let clip = Clipboard { source_artboard: Some(board.rect), artboard: Some(board), ..Clipboard::copy(st, &art) }; + let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a)); + let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect(); + let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) }; let copied = clip.nodes.len(); if cut { s.edit("Cut Artboard", |d, sel| { - if i >= d.artboards.len() { + if indices.iter().any(|i| *i >= d.artboards.len()) { return Err(EngineError::Other("no such artboard".into())); } - // Its guides go with it. - let gone = d.artboards.remove(i).id; - d.retain_guides(sel, |_, g| g.artboard != Some(gone)); + // The last first, so the others keep their places; their guides go with them. + for &i in indices.iter().rev() { + let gone = d.artboards.remove(i).id; + d.retain_guides(sel, |_, g| g.artboard != Some(gone)); + } d.remove_many(&art)?; sel.prune(d); Ok(()) })?; if s.tool_id() == "artboard" { - s.set_tool_option("active", &json!(i.saturating_sub(1))); + s.set_tool_option("active", &json!(indices.first().map_or(0, |i| i.saturating_sub(1)))); } } s.clipboard = clip; diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 47066421a..f193fa13c 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -148,7 +148,93 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { } let st = s.doc().unwrap(); assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n)); - assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied"); + assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied"); +} + +/// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on +/// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard. +fn two_selected_artboards(s: &mut Session) -> [u64; 3] { + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 700, "width": 100, "height": 100, "name": "Small"})).unwrap(); + let sq = |s: &mut Session, x: u64, y: u64| { + s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap() + }; + let ids = [sq(s, 10, 10), sq(s, 1010, 10), sq(s, 10, 710)]; + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("artboard", ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([0, 1])); + ids +} + +/// The objects on the document's layers, in paint order. +fn objects(s: &Session) -> Vec { + s.doc().unwrap().doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id.0)).collect() +} + +/// Duplicate Artboards copies every selected artboard with its art, in one undo step (the public +/// Use the artboard context menu page). The copies keep their places relative to each other, right +/// of the last artboard. +#[test] +fn duplicate_artboards_copies_every_selected_artboard_in_one_step() { + let mut s = session(); + let [a, b, _] = two_selected_artboards(&mut s); + let before = (boards(&s), objects(&s)); + for p in [json!({"indices": [0, 1]}), json!({})] { + let out = s.execute("artboard.duplicate", &p).unwrap(); + assert_eq!((out["index"].clone(), out["indices"].clone()), (json!(3), json!([3, 4])), "{p}"); + let copies = ids(&out["ids"]); + assert_eq!(copies.len(), 2, "{p}"); + assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())], "{p}"); + assert_eq!((left(&s, copies[0]), left(&s, copies[1])), (1130.0, 2130.0), "{p}: each square on its artboard's copy"); + assert!(left(&s, a) == 10.0 && left(&s, b) == 1010.0, "{p}: the originals stay"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((boards(&s), objects(&s)), before, "{p}: one undo step"); + } +} + +/// With the Artboard tool, Copy takes every selected artboard and the art on each (the public Cut, +/// copy, and paste artboards page). Paste adds a copy of each with its art in one undo step, +/// together right of the last artboard; Paste in Place puts them where they were. The tool then +/// selects the pasted artboards. +#[test] +fn artboard_tool_copy_and_paste_bring_every_selected_artboard() { + let mut s = session(); + two_selected_artboards(&mut s); + let before = (boards(&s), objects(&s)); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["copied"], 2, "the squares on artboards 1 and 2"); + let out = s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!((out["artboard"].clone(), out["artboards"].clone()), (json!(3), json!([3, 4]))); + assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())]); + let pasted = ids(&out["ids"]); + assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (1130.0, 2130.0)); + assert_eq!(s.tool_options()["selected"], json!([3, 4]), "the tool selects the pasted artboards"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((boards(&s), objects(&s)), before, "one undo step"); + let out = s.execute("edit.pasteInPlace", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!((d.artboards[3].rect, d.artboards[4].rect), (d.artboards[0].rect, d.artboards[1].rect)); + let pasted = ids(&out["ids"]); + assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (10.0, 1010.0)); +} + +/// With the Artboard tool, Cut takes every selected artboard and the art on each away in one undo +/// step, and leaves the other artboards and their art; it fails when every artboard is selected. +#[test] +fn artboard_tool_cut_removes_every_selected_artboard() { + let mut s = session(); + let [a, b, c] = two_selected_artboards(&mut s); + let before = (boards(&s), objects(&s)); + assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["copied"], 2); + assert_eq!((boards(&s), objects(&s)), (vec![(0.0, "Small".into())], vec![c])); + s.execute("edit.pasteInPlace", &json!({})).unwrap(); + assert_eq!(boards(&s), vec![(0.0, "Small".into()), (0.0, "Artboard 1".into()), (1000.0, "Artboard 2".into())]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((boards(&s), objects(&s)), before, "the cut is one undo step"); + assert!(objects(&s).contains(&a) && objects(&s).contains(&b)); + s.set_tool_option("selected", &json!([0, 1, 2])); + assert!(s.execute("edit.cut", &json!({})).is_err(), "every artboard"); + assert_eq!(boards(&s), before.0); } /// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index cd6df62b7..e4ac88865 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -119,6 +119,23 @@ fn duplicate(app: &mut VectorcraftApp, i: usize) { } } +/// Duplicate the selected artboards ([`picked`]) in one undo step (`artboard.duplicate {indices}`) +/// and select the copies, the active artboard's copy active. +pub(crate) fn duplicate_picked(app: &mut VectorcraftApp) { + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + let all = picked(app, n); + let active = selected(app, n); + let Ok(r) = app.run("artboard.duplicate", json!({ "indices": all })) else { return }; + let copies: Vec = + r["indices"].as_array().into_iter().flatten().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).collect(); + let Some(first) = all.iter().position(|i| *i == active).and_then(|k| copies.get(k)).or(copies.first()).copied() else { return }; + select(app, first); + if app.session.tool_id() == "artboard" { + let rest = copies.iter().copied().filter(|c| *c != first); + app.session.set_tool_option("selected", &json!(std::iter::once(first).chain(rest).collect::>())); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let abs: Vec = app.session.active().map(|d| d.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); @@ -241,7 +258,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("artboard.new", json!({})).ok(); } if menu_item(ui, tl!("Duplicate Artboards"), n > 0, false) { - duplicate(app, sel); + duplicate_picked(app); } if menu_item(ui, tl!("Delete Artboards"), n > 1, false) { delete_selected(app); @@ -549,6 +566,48 @@ mod tests { assert_eq!(app.session.tool_id(), tool); } + /// With two artboards selected with the Artboard tool, the panel menu's Duplicate Artboards + /// copies both, with their art, in one undo step, and the tool then selects the copies, the + /// active artboard's copy active. + #[test] + fn the_panel_menus_duplicate_artboards_copies_every_selected_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + for x in [400, 800] { + app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap(); + } + for x in [10, 410] { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap(); + } + app.select_tool("artboard"); + select(&mut app, 1); + app.session.set_tool_option("selected", &json!([1, 0])); + let ctx = egui::Context::default(); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| menu(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + let item = frame(&mut app, vec![]) + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text().trim_start() == "Duplicate Artboards" => Some(t.visual_bounding_rect().center()), + _ => None, + }) + .unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(item), button(true), button(false)]); + let d = &app.session.active().unwrap().doc; + let names: Vec<&str> = d.artboards.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 1 copy", "Artboard 2 copy"]); + assert_eq!(d.layers[0].children().unwrap().len(), 4, "the squares and their copies"); + assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (4, json!([4, 3])), "Artboard 2's copy is active"); + app.run("edit.undo", json!({})).unwrap(); + let d = &app.session.active().unwrap().doc; + assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (3, 2), "one undo step"); + } + /// Choosing the Artboard tool starts it on the active artboard, the one the panel highlights: /// Artboard Options shows that artboard, the pointer moving over another artboard leaves it /// active, and Delete removes it. Shift+O while the tool is in use keeps the artboards it has diff --git a/docs/mcp.md b/docs/mcp.md index c9e30c1dd..c3d250d2b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2350,19 +2350,22 @@ on top / at the bottom of the current layer. `edit.pasteOnAllArtboards` keeps th copied from. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`) pastes objects back into the layers they came from, by name, making missing ones. -Artboards copy with their art. `artboard.copy {index?, art?}` puts an artboard on the clipboard together with the -art fully inside it (`art` defaults to the Artboard tool's `moveArt` option, Move/Copy Artwork with Artboard; locked -and hidden art only with prefs `moveLockedWithArtboard`); with the Artboard tool chosen, `edit.copy` does this for -the tool's artboard and `edit.cut` runs `artboard.cut`, which also deletes them (never the only artboard). Then -`edit.paste` adds a copy of the artboard right of the last one, with its art in the layers it came from, in one undo -step (`edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack` put it where it was; `edit.pasteOnAllArtboards` -pastes only the art), in this document or another; the result's `artboard` is the new artboard's index. -`artboard.duplicate {index?, art?}` (Window › Artboards › Duplicate Artboards, or a row dragged onto New Artboard) -does the same in one step → `{index, ids}`, and `artboard.move {copy: true, moveArt: true}` is the Artboard tool's -Alt-drag. These two commands also move the pattern tiles of the copies when `patterns` is true (default: prefs -`transformPatternTiles`); `edit.paste` leaves the tiles of a pasted artboard's art where they were. -`artboard.copy`, `artboard.cut` and `artboard.duplicate` take the Artboard tool's artboard when the call names no -`index`. Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the +Artboards copy with their art. `artboard.copy {index? | indices?, art?}` puts artboards on the clipboard together +with the art fully inside each (`index` and `indices` default to the artboards the Artboard tool has selected; `art` +defaults to the tool's `moveArt` option, Move/Copy Artwork with Artboard; locked and hidden art only with prefs +`moveLockedWithArtboard`); with the Artboard tool chosen, `edit.copy` does this for the tool's selected artboards and +`edit.cut` runs `artboard.cut`, which also deletes them (one artboard is always left: a cut of every artboard fails). +Then `edit.paste` adds a copy of each artboard, together right of the last one and placed as they lay, with their art +in the layers it came from, in one undo step (`edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack` put +them where they were; `edit.pasteOnAllArtboards` pastes only the art), in this document or another; the result's +`artboards` lists the new artboards' indices and `artboard` is the first of them, and the Artboard tool, when chosen, +selects them. `artboard.duplicate {index? | indices?, art?}` does the same in one step → `{index, indices, ids}`. +Window › Artboards › Duplicate Artboards passes the selected artboards (those the Artboard tool has selected, else the +active one) as `indices`, and a row dragged onto New Artboard passes its artboard as `index`. +`artboard.move {copy: true, moveArt: true}` is the Artboard tool's Alt-drag. `artboard.duplicate` and `artboard.move` +also move the pattern tiles of the copies when `patterns` is true (default: prefs `transformPatternTiles`); +`edit.paste` leaves the tiles of a pasted artboard's art where they were. +Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the 0-based index of an artboard (past the last one, negative, fractional, a string or null), and the document and the clipboard stay as they were. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b5ef3723f..6aa604036 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -535,6 +535,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 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. 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). View › Rotate View to Selection turns the view to the angle of the selected objects' bounding box, so the box is square to the window (`view.rotateToSelection`). 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.