diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bf6b673ff..b721fc0ed 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected", + "{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected", has_selection_guides_or_artboard_tool, cut ), @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{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; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", + "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), 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; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -946,15 +946,27 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { Ok((direct, roots)) } -/// 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 })) +/// Copy or Cut takes artboards: the Artboard tool is chosen, or the journal recorded the artboards +/// it took then. +fn takes_artboards(s: &Session, p: &Value) -> bool { + s.tool_id() == "artboard" || p.get("selectedArtboards").is_some() +} + +/// Copy or Cut of artboards: `selectedArtboards`, `art` and `lockedAndHidden` of `p`, or their +/// defaults from the Artboard tool, which the journal entry records. +fn copy_artboards(s: &mut Session, p: &Value, cut: bool) -> Result { + let given: serde_json::Map = + ["selectedArtboards", "art", "lockedAndHidden"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect(); + let given = Value::Object(given); + super::panelcmds::boards_and_art_noted(s, &given, if cut { "edit.cut" } else { "edit.copy" })?; + super::panelcmds::artboard_copy(s, &given, cut) } fn copy(s: &mut Session, p: &Value) -> Result { + switch_or(p, "art", false, "edit.copy")?; // The Artboard tool copies its artboard (and the art on it). - if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); + if takes_artboards(s, p) { + return copy_artboards(s, p, false); } // With an object of a dynamic symbol instance's art selected, the instance is copied. let (direct, targets) = copy_cut_targets(s)?; @@ -994,8 +1006,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { } fn cut(s: &mut Session, p: &Value) -> Result { - if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); + switch_or(p, "art", false, "edit.cut")?; + if takes_artboards(s, p) { + return copy_artboards(s, p, true); } refuse_symbol_part(s)?; // Ruler guides selected on their own: Copy takes them, Clear removes them. diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2d31997c4..7ee4abe86 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -111,9 +111,8 @@ fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, bool } /// [`boards_and_art`], with both values added to the running command's journal entry, so a replay -/// acts on the same artboards and art whatever the Artboard tool's state is then. `edit.cut` and -/// `edit.copy` call [`artboard_copy`] without this and take no such params. -fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { +/// acts on the same artboards and art whatever the Artboard tool's state is then. +pub(crate) fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { let (indices, art) = boards_and_art(s, p, cmd)?; note_artboards(s, p, &indices); s.note_journal("art", json!(art)); diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 9fcb7cd4c..9708d9af3 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -25,9 +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 view's center the app passes to Edit Symbol); -/// an action leaves them out ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 22] = [ +/// Artboard tool had selected for its commands; 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] = [ ("file.new", "created"), ("edit.newDocumentFromState", "created"), ("document.save", "modified"), @@ -46,6 +47,10 @@ const REPLAY_ONLY: [(&str, &str); 22] = [ ("artboard.cut", "selectedArtboards"), ("artboard.rename", "selectedArtboards"), ("artboard.switchOrientation", "selectedArtboards"), + ("edit.copy", "selectedArtboards"), + ("edit.copy", "art"), + ("edit.cut", "selectedArtboards"), + ("edit.cut", "art"), ("select.allOnArtboard", "artboard"), ("symbol.edit", "artboard"), ("symbol.edit", "viewCenter"), diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 5ad84885f..881bec9da 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -76,9 +76,13 @@ impl Session { cmd::ids_param(params, "ids", id)?; } // Clear's menu needs a selection; an agent supplying explicit targets only needs a - // document. The command validates those targets before making any edit. + // document. The command validates those targets before making any edit. Cut and Copy given + // the artboards the journal recorded need only a document too, whatever is selected and + // whichever tool is chosen. let given = given_ids(spec.params, params); - let enabled = if id == "edit.clear" && params.get("ids").is_some() { + let enabled = if (id == "edit.clear" && params.get("ids").is_some()) + || (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some()) + { cmd::has_doc(self) } else if let Some(ids) = given.clone() { // Objects given as `ids` (or `id`) stand in for the selection: the command is enabled diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 6d4e1a084..7dd2983c2 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -343,23 +343,139 @@ 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, apart from the locked-art preference the Artboard tool's copy notes. +/// A Cut or Copy with the Artboard tool replays on the artboards it took in a new session, where +/// another tool is chosen: with nothing selected there, and with an object selected that a Cut or +/// Copy of objects would take. What it pastes is the same. #[test] -fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() { +fn edit_copy_and_cut_with_the_artboard_tool_replay_on_its_artboards() { + for id in ["edit.copy", "edit.cut"] { + for select in ["select.all", "select.none"] { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute(select, &json!({})).unwrap(); + s.execute(id, &json!({})).unwrap(); + let boards = if id == "edit.cut" { 2 } else { 3 }; + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "{id} after {select}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id} after {select}"); + let entry = s.journal.last().unwrap().clone(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards + 1, "{id} after {select}"); + let r = replayed(&s); + assert_ne!(r.tool_id(), "artboard"); + assert_eq!(documents(&r), documents(&s), "{id} after {select}"); + assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false})), "{id} after {select}"); + } + } +} + +/// A Copy after a Paste takes the pasted artboards, which the Artboard tool selected, and so does its +/// replay, though `edit.paste` selects nothing in a new session's tool. +#[test] +fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboards() { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true, "lockedAndHidden": false})); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 5); + assert_eq!(documents(&replayed(&s)), documents(&s)); +} + +/// A recorded action keeps `edit.copy` and `edit.cut` as given, so when it plays they follow the tool +/// chosen then, and a batch step records and leaves out the same values. The locked-art preference +/// that the Artboard tool's copy notes stays in the action. +#[test] +fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() { for id in ["edit.copy", "edit.cut"] { let mut s = Session::new(); - second_artboard_selected(&mut s); + let start = second_artboard_selected(&mut s); s.execute(id, &json!({})).unwrap(); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({"lockedAndHidden": false})), "{id}"); + let action = s.journal_for_action(start); + assert_eq!(action, [(id.to_string(), json!({"lockedAndHidden": false}))], "{id}"); + let mut played = Session::new(); + played.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + played.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + played.execute(id, &action[0].1).unwrap(); + assert_eq!(played.doc().unwrap().doc.artboards.len(), 1, "{id}"); + assert!(played.clipboard.artboards.is_empty(), "{id}"); + assert_eq!(played.clipboard.nodes.len(), 1, "{id}"); + + let mut s = Session::new(); + let start = second_artboard_selected(&mut s); + let steps = json!([{"command": id, "params": {}}, {"command": "edit.paste", "params": {}}]); + s.execute("command.batch", &json!({ "commands": steps })).unwrap(); + assert_eq!( + s.journal.last().unwrap().1["commands"][0]["params"], + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + "{id}" + ); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); + assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({"lockedAndHidden": false}), "{id}"); } } +/// Without the Artboard tool, Cut and Copy of objects record nothing more than they did. +#[test] +fn edit_copy_and_cut_of_objects_record_their_params_as_given() { + for id in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute(id, &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}"); + assert!(s.clipboard.artboards.is_empty(), "{id}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); + } +} + +/// A recorded `selectedArtboards` needs only a document and is checked as `indices` is, as are `art` +/// and the rule that a cut leaves one artboard. A failed call changes neither the document nor the +/// clipboard. +#[test] +fn edit_copy_and_cut_read_the_recorded_artboards() { + for id in ["edit.copy", "edit.cut"] { + assert!(matches!(Session::new().execute(id, &json!({"selectedArtboards": [0]})), Err(crate::EngineError::Disabled(..)))); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}"); + let before = documents(&s); + for bad in [ + json!({"selectedArtboards": [9]}), + json!({"selectedArtboards": []}), + json!({"selectedArtboards": "1"}), + json!({"selectedArtboards": null}), + json!({"selectedArtboards": [1], "art": "yes"}), + ] { + let err = s.execute(id, &bad).unwrap_err().to_string(); + assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}"); + assert_eq!(documents(&s), before, "{id} {bad}"); + assert!(s.clipboard.artboards.is_empty(), "{id} {bad}"); + } + s.execute(id, &json!({"selectedArtboards": [1], "art": false})).unwrap(); + assert_eq!(s.clipboard.artboards.len(), 1, "{id}"); + assert!(s.clipboard.nodes.is_empty(), "{id}"); + assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [1], "art": false}), "{id}"); + // Objects selected, `art` is still checked. + s.execute("select.all", &json!({})).unwrap(); + let err = s.execute(id, &json!({"art": "yes"})).unwrap_err().to_string(); + assert!(err.contains(id), "{id}: {err}"); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + assert!(s.execute("edit.cut", &json!({"selectedArtboards": [0]})).is_err()); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); +} + /// `artboard.duplicate` and the cut and copy record the tool's Move/Copy Artwork option: replayed in /// a new session, where the option is on, the art stays behind as it did. #[test] fn artboard_commands_record_the_tools_move_art_option() { - for id in ["artboard.duplicate", "artboard.cut", "artboard.copy"] { + for id in ["artboard.duplicate", "artboard.cut", "artboard.copy", "edit.cut", "edit.copy"] { let mut s = Session::new(); second_artboard_selected(&mut s); s.set_tool_option("moveArt", &json!(false)); diff --git a/docs/mcp.md b/docs/mcp.md index 7697399c7..15d010756 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4403,6 +4403,13 @@ When a call to `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard. names no artboard, its journal entry records the artboards it acted on as `selectedArtboards`. A replay of the journal acts on those artboards, and a recorded action leaves the value out and acts on the selection when it plays. +`edit.copy` and `edit.cut` with the Artboard tool chosen record the same `selectedArtboards`, and `art` beside it. +`edit.copy {selectedArtboards: [n…], art?}` and `edit.cut {selectedArtboards: [n…], art?}` run `artboard.copy` and +`artboard.cut` on those artboards whichever tool is chosen and whatever is selected, so a replay of the journal in a new +session takes the same artboards. A value of `selectedArtboards` that names no artboard fails as `indices` does, and `art` +that is not true or false fails. A recorded action leaves both values out: when it plays, `edit.copy` and `edit.cut` take +artboards when the Artboard tool is chosen then, and the selection otherwise. + `artboard.selectAll {}` chooses the Artboard tool when another tool is in use and selects every artboard. The active artboard stays active, the art's selection stays as it is, and no undo step is recorded → `{selected: [index…]}`, the active artboard first. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4acd06b4c..80b07c159 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -981,6 +981,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. + 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. With the Artboard tool, Paste and Paste in Place make the first pasted artboard the active one (the navigator's and the Artboards panel's), and Cut makes the artboard before the first cut one active (the new first artboard when artboard 1 was cut). A command that chooses another document (`document.activate`) starts the tool on that document's active artboard, as choosing its tab does, and File › Revert keeps the active artboard and starts the tool on it. Paste used to leave the active artboard where it was while the tool had the pasted artboard active, and Cut left the active artboard on the artboard after the cut one while the tool had the one before it.