mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Artboards: the Artboards panel's Delete Empty Artboards deletes every artboard with no artwork in one undo step
Squashed from #4142 onto main.
This commit is contained in:
committed by
bflatastic
parent
0b5affef5e
commit
a34055beec
@@ -138,6 +138,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
artboard_delete
|
||||
),
|
||||
cmd!(
|
||||
"artboard.deleteEmpty",
|
||||
"Delete Empty Artboards",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{} delete every artboard with no artwork on it (no object, hidden, locked and template art included, whose visual bounds meet or touch it, clip groups and clipping layers by their members inside the clip; guides don't count), with its guides, in one step; locked artboards stay; when every artboard is empty and none is locked, artboard 1 stays (with a locked one left, artboard 1 can go) → {deleted: [index…]} (the indices before deleting)",
|
||||
has_empty_artboards,
|
||||
artboard_delete_empty
|
||||
),
|
||||
cmd!(
|
||||
"artboard.setProps",
|
||||
"Artboard Options…",
|
||||
@@ -863,21 +872,49 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
|
||||
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
|
||||
s.edit("Delete Artboard", |d, sel| {
|
||||
if indices.len() >= d.artboards.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.edit("Delete Artboard", |d, sel| remove_artboards(d, sel, &indices))?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left.
|
||||
fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
|
||||
if indices.len() >= d.artboards.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The artboards Delete Empty Artboards deletes: those with no artwork
|
||||
/// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one
|
||||
/// of them (a document keeps one, as Delete Artboards keeps artboard 1).
|
||||
pub(crate) fn empty_artboards(d: &Document) -> Vec<usize> {
|
||||
let mut empty: Vec<usize> = d.artboards.iter().enumerate().filter(|(_, a)| !a.locked && !d.artboard_has_art(a.rect)).map(|(i, _)| i).collect();
|
||||
if empty.len() == d.artboards.len() {
|
||||
empty.retain(|&i| i != 0);
|
||||
}
|
||||
empty
|
||||
}
|
||||
|
||||
fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if empty_artboards(&st.doc).is_empty() { Err("no empty artboards to delete".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let indices = empty_artboards(&s.doc()?.doc);
|
||||
if indices.is_empty() {
|
||||
return Err(EngineError::Other("no empty artboards to delete".into()));
|
||||
}
|
||||
s.edit("Delete Empty Artboards", |d, sel| remove_artboards(d, sel, &indices))?;
|
||||
Ok(json!({ "deleted": indices }))
|
||||
}
|
||||
|
||||
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock,
|
||||
/// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel
|
||||
/// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when
|
||||
|
||||
@@ -1144,3 +1144,122 @@ fn stroke_advanced_scale_swap_flip() {
|
||||
assert!(s.execute("stroke.setAdvanced", &json!({})).is_err());
|
||||
assert!(s.execute("stroke.setAdvanced", &json!({"flipProfile": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
/// A document of `n` 200 × 150 artboards in a row, 400 pt apart (`x` = 400·i), each with a
|
||||
/// guide of its own.
|
||||
fn row_of_artboards(n: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
|
||||
for i in 1..n {
|
||||
s.execute("artboard.new", &json!({"x": 400 * i, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
}
|
||||
for i in 0..n {
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 400 * i + 50, "artboard": i})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn box_at(s: &mut Session, x: f64, w: f64) -> NodeId {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 20})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn artboard_names(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Artboards panel › Delete Empty Artboards: with art only on artboard 2, artboards 1 and 3 go
|
||||
/// with their guides in one undo step, the art and its ids untouched; Undo brings them back.
|
||||
#[test]
|
||||
fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() {
|
||||
let mut s = row_of_artboards(3);
|
||||
let art = box_at(&mut s, 450.0, 20.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(artboard_names(&s), ["Artboard 2"]);
|
||||
assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them");
|
||||
assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays");
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]);
|
||||
assert_eq!(s.doc().unwrap().doc.guides.len(), 3);
|
||||
}
|
||||
|
||||
/// What counts as art: hidden and locked objects, an object straddling two artboards and one
|
||||
/// that only touches an artboard's edge; a group counts by its members, not by its box.
|
||||
#[test]
|
||||
fn delete_empty_artboards_keeps_artboards_with_hidden_locked_or_shared_art() {
|
||||
let mut s = row_of_artboards(5);
|
||||
// Hidden art on 1, locked art on 2.
|
||||
box_at(&mut s, 10.0, 20.0);
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
box_at(&mut s, 410.0, 20.0);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
// Straddling 3 (x 800–1000) and 4 (x 1200–1400).
|
||||
box_at(&mut s, 900.0, 350.0);
|
||||
// A group of a box on 3 and one whose right edge touches 6's left edge (x 2000): its box spans
|
||||
// 5 (x 1600–1800), which stays empty, and 6 has art only by the touch.
|
||||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
let a = box_at(&mut s, 850.0, 20.0);
|
||||
let b = box_at(&mut s, 1980.0, 20.0);
|
||||
// No stroke, so its visual box ends at x 2000.
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [4]}));
|
||||
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4", "Artboard 6"]);
|
||||
}
|
||||
|
||||
/// A locked empty artboard stays; with every artboard empty artboard 1 stays; with none empty
|
||||
/// the command is disabled, refuses and adds no undo step.
|
||||
#[test]
|
||||
fn delete_empty_artboards_leaves_locked_ones_and_artboard_1() {
|
||||
let mut s = row_of_artboards(3);
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}), "all empty, one locked");
|
||||
assert_eq!(artboard_names(&s), ["Artboard 2"]);
|
||||
|
||||
let mut s = row_of_artboards(3);
|
||||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 2]}), "all empty: artboard 1 stays");
|
||||
assert_eq!(artboard_names(&s), ["Artboard 1"]);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
|
||||
assert!((spec.enabled)(&s).is_err(), "one artboard left: nothing to delete");
|
||||
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err());
|
||||
box_at(&mut s, 10.0, 20.0);
|
||||
let steps_with_art = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err(), "nothing empty");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps_with_art);
|
||||
assert!(steps_with_art > steps);
|
||||
}
|
||||
|
||||
/// Art outside a clip shows nowhere: a clip group or a clipping layer has art only where its
|
||||
/// members show inside the clip, so an artboard that only the clipped-away part of a member
|
||||
/// covers is empty.
|
||||
#[test]
|
||||
fn delete_empty_artboards_measures_clipped_art_through_the_clip() {
|
||||
// Clip group: the clip on 1, a member running on to 2. Clipping layer: the clip on 3, a member
|
||||
// running on to 4. Nothing else.
|
||||
let mut s = row_of_artboards(4);
|
||||
let long = box_at(&mut s, 50.0, 400.0);
|
||||
let clip = box_at(&mut s, 10.0, 100.0);
|
||||
// The front object clips (drawn after the member).
|
||||
s.execute("select.set", &json!({"ids": [long.0, clip.0]})).unwrap();
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "Clipped"})).unwrap();
|
||||
box_at(&mut s, 850.0, 400.0);
|
||||
// The layer's top object becomes its clipping path.
|
||||
let layer_clip = box_at(&mut s, 810.0, 100.0);
|
||||
let layer_of =
|
||||
|s: &Session| s.doc().unwrap().doc.layers.iter().find(|l| l.children().unwrap().iter().any(|c| c.id == layer_clip)).unwrap().clone();
|
||||
let layer = layer_of(&s);
|
||||
assert_eq!(layer.children().unwrap().len(), 2, "both in their own layer");
|
||||
s.execute("layer.clippingMask.toggle", &json!({"id": layer.id.0})).unwrap();
|
||||
let layer = layer_of(&s);
|
||||
assert!(matches!(layer.kind, vectorcraft_doc::NodeKind::Layer { clip: true, .. }));
|
||||
let first = layer.children().unwrap()[0].id;
|
||||
assert_eq!(first, layer_clip, "the layer's first object clips the rest");
|
||||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 3]}));
|
||||
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 3"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user