Merge pull request #3670 from dotCooCoo/fix/batch-revert-keeps-the-view-artboard

Artboards: an action or Batch… that reverts or switches documents keeps the active artboard
This commit is contained in:
BFlat
2026-10-23 00:46:58 +07:00
committed by GitHub
7 changed files with 156 additions and 15 deletions
+10
View File
@@ -1322,6 +1322,12 @@ pub struct Session {
depth: u32,
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
tool_panic: Option<EngineError>,
/// How many times switching or closing documents, or replacing the active one, has started the
/// tool afresh ([`Session::tool_resets`]).
tool_resets: u64,
/// Whether the tool's options were set after it last started afresh
/// ([`Session::tool_set_since_reset`]).
tool_set_since_reset: bool,
/// Draw Normal / Behind / Inside (toolbar drawing modes).
pub draw_mode: DrawMode,
/// The path new art is drawn inside (Draw Inside).
@@ -1447,6 +1453,8 @@ impl Session {
preview_memo: Default::default(),
depth: 0,
tool_panic: None,
tool_resets: 0,
tool_set_since_reset: false,
draw_mode: DrawMode::Normal,
draw_inside: None,
untitled_counter: 0,
@@ -1527,6 +1535,8 @@ impl Session {
}
self.keep_tool_settings();
self.tool = self.make_tool(self.tool.id());
self.tool_resets = self.tool_resets.wrapping_add(1);
self.tool_set_since_reset = false;
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
+16
View File
@@ -73,6 +73,20 @@ impl Session {
self.tool.id()
}
/// How many times switching or closing documents, or replacing the active one (File › Revert),
/// has started the tool afresh, also inside a `command.batch` or `action.batch`. A frontend
/// compares it before and after a command.
pub fn tool_resets(&self) -> u64 {
self.tool_resets
}
/// Whether the tool's options were set ([`Self::set_tool_option`], `tool.setOption`) after
/// [`Self::tool_resets`] last counted, as a Paste or Cut with the Artboard tool sets them after a
/// batch's Revert step.
pub fn tool_set_since_reset(&self) -> bool {
self.tool_set_since_reset
}
/// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first.
pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
// Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it
@@ -467,6 +481,7 @@ impl Session {
}
pub fn set_tool_option(&mut self, key: &str, v: &Value) {
self.tool.set_option(key, v);
self.tool_set_since_reset = true;
self.keep_tool_settings();
}
@@ -480,6 +495,7 @@ impl Session {
/// Liquify tools' Global Brush Dimensions). Returns the tool's options.
pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map<String, Value>) -> Value {
let active = id.is_none_or(|id| id == self.tool.id());
self.tool_set_since_reset |= active;
let mut other = (!active).then(|| self.make_tool(id.unwrap_or_default()));
let t = other.as_deref_mut().unwrap_or(self.tool.as_mut());
for (k, v) in values {
+22 -15
View File
@@ -715,29 +715,36 @@ impl VectorcraftApp {
}
}
/// The active document's uid and the artboards the Artboard tool has selected (null with another
/// tool), which [`Self::follow_artboard_tool`] compares before and after a command.
fn artboard_tool_state(&self) -> (Option<u64>, Value) {
/// The active document's uid, how many times the tool has started afresh
/// ([`vectorcraft_engine::Session::tool_resets`]) and the artboards the Artboard tool has
/// selected (null with another tool), which [`Self::follow_artboard_tool`] compares before and
/// after a command.
fn artboard_tool_state(&self) -> (Option<u64>, u64, Value) {
let selected = if self.session.tool_id() == "artboard" { self.session.tool_options()["selected"].clone() } else { Value::Null };
(self.session.active().map(|d| d.uid), selected)
(self.session.active().map(|d| d.uid), self.session.tool_resets(), selected)
}
/// After a command run with the Artboard tool, given [`Self::artboard_tool_state`] from before
/// it: when the command chose another document, or `reverted` the document (File › Revert),
/// the tool takes the document's active artboard, as choosing its tab does. When the command
/// changed the artboards the tool has selected (Paste and Cut with the tool, `tool.setOption`),
/// the tool's active artboard becomes the active one (the view's), as a click with the tool
/// makes it.
fn follow_artboard_tool(&mut self, before: (Option<u64>, Value), reverted: bool) {
/// it: when the command chose another document, or started the tool afresh in the same one
/// (File › Revert, a `command.batch` or `action.batch` that reverts it or switches to another
/// document and back), the tool takes the document's active artboard, as choosing its tab
/// does. When the command changed the artboards the tool has selected (Paste and Cut with the
/// tool, `tool.setOption`), also after starting it afresh (a batch step after the revert or the
/// switch), the tool's active artboard becomes the active one (the view's), as a click with the
/// tool makes it.
fn follow_artboard_tool(&mut self, before: (Option<u64>, u64, Value)) {
self.sync_views();
if self.session.tool_id() != "artboard" {
return;
}
let Some(n) = self.session.active().map(|d| d.doc.artboards.len()).filter(|n| *n > 0) else { return };
let (doc, selected) = self.artboard_tool_state();
if reverted || doc != before.0 {
let (doc, resets, selected) = self.artboard_tool_state();
// The tool started afresh during the command, and no later step (a Paste or Cut) set its options.
let reset = resets != before.1;
let fresh = reset && !self.session.tool_set_since_reset();
if fresh || (doc != before.0 && !reset) {
panels::artboards::select(self, panels::artboards::selected(self, n));
} else if selected != before.1
} else if (reset || selected != before.2)
&& let Some(i) = self.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok())
&& i < n
&& let Some(v) = self.view_mut()
@@ -815,7 +822,7 @@ impl VectorcraftApp {
}
let artboards = self.artboard_tool_state();
if let Some(r) = menus::run_ui_command(self, id, &params) {
self.follow_artboard_tool(artboards, false);
self.follow_artboard_tool(artboards);
return r;
}
let mut params = params;
@@ -873,7 +880,7 @@ impl VectorcraftApp {
}
self.sync_views();
// File › Revert replaces the document in its tab, keeps the tab's view and starts the tool afresh.
self.follow_artboard_tool(artboards, r.is_ok() && id == "file.revert");
self.follow_artboard_tool(artboards);
match &r {
Err(e) => self.ui.status = e.clone(),
Ok(v) => {
+45
View File
@@ -970,6 +970,23 @@ mod tests {
assert_eq!((app.view().unwrap().artboard, picked(&app, 3)), (1, vec![1]));
}
/// With the Artboard tool, a `command.batch` that stays in the document makes the artboard the
/// tool then has active the active one, as its steps do on their own: a batch that leaves the
/// tool's artboards keeps the active artboard, and Copy and Paste make the pasted one active.
#[test]
fn a_batch_in_one_document_makes_the_artboard_tools_artboard_active() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap();
app.select_tool("artboard");
select(&mut app, 1);
let active = |app: &VectorcraftApp| (app.view().unwrap().artboard, app.session.tool_options()["active"].clone());
app.run("command.batch", json!({"commands": [{"command": "object.unlockAll", "params": {}}]})).unwrap();
assert_eq!(active(&app), (1, json!(1)));
let steps = [json!({"command": "edit.copy", "params": {}}), json!({"command": "edit.paste", "params": {}})];
app.run("command.batch", json!({"commands": steps})).unwrap();
assert_eq!(active(&app), (3, json!(3)), "the pasted Artboard 4");
}
/// With the Artboard tool, `document.activate` makes the active artboard of the document it
/// switches to the tool's, as choosing the document's tab does.
#[test]
@@ -983,6 +1000,34 @@ mod tests {
assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (2, json!([2])));
}
/// With the Artboard tool, a `command.batch` that switches to another document and back keeps
/// the active artboard, and the tool starts on it. When a later step of the batch pastes an
/// artboard, the pasted one becomes active, as Paste on its own makes it. A later Cut of the
/// tool's artboard 1 makes the new first artboard active, as Cut on its own makes it.
#[test]
fn a_batch_that_switches_documents_and_back_keeps_the_active_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 150, "artboards": 3})).unwrap();
app.run("file.new", json!({"width": 200, "height": 150, "artboards": 2})).unwrap();
app.run("document.activate", json!({"index": 0})).unwrap();
app.select_tool("artboard");
select(&mut app, 2);
let step = |command: &str, params: Value| json!({"command": command, "params": params});
let back = [step("document.activate", json!({"index": 1})), step("document.activate", json!({"index": 0}))];
app.run("command.batch", json!({"commands": back})).unwrap();
assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (2, json!([2])));
app.run("edit.copy", json!({})).unwrap();
let mut steps = back.to_vec();
steps.push(step("edit.paste", json!({})));
app.run("command.batch", json!({"commands": steps})).unwrap();
assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (3, json!([3])), "the pasted Artboard 4");
let mut steps = back.to_vec();
steps.push(step("edit.cut", json!({})));
app.run("command.batch", json!({"commands": steps})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3, "Artboard 1 cut");
assert_eq!((app.view().unwrap().artboard, app.session.tool_options()["selected"].clone()), (0, json!([0])), "the new first artboard");
}
/// With the Artboard tool, a double-click on a name with Shift or Ctrl/Cmd held that leaves
/// several artboards selected opens the name field for all of them. A Ctrl/Cmd double-click on
/// an unselected row's name adds its artboard to the selection, and the second click leaves it
+58
View File
@@ -96,6 +96,64 @@ fn revert_with_the_artboard_tool_keeps_the_active_artboard() {
let _ = std::fs::remove_dir_all(d);
}
/// With the Artboard tool, a `command.batch` with a Revert step keeps the active artboard, and the
/// tool starts on it, as Revert on its own does. When a later step pastes an artboard, the pasted
/// one becomes active, as Paste on its own makes it.
#[test]
fn a_batch_that_reverts_with_the_artboard_tool_keeps_the_active_artboard() {
let d = dir("revert-batch");
let path = d.join("a.vectorcraft").to_string_lossy().to_string();
let mut app = app();
app.run("file.new", json!({"width": 100, "height": 100, "artboards": 3})).unwrap();
app.run("file.saveAs", json!({"path": path})).unwrap();
let saved = objects(&app);
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 2);
rect(&mut app);
let revert = json!({"command": "file.revert", "params": {"confirmed": true}});
app.run("command.batch", json!({"commands": [revert]})).unwrap();
assert_eq!(objects(&app), saved, "the batch reverted");
let active = |app: &VectorcraftApp| (app.view().unwrap().artboard, app.session.tool_options()["selected"].clone());
assert_eq!(active(&app), (2, json!([2])));
app.run("edit.copy", json!({})).unwrap();
rect(&mut app);
app.run("command.batch", json!({"commands": [revert, {"command": "edit.paste", "params": {}}]})).unwrap();
assert_eq!(active(&app), (3, json!([3])), "the pasted Artboard 4");
let _ = std::fs::remove_dir_all(d);
}
/// With the Artboard tool, the Actions panel menu's Batch (`action.batch`) keeps the active
/// artboard, and the tool starts on it: over a folder, whose files it opens and closes, and over
/// data sets with a Revert step.
#[test]
fn batch_with_the_artboard_tool_keeps_the_active_artboard() {
let d = dir("batch-artboard");
let src = d.join("src");
std::fs::create_dir_all(&src).unwrap();
let mut other = app();
other.run("file.new", json!({"width": 50, "height": 50})).unwrap();
other.run("file.saveAs", json!({"path": src.join("b.vectorcraft").to_string_lossy()})).unwrap();
let path = d.join("a.vectorcraft").to_string_lossy().to_string();
let mut app = app();
app.run("file.new", json!({"width": 100, "height": 100, "artboards": 3})).unwrap();
app.run("dataset.new", json!({"name": "a"})).unwrap();
app.run("file.saveAs", json!({"path": path})).unwrap();
let saved = objects(&app);
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 2);
let active = |app: &VectorcraftApp| (app.view().unwrap().artboard, app.session.tool_options()["selected"].clone());
let unlock = json!([{"command": "object.unlockAll", "params": {}}]);
let r = app.run("action.batch", json!({"folder": src.to_string_lossy(), "destination": "saveAndClose", "commands": unlock})).unwrap();
assert_eq!((r["count"].clone(), r["failed"].clone(), app.session.documents().len()), (json!(1), json!(0), 1), "{r}");
assert_eq!(active(&app), (2, json!([2])), "over a folder");
rect(&mut app);
let revert = json!([{"command": "file.revert", "params": {"confirmed": true}}]);
let r = app.run("action.batch", json!({"source": "datasets", "commands": revert})).unwrap();
assert_eq!((r["count"].clone(), r["failed"].clone(), objects(&app)), (json!(1), json!(0), saved), "{r}");
assert_eq!(active(&app), (2, json!([2])), "over data sets, reverting");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn new_from_template_takes_bytes_from_agents() {
let mut app = app();
+4
View File
@@ -2525,6 +2525,10 @@ and its steps of these commands get the artboard that is active when the action
Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection
tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. Choosing the Artboard
tool (`tool.select {tool: "artboard"}`) starts it on the active artboard: its `active` option is that artboard's index.
A command that starts the Artboard tool afresh in the same document (`file.revert`, or a `command.batch` or
`action.batch` that reverts the document or switches to another document and back) keeps the active artboard and
starts the tool on it. When a later step of the batch chooses the tool's artboards (a Paste or Cut), the artboard the
tool then has active becomes the active one.
`artboard.fitToArt {index? | indices?}` (Object › Artboards › Fit to Artwork Bounds) fits each artboard it names to
the art on that artboard, in one undo step. The art is what an export draws, measured with its strokes and with the
+1
View File
@@ -669,6 +669,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document.
The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only.
With the Artboard tool, 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.
With the Artboard tool, an action that reverts the document or switches to another document and back (`command.batch`), and the Actions panel menu's Batch… over a folder whose files it closes or over data sets with a Revert step (`action.batch`), keep the active artboard and start the tool on it, as File › Revert does. They used to make artboard 1 the active one.
The Artboards panel menu's Convert to Artboards turns the geometric bounds of each selected object that has an area into a new artboard and removes those objects, in one undo step, as Object › Artboards › Convert to Artboards does (`artboard.convertToArtboards`). It is disabled when nothing is selected. It used to be disabled always.
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`).