From a0bd9bbbadd5e3b0823308efbf666f9c5d1e9736 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 11:04:54 +0700 Subject: [PATCH] Artboards: duplicate, copy, cut, move and rearrange journal whether locked and hidden art goes Squashed from #5994 onto main. --- crates/engine/src/cmd/edit.rs | 15 +++-- crates/engine/src/cmd/menucmds.rs | 6 +- crates/engine/src/cmd/panelcmds.rs | 41 +++++++++----- crates/engine/src/cmd/xform.rs | 5 +- crates/engine/src/session/execute.rs | 13 +++-- crates/engine/src/tests/journal.rs | 29 +++++++--- crates/engine/src/tests/panelcmds.rs | 85 ++++++++++++++++++++++++++++ crates/engine/src/tests/xform.rs | 25 ++++++++ docs/mcp.md | 18 +++--- docs/target-app-parity.md | 1 + 10 files changed, 196 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f4b2d21d2..529d9513c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -139,7 +139,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", has_selection_guides_or_artboard_tool, cut ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -701,10 +701,15 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { Ok((direct, roots)) } -fn copy(s: &mut Session, _: &Value) -> Result { +/// The params edit.copy and edit.cut pass on to `artboard.copy` and `artboard.cut`. +fn artboard_clipboard_params(p: &Value) -> Value { + p.get("lockedAndHidden").map_or_else(|| json!({}), |v| json!({ "lockedAndHidden": v })) +} + +fn copy(s: &mut Session, p: &Value) -> Result { // The Artboard tool copies its artboard (and the art on it). if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), false); + return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; @@ -744,7 +749,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), true); + return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 95f8c3e8f..3c16fba87 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "Rearrange All Artboards…", ["Object", "Artboards"], None, - "{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}", + "{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with lockedAndHidden), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}", has_doc, rearrange_artboards ), @@ -1274,7 +1274,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5); let move_art = switch_or(p, "moveArtwork", true, CMD)?; check_switches(p, &["patterns"], CMD)?; - let locked_and_hidden = s.prefs.move_locked_with_artboard; + let locked_and_hidden = super::panelcmds::locked_and_hidden(s, p, move_art, CMD)?; let d = &s.doc()?.doc; let rects: Vec = d.artboards.iter().map(|a| a.rect).collect(); let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect(); @@ -1291,7 +1291,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { // With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the // artboard its center is on, with symbol instances measured by their art as // `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked - // artboard, moves only when prefs moveLockedWithArtboard is on. + // artboard, moves only with `locked_and_hidden`. fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) { for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) { if c.is_layer() { objects(c, all, out) } else { out.push(c) } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2e3ae4301..6eeafd7c3 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; -use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or}; use crate::{Clipboard, EngineError}; pub fn specs() -> Vec { @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), 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}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} 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`, 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? | indices?, selectedArtboards?, art?} (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}", + "{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} (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| { boards_and_art_noted(s, p, "artboard.copy")?; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Cut Artboard", [], None, - "{index? | indices?, selectedArtboards?, 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, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} 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, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless lockedAndHidden); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| { boards_and_art_noted(s, p, "artboard.cut")?; @@ -253,14 +253,24 @@ pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) -> } } +/// Does locked and hidden art go with an artboard (`lockedAndHidden` param, else prefs +/// moveLockedWithArtboard)? Noted in the journal when `art` goes along, so a replay takes the same +/// art whatever the preference is then. +pub(crate) fn locked_and_hidden(s: &mut Session, p: &Value, art: bool, cmd: &str) -> Result { + let all = switch_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard, cmd)?; + if art { + s.note_journal("lockedAndHidden", json!(all)); + } + Ok(all) +} + /// 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. With `moving` (a move or a cut), -/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard -/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it. -pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result> { +/// ones only when `all`), in paint order. With `moving` (a move or a cut), the art also on another +/// locked artboard stays unless `all` ([`vectorcraft_doc::Document::art_moving_with`]); a copy +/// takes it. +pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, all: bool, moving: 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()))?; - let all = s.prefs.move_locked_with_artboard; Ok(match (art, moving) { (false, _) => vec![], (true, false) => d.art_on_artboard(rect, all), @@ -270,11 +280,11 @@ pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Re /// 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, moving: bool) -> Result)>> { +pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, all: bool, moving: bool) -> Result)>> { let mut seen = std::collections::HashSet::new(); let mut arts = vec![]; for &i in indices { - let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect(); + let art = artboard_art(s, i, art, all, moving)?.into_iter().filter(|id| seen.insert(*id)).collect(); arts.push((i, art)); } Ok(arts) @@ -291,7 +301,8 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; - let arts = artboards_art(s, &indices, art, false)?; + let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?; + let arts = artboards_art(s, &indices, art, locked_art, false)?; 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, _| { @@ -316,8 +327,10 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { /// 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 (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; - let arts = artboards_art(s, &indices, art, cut)?; + let cmd = if cut { "artboard.cut" } else { "artboard.copy" }; + let (indices, art) = boards_and_art(s, p, cmd)?; + let locked_art = locked_and_hidden(s, p, art, cmd)?; + let arts = artboards_art(s, &indices, art, locked_art, cut)?; let st = s.doc()?; if cut && st.doc.artboards.len() <= indices.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index bac865c71..998109f3d 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Move Artboard", [], None, - "{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with prefs moveLockedWithArtboard), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}", + "{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with lockedAndHidden), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}", has_doc, artboard_move ), @@ -384,13 +384,14 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result { let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let move_art = bool_or(p, "moveArt", false); let copy = bool_or(p, "copy", false); + let locked_art = super::panelcmds::locked_and_hidden(s, p, move_art, "artboard.move")?; if !copy { super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?; } // Each artboard's art, an object counted once (artboards can overlap). let mut arts: Vec<(usize, Vec)> = vec![]; for &i in &indices { - let art = super::panelcmds::artboard_art(s, i, move_art, !copy)?; + let art = super::panelcmds::artboard_art(s, i, move_art, locked_art, !copy)?; let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect(); arts.push((i, art)); } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 8d8568a34..21822583d 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -162,8 +162,8 @@ impl Session { } /// Record `key: value` in the running top-level command's journal entry (or its interaction's - /// preview, or its step in a batch) unless its params give `key`: a value it resolved from the - /// preferences or the clock. + /// preview, or its step in a batch) unless its params give `key` (a null gives nothing): a value + /// it resolved from the preferences or the clock. pub fn note_journal(&mut self, key: &str, value: Value) { if self.depth == self.note_depth { self.journal_note.insert(key.to_string(), value); @@ -231,9 +231,14 @@ impl Session { pub(super) fn noted(&mut self, params: &Value) -> Value { let mut note = std::mem::take(&mut self.journal_note); match params { - // The params' own values win. + // The params' own values win, except a null: it reads as a key left out, so the + // noted value is the one the command resolved. Value::Object(m) if !note.is_empty() => { - note.extend(m.clone()); + for (k, v) in m { + if !v.is_null() || !note.contains_key(k) { + note.insert(k.clone(), v.clone()); + } + } Value::Object(note) } Value::Null if !note.is_empty() => Value::Object(note), diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 26fcc0835..6d4e1a084 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -268,9 +268,24 @@ fn artboard_commands_record_the_selected_artboards_for_a_replay_and_not_for_an_a for (id, params, recorded, action) in [ ("artboard.switchOrientation", json!({}), json!({"selectedArtboards": [1], "scaleArt": false}), json!({"scaleArt": false})), ("artboard.rename", json!({"name": "Back"}), json!({"name": "Back", "selectedArtboards": [1]}), json!({"name": "Back"})), - ("artboard.duplicate", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), - ("artboard.cut", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), - ("artboard.copy", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), + ( + "artboard.duplicate", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), + ( + "artboard.cut", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), + ( + "artboard.copy", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), ] { let mut s = Session::new(); let start = second_artboard_selected(&mut s); @@ -303,14 +318,14 @@ fn artboard_commands_keep_a_named_artboard_and_note_a_batch_steps_artboards() { s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap(); assert_eq!(s.journal.last().unwrap().1, json!({"index": 0, "scaleArt": false})); s.execute("artboard.duplicate", &json!({"indices": [0]})).unwrap(); - assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true})); + assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true, "lockedAndHidden": false})); let steps = json!([{"command": "artboard.switchOrientation", "params": {}}]); s.execute("command.batch", &json!({ "commands": steps })).unwrap(); assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "scaleArt": false})); assert_eq!(documents(&replayed(&s)), documents(&s)); let action = s.journal_for_action(start); assert_eq!(action[0].1, json!({"index": 0, "scaleArt": false})); - assert_eq!(action[1].1, json!({"indices": [0], "art": true})); + assert_eq!(action[1].1, json!({"indices": [0], "art": true, "lockedAndHidden": false})); assert_eq!(action[2].1["commands"][0]["params"], json!({"scaleArt": false})); } @@ -329,14 +344,14 @@ fn artboard_commands_read_the_recorded_selected_artboards() { } /// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries -/// stay as given. +/// stay as given, apart from the locked-art preference the Artboard tool's copy notes. #[test] fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() { for id in ["edit.copy", "edit.cut"] { let mut s = Session::new(); second_artboard_selected(&mut s); s.execute(id, &json!({})).unwrap(); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}"); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({"lockedAndHidden": false})), "{id}"); } } diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 706f40940..b4472c668 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -485,6 +485,11 @@ fn artboard_commands_refuse_a_boolean_or_number_given_as_text() { ("artboard.duplicate", "art", json!({"index": 0, "art": "false"})), ("artboard.copy", "art", json!({"index": 0, "art": "true"})), ("artboard.cut", "art", json!({"index": 1, "art": 0})), + ("artboard.move", "lockedAndHidden", json!({"index": 0, "dx": 10, "moveArt": true, "lockedAndHidden": "true"})), + ("artboard.duplicate", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": "true"})), + ("artboard.copy", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": 1})), + ("artboard.cut", "lockedAndHidden", json!({"index": 1, "lockedAndHidden": 0})), + ("artboard.rearrange", "lockedAndHidden", json!({"spacing": 100, "lockedAndHidden": "true"})), ] .into_iter() .map(|(cmd, key, p)| not_a_switch(cmd, p, key)) @@ -961,6 +966,86 @@ fn art_also_on_a_locked_artboard_stays_when_another_artboard_moves() { assert_eq!(ids(&moved["moved"]), [free.0, held.0], "with the preference on, it moves"); } +/// Two artboards with the Artboard tool on the second, which holds a plain, a locked and a hidden +/// rectangle, under prefs moveLockedWithArtboard `on`. +fn locked_art_session(on: bool) -> Session { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let mut shape = |x: u32| s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 20})).unwrap()["id"].clone(); + let (_, locked, hidden) = (shape(840), shape(900), shape(960)); + s.execute("object.lock", &json!({"ids": [locked]})).unwrap(); + s.execute("object.hide", &json!({"ids": [hidden]})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("artboard", ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([1])); + s.prefs.move_locked_with_artboard = on; + s +} + +/// The bounds of every object in the document, and how many objects the clipboard holds. +fn art_and_clipboard(s: &Session) -> (Vec>, usize) { + let mut art = vec![]; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |n| { + if !n.is_layer() { + art.push(n.geometric_bounds()); + } + }); + } + (art, s.clipboard.nodes.len()) +} + +/// A command that takes art with an artboard journals whether locked and hidden art went along, so +/// a replay under the other preference takes the same art, in one undo step. +#[test] +fn artboard_commands_journal_the_locked_art_preference() { + let run = |on: bool, cmd: &str, p: &serde_json::Value| { + let mut s = locked_art_session(on); + s.execute(cmd, p).unwrap(); + (art_and_clipboard(&s), s.journal.last().unwrap().clone()) + }; + let mut differ = vec![]; + for (cmd, p) in [ + ("artboard.duplicate", json!({"index": 1, "art": true})), + ("artboard.copy", json!({"index": 1, "art": true})), + ("artboard.cut", json!({"index": 1, "art": true})), + ("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true})), + ("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true, "copy": true})), + ("artboard.rearrange", json!({"layout": "row", "spacing": 100})), + ("edit.copy", json!({})), + ("edit.cut", json!({})), + // A null reads as a key left out. + ("artboard.duplicate", json!({"index": 1, "art": true, "lockedAndHidden": null})), + ("artboard.rearrange", json!({"layout": "row", "spacing": 100, "lockedAndHidden": null})), + ("edit.cut", json!({"lockedAndHidden": null})), + ] { + let (with, (id_with, p_with)) = run(true, cmd, &p); + let (without, (id_without, p_without)) = run(false, cmd, &p); + assert_ne!(with, without, "{cmd} {p}: an omitted param reads the preference"); + if run(false, &id_with, &p_with).0 != with { + differ.push(format!("{cmd} {p}, replayed with the preference off")); + } + if run(true, &id_without, &p_without).0 != without { + differ.push(format!("{cmd} {p}, replayed with the preference on")); + } + + let mut s = locked_art_session(true); + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + s.execute(cmd, &p).unwrap(); + if !cmd.ends_with("copy") { + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd} {p}: one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, doc, "{cmd} {p}: undone"); + } + } + assert!(differ.is_empty(), "{differ:#?}"); + // With the Artboard tool chosen, a bad value is refused as it is by the command run. + let mut s = locked_art_session(false); + let calls: Vec<_> = + ["edit.copy", "edit.cut"].map(|cmd| crate::tests::not_a_switch(cmd, json!({"lockedAndHidden": "true"}), "lockedAndHidden")).to_vec(); + assert_eq!(crate::tests::not_refused(&mut s, &calls), Vec::::new()); +} + /// A locked artboard keeps its place and size: `artboard.setProps` refuses a new rectangle but /// still renames it, `artboard.move`, `artboard.delete`, `artboard.cut` and `artboard.fitToArt` /// refuse it, and Rearrange leaves it and the art on it where they are. A copy of it starts diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index 5dfd7ca09..ce2d3a3e7 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -904,6 +904,31 @@ fn coordinate_moves_journal_the_locked_art_preference() { s.prefs.move_locked_with_artboard = false; s.execute(&id, &p).unwrap(); assert_eq!(bounds(&s, locked), Rect::new(130.0, 100.0, 330.0, 200.0)); + s.execute("edit.undo", &json!({})).unwrap(); + // A null reads as a key left out, and journals the value it resolved to. + s.prefs.move_locked_with_artboard = true; + s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "moveArt": true, "lockedAndHidden": null})).unwrap(); + assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true); +} + +/// A drag with the Artboard tool journals whether locked art went along, so a replay under the +/// other preference moves the same art. +#[test] +fn artboard_tool_drag_journals_the_locked_art_preference() { + let mut s = session(); + let locked = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.prefs.move_locked_with_artboard = true; + gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default()); + let moved = Rect::new(150.0, 130.0, 200.0, 180.0); + assert!(close(bounds(&s, locked), moved)); + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!((id.as_str(), &p["lockedAndHidden"]), ("artboard.move", &json!(true))); + s.execute("edit.undo", &json!({})).unwrap(); + s.prefs.move_locked_with_artboard = false; + s.execute(&id, &p).unwrap(); + assert!(close(bounds(&s, locked), moved)); } /// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it diff --git a/docs/mcp.md b/docs/mcp.md index fe2d705ca..9d57428b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1168,7 +1168,7 @@ These Object menu commands refuse a string, a number or another value that is no `` `copy` must be true or false ``, naming the key, and change nothing: `reflectX`, `reflectY`, `random`, `copy`, `relative`, `strokes`, `corners`, `objects` and `patterns` in `object.transformEach`; `gray` and `deleteRaster` in `object.createObjectMosaic`; `trim` in `object.cropImage`; `allArtboards` in `object.createTrimMarks`; and `byColumn`, -`moveArtwork` and `patterns` in `artboard.rearrange`. A null is read as a key left out. `object.createTrimMarks` +`moveArtwork`, `patterns` and `lockedAndHidden` in `artboard.rearrange`. A null is read as a key left out. `object.createTrimMarks` refuses a `style` that is not a string, such as `5`, with `` `style` must be roman or japanese ``, the message it gives for a string that names no style. @@ -3489,7 +3489,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture guide that near. - `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and hidden art, and art that is also on a locked artboard, with the artboard too, and `artboard.setProps {scaleArt: true}` - scales it too; off (the default) it stays where it is. + scales it too; off (the default) it stays where it is. `artboard.duplicate`, `artboard.copy` and `artboard.cut` copy or + cut locked and hidden art only with it on. A `lockedAndHidden` param on any of these commands takes its place. - `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to the pointer. - `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt) @@ -3943,8 +3944,8 @@ isolation mode. 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 +defaults to the tool's `moveArt` option, Move/Copy Artwork with Artboard; locked and hidden art only with +`lockedAndHidden: true`, default: 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 @@ -3957,6 +3958,9 @@ active one) as `indices`, and a row dragged onto New Artboard passes its artboar `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. +`artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.move` and `artboard.rearrange` take `lockedAndHidden?` +(default: prefs `moveLockedWithArtboard`), and with the Artboard tool chosen `edit.copy` and `edit.cut` pass it on. When +art goes along, the journal records the value, so a replay under another preference takes the same art. 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. @@ -3964,8 +3968,8 @@ These artboard commands refuse a string, a number or another value that is not t `` `art` must be true or false ``, naming the key, and leave the document, its undo history and the clipboard as they were: `moveArt`, `scaleArt`, `strokes`, `corners`, `patterns`, `lockedAndHidden` and an artboard's `locked`, `showCenterMark`, `showCrossHairs` and `showVideoSafeAreas` in `artboard.setProps`; `showCenterMark`, `showCrossHairs` -and `showVideoSafeAreas` in `artboard.new`; `moveArt`, `copy` and `patterns` in `artboard.move`; `art` and `patterns` in `artboard.duplicate`; -and `art` in `artboard.copy` and `artboard.cut`. `artboard.setProps` refuses an artboard's `x`, `y`, `width` or +and `showVideoSafeAreas` in `artboard.new`; `moveArt`, `copy`, `patterns` and `lockedAndHidden` in `artboard.move`; `art`, `patterns` and `lockedAndHidden` in `artboard.duplicate`; +and `art` and `lockedAndHidden` in `artboard.copy` and `artboard.cut`. `artboard.setProps` refuses an artboard's `x`, `y`, `width` or `height` that is not a number, and `artboard.move` refuses such a `dx` or `dy`, with `` `dx` must be a number, not "10" ``. A null is read as a key left out. `artboard.new` refuses an `x`, `y`, `width` or `height` that is not a number with the same message, and @@ -4073,7 +4077,7 @@ editable until a selection leaves it out or an edit moves it off the locked artb other art on it. `edit.undo`, `edit.redo` and a failed `command.batch` bring back the selection a step had, with that art selected and editable when it was. When another artboard moves, scales or is cut (`artboard.move`, `artboard.setProps`, `artboard.cut`, `artboard.rearrange`), art that is also on a locked artboard stays where it is -unless prefs `moveLockedWithArtboard` is on (`lockedAndHidden` for `artboard.setProps`); a copy of an artboard copies +unless `lockedAndHidden` is true (default: prefs `moveLockedWithArtboard`); a copy of an artboard copies it. With the Artboard tool, a click selects a locked artboard, but a drag doesn't move or copy it, it shows no handles, Delete leaves it, and a double-click on its name above the canvas doesn't edit the name (`artboard.setProps {name}` and the Artboards panel diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 184126541..906ea0be5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1179,6 +1179,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Transform Each (Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale Corners, Transform Objects and Transform Patterns), Create Object Mosaic (gray tiles and deleting the image), Crop Image (its trim), Create Trim Marks (all artboards) and Rearrange All Artboards (by column, Move Artwork with Artboard and Transform Patterns) refuse a value other than true, false or null, such as "false", and change nothing. Create Trim Marks refuses a style that is not text, such as 5. New Artboard refuses a position or size other than a number or null, such as "10", and New Artboard and Artboard Options refuse a name other than text or null, such as 5; each leaves the artboards and the undo history as they were. Artboard Options (Move Artwork, Scale Artwork, Scale Strokes & Effects, Scale Corners, Transform Patterns, locked and hidden art and the lock), Move Artboard (Move Artwork, the copy and Transform Patterns), Duplicate Artboards (Transform Patterns) and Duplicate, Copy and Cut Artboard (the art) refuse a value other than true, false or null, such as "true", and Artboard Options and Move Artboard refuse a position, size or move given as text, such as "10"; each leaves the document, its undo history and the clipboard as they were. + Duplicate, Copy, Cut and Move Artboard and Rearrange All Artboards take `lockedAndHidden` (default: Move Locked and Hidden Artwork with Artboard) and refuse a value other than true, false or null. When art goes along, the journal records the value, so a replay under the other preference takes the same art. The Layers panel's New Layer (on top), Select Art and Target (Shift-click), Move Rows (the copy), Template, Paste Remembers Layers and Release to Layers (Build) refuse a value other than true, false or null, such as "true", and leave the document, the selection, the highlighted rows and the undo history as they were; New Sublayer refuses such a `top` too. Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing. The commands that take `keepModel` (fills and strokes, Appearance rows, Live Paint, Shape Builder fills, gradient stops and freeform points, gradient mesh points, type colors, the Symbol Stainer and the swatch commands) refuse a `keepModel` other than true, false or null, such as "true". Toggle Fill/Stroke Focus (`fill`), Invert, Complement, Apply Last Color and Apply Last Gradient (`stroke`), `paint.setFill` and `paint.setStroke` (`focus`, `none`), and `appearance.setItem`, `livePaint.fill`, `livePaint.strokeEdge`, a `shapeBuilder.merge` `fill`, `swatch.new` and `swatch.edit`'s `paint` (`none`) refuse such a value too. Each leaves the document, its undo history and the defaults for new art as they were.