mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Artboards: each artboard can have a background color on the canvas
Squashed from #2403 onto main.
This commit is contained in:
@@ -136,7 +136,7 @@ impl Builder {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0)
|
||||
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false })
|
||||
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false, color: None })
|
||||
.collect();
|
||||
if self.doc.artboards.is_empty() {
|
||||
return Err(err("the Affinity document has no page with a size"));
|
||||
|
||||
@@ -340,7 +340,7 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen
|
||||
|
||||
/// `d` with one artboard, `rect`.
|
||||
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
|
||||
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }];
|
||||
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false, color: None }];
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{x?, y?, width?, height?, name?} (default: right of the last)",
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none)} (default place: right of the last)",
|
||||
has_doc,
|
||||
artboard_new
|
||||
),
|
||||
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artboard Options…",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index | artboards: [{index, name?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
"{index | artboards: [{index, name?, color?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
has_doc,
|
||||
artboard_set
|
||||
),
|
||||
@@ -803,16 +803,27 @@ fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
Rect::new(x, y, x + w, y + h)
|
||||
}
|
||||
|
||||
/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for
|
||||
/// null (no color), else `Some(Some(color))`.
|
||||
fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> {
|
||||
match p.get("color") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(v) => color_value(v).map(|c| Some(Some(c))).ok_or_else(|| bad(cmd, format!("bad color {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d0 = &s.doc()?.doc;
|
||||
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
|
||||
let r = rect_from(p, def);
|
||||
let color = artboard_color(p, "artboard.new")?.flatten();
|
||||
let idx = s.edit("New Artboard", |d, _| {
|
||||
let id = d.next_artboard_id();
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
|
||||
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
|
||||
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false, color });
|
||||
Ok(d.artboards.len() - 1)
|
||||
})?;
|
||||
Ok(json!({ "index": idx }))
|
||||
@@ -841,6 +852,7 @@ struct ArtboardEdit<'a> {
|
||||
index: usize,
|
||||
now: Rect,
|
||||
name: Option<&'a str>,
|
||||
color: Option<Option<vectorcraft_color::Color>>,
|
||||
delta: vectorcraft_geom::Vec2,
|
||||
resized: bool,
|
||||
fit: Option<Affine>,
|
||||
@@ -872,6 +884,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("artboard.setProps", "an artboard is listed twice"));
|
||||
}
|
||||
let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
// The background color: a color sets it, null clears it, absent leaves it as it is.
|
||||
let color = artboard_color(q, "artboard.setProps")?;
|
||||
let now = rect_from(q, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
let fit = if scale_art && resized { rect_map(was, now) } else { None };
|
||||
@@ -881,7 +895,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
|
||||
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
|
||||
let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
|
||||
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), delta, resized, fit, transform, art });
|
||||
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), color, delta, resized, fit, transform, art });
|
||||
}
|
||||
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
|
||||
// the preference is then.
|
||||
@@ -900,6 +914,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
|
||||
a.name = if n.is_empty() { format!("Artboard {}", e.index + 1) } else { n.to_string() };
|
||||
}
|
||||
if let Some(c) = e.color {
|
||||
a.color = c;
|
||||
}
|
||||
let id = a.id;
|
||||
if let Some(xf) = e.transform {
|
||||
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
|
||||
|
||||
@@ -966,6 +966,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
rect: b,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
color: None,
|
||||
});
|
||||
converted.push(id);
|
||||
}
|
||||
|
||||
@@ -340,6 +340,7 @@ impl DocSettings {
|
||||
rect,
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
color: None,
|
||||
})
|
||||
.collect();
|
||||
d
|
||||
|
||||
@@ -128,6 +128,15 @@ pub fn document(s: &Session) -> Value {
|
||||
document_opts(s, SummaryOpts::default())
|
||||
}
|
||||
|
||||
/// An artboard's name and rectangle, and its background color (`#rrggbb`) when it has one.
|
||||
fn artboard_summary(a: &vectorcraft_doc::Artboard) -> Value {
|
||||
let mut v = json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()});
|
||||
if let Some(c) = a.color {
|
||||
v["color"] = json!(c.to_hex());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and
|
||||
/// the rest always come whole): `depth: 0` is the skeleton — top layers with counts.
|
||||
pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
|
||||
@@ -245,7 +254,7 @@ fn document_base(s: &Session) -> Value {
|
||||
"revision": st.revision,
|
||||
"units": d.units.label(),
|
||||
"colorMode": format!("{:?}", d.color_mode),
|
||||
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
|
||||
"artboards": d.artboards.iter().map(artboard_summary).collect::<Vec<_>>(),
|
||||
"currentLayer": st.active_layer.map(|l| l.0),
|
||||
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
|
||||
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Panel-support commands (`cmd/{panelcmds,gradient,appearance,stroke,swatch,style}.rs`).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{GradientKind, Paint};
|
||||
use vectorcraft_color::{Color, GradientKind, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, NodeKind};
|
||||
|
||||
use super::*;
|
||||
@@ -94,6 +94,67 @@ fn artboards_reorder_and_duplicate() {
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second");
|
||||
}
|
||||
|
||||
/// An artboard's background color (Properties › Artboards › Fill): `artboard.setProps {color}` sets
|
||||
/// it and `{color: null}` clears it, each in one undo step. A change without `color` leaves it, a
|
||||
/// duplicate keeps it, `inspect_document` reports it, `artboard.new {color}` starts with it, an
|
||||
/// `artboards` list gives each listed artboard its own color in one undo step, and a bad color is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn artboard_color_is_set_and_cleared_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let color = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].color;
|
||||
let blue = Color::from_hex("#336699");
|
||||
let black = Some(Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "color": "#336699"})).unwrap();
|
||||
assert_eq!((color(&s, 0), color(&s, 1)), (None, blue), "only that artboard");
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Dark"})).unwrap();
|
||||
assert_eq!(color(&s, 1), blue, "a change without a color leaves it");
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 100}})).unwrap();
|
||||
assert_eq!(color(&s, 1), black);
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "color": null})).unwrap();
|
||||
assert_eq!(color(&s, 1), None);
|
||||
for was in [black, blue, blue, None] {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(color(&s, 1), was);
|
||||
}
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let doc = crate::inspect::document(&s);
|
||||
assert_eq!(doc["artboards"][1]["color"], "#336699");
|
||||
assert!(doc["artboards"][0].get("color").is_none(), "{doc}");
|
||||
let copy = s.execute("artboard.duplicate", &json!({"index": 1})).unwrap()["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(color(&s, copy), blue, "a duplicate keeps the color");
|
||||
let made = s.execute("artboard.new", &json!({"color": "#336699"})).unwrap()["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(color(&s, made), blue, "a new artboard takes `color` too");
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": "#336699"}, {"index": 1, "color": null}]})).unwrap();
|
||||
assert_eq!((color(&s, 0), color(&s, 1)), (blue, None), "each listed artboard takes its own color");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "in one undo step");
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert!(s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": null}, {"index": 1, "color": "nope"}]})).is_err());
|
||||
assert!(s.execute("artboard.setProps", &json!({"index": 1, "color": "nope"})).is_err());
|
||||
assert!(s.execute("artboard.new", &json!({"color": "nope"})).is_err());
|
||||
assert_eq!(*s.doc().unwrap().doc, *before, "a bad color changes nothing");
|
||||
}
|
||||
|
||||
/// The native format writes an artboard's background color and reads it back. An artboard without
|
||||
/// one is written as before, with no `color` key.
|
||||
#[test]
|
||||
fn artboard_color_round_trips_through_the_native_format() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap();
|
||||
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||||
let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let keys = |i: usize| saved["document"]["artboards"][i].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
|
||||
assert_eq!(keys(0), ["id", "name", "rect", "show_center_mark", "show_cross_hairs"]);
|
||||
assert!(keys(1).contains(&"color".to_string()), "{:?}", keys(1));
|
||||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||||
assert_eq!(back.artboards[0].color, None);
|
||||
assert_eq!(back.artboards[1].color, Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
|
||||
assert_eq!(back.artboards, s.doc().unwrap().doc.artboards);
|
||||
}
|
||||
|
||||
/// The artboards' left edges and names.
|
||||
fn boards(s: &Session) -> Vec<(f64, String)> {
|
||||
s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect()
|
||||
|
||||
Reference in New Issue
Block a user