diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index 6d5d50d4d..d7bfcafca 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -106,6 +106,60 @@ impl super::Document { d.layers.insert(0, Arc::new(layer)); Some(d) } + + /// Where an artboard the size of artboard `i` goes on `side` of it, [`ARTBOARD_GAP`] away, + /// when there is room for it there: every side within [`crate::MAX_COORD`] of the origin and + /// no other artboard overlapping it (touching edges don't overlap). `None` when there is no + /// such artboard or no room. + pub fn room_beside_artboard(&self, i: usize, side: ArtboardSide) -> Option { + let src = self.artboards.get(i)?.rect; + let r = src + side.offset(src); + let in_canvas = [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD); + let clear = self.artboards.iter().all(|a| !(r.x0 < a.rect.x1 && a.rect.x0 < r.x1 && r.y0 < a.rect.y1 && a.rect.y0 < r.y1)); + (in_canvas && clear).then_some(r) + } +} + +/// The space between an artboard and one added or duplicated beside it (VectorCraft's own value, +/// the space New Artboard and Duplicate Artboards leave right of the last artboard). +pub const ARTBOARD_GAP: f64 = 20.0; + +/// A side of an artboard, where an artboard added or duplicated beside it goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArtboardSide { + Left, + Right, + Top, + Bottom, +} + +impl ArtboardSide { + pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left]; + + /// The side a command's `side` param names: "left", "right", "top" or "bottom". + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|s| s.name() == name) + } + + pub fn name(self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + Self::Top => "top", + Self::Bottom => "bottom", + } + } + + /// How far a copy of `r` moves to sit on this side of it, [`ARTBOARD_GAP`] away (y down). + pub fn offset(self, r: Rect) -> vectorcraft_geom::Vec2 { + let (w, h) = (r.width() + ARTBOARD_GAP, r.height() + ARTBOARD_GAP); + match self { + Self::Left => vectorcraft_geom::Vec2::new(-w, 0.0), + Self::Right => vectorcraft_geom::Vec2::new(w, 0.0), + Self::Top => vectorcraft_geom::Vec2::new(0.0, -h), + Self::Bottom => vectorcraft_geom::Vec2::new(0.0, h), + } + } } /// A document's locked artboards ([`Document::locked_artboards`]). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8eeb1786d..c995158d8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -141,7 +141,7 @@ mod limits; mod records; mod units; -pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; pub use canvas_range::BeyondCanvas; #[cfg(test)] use document_ext::free_id; diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index d4adaf2fd..d5437d017 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Rect}; use super::{ @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)", + "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -869,6 +869,17 @@ fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Result> { + let bad_side = || bad(cmd, "`side` must be \"left\", \"right\", \"top\" or \"bottom\""); + match p.get("side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => ArtboardSide::parse(v).map(Some).ok_or_else(bad_side), + Some(_) => Err(bad_side()), + } +} + /// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within /// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`]. fn video_pixel_aspect(p: &Value, cmd: &str) -> Result> { @@ -886,10 +897,24 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { 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(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + let r = match artboard_side(p, "artboard.new")? { + Some(side) => { + if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { + return Err(bad("artboard.new", "`side` places the artboard: leave out x, y, width and height")); + } + let beside = match p.get("beside") { + None | Some(Value::Null) => d0.artboards.len().saturating_sub(1), + Some(_) => super::appearance::index_param(p, "beside", "artboard.new")?, + }; + let src = d0.artboards.get(beside).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + src + side.offset(src) + } + None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), + None => rect_from(p, def), + }; let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 6eeafd7c3..073031a8f 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -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), 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}", + "{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), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the copies go together on that side of the artboards they copy, 20 pt away)} a copy of each artboard, the copies placed together right of the last artboard as they lay (or on `side`), 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 ), @@ -297,9 +297,11 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec } /// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep -/// their places relative to each other, together right of the last artboard. +/// their places relative to each other, together right of the last artboard, or with `side` +/// together on that side of the artboards they copy. fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; + let side = super::layer::artboard_side(p, "artboard.duplicate")?; let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?; let arts = artboards_art(s, &indices, art, locked_art, false)?; @@ -311,7 +313,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { .filter_map(|(i, _)| d.artboards.get(*i)) .map(|a| a.rect) .reduce(|a, b| a.union(b)) - .map(|r| beside_artboards(d, r)) + .map(|r| side.map_or_else(|| beside_artboards(d, r), |side| side.offset(r))) .unwrap_or_default(); let (mut index, mut ids) = (vec![], vec![]); for (i, art) in &arts { diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 0f95fc2f6..0870ffb87 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -119,6 +119,60 @@ fn artboards_reorder_and_duplicate() { assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second"); } +/// `artboard.new {beside, side}` adds an empty artboard the size of artboard `beside`, 20 pt from +/// that side of it, and `artboard.duplicate {index, side}` puts the copy and its art there; each is +/// one undo step. An unknown side, `beside` without `side`, and `side` with a size or place are +/// refused and change nothing. +#[test] +fn artboards_are_added_and_duplicated_on_a_side() { + let mut s = session(); + let id = rect(&mut s); + let board = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].rect; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let src = board(&s, 0); + let before = undo(&s); + assert_eq!(s.execute("artboard.new", &json!({"beside": 0, "side": "bottom"})).unwrap()["index"], 1); + assert_eq!(board(&s, 1), src + vectorcraft_geom::Vec2::new(0.0, src.height() + 20.0), "same size, 20 pt below"); + assert_eq!(undo(&s), before + 1, "one undo step"); + assert!(!s.doc().unwrap().doc.artboard_has_art(board(&s, 1)), "the new artboard is empty"); + s.execute("artboard.new", &json!({"beside": 0, "side": "left"})).unwrap(); + assert_eq!(board(&s, 2), src + vectorcraft_geom::Vec2::new(-(src.width() + 20.0), 0.0)); + s.execute("artboard.new", &json!({"side": "top"})).unwrap(); + assert_eq!(board(&s, 3), board(&s, 2) + vectorcraft_geom::Vec2::new(0.0, -(src.height() + 20.0)), "by default beside the last"); + s.execute("artboard.new", &json!({"side": "top", "beside": null, "x": null})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + + let before = undo(&s); + let out = s.execute("artboard.duplicate", &json!({"index": 0, "side": "right", "art": true})).unwrap(); + assert_eq!(out["index"], 4); + let dv = vectorcraft_geom::Vec2::new(src.width() + 20.0, 0.0); + assert_eq!(board(&s, 4), src + dv, "the copy sits 20 pt right of the artboard it copies"); + let copies: Vec = out["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 1); + let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert_eq!(bounds(&s, copies[0]), bounds(&s, id) + dv, "its art goes with it"); + assert_eq!(undo(&s), before + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 4, "undo takes the copy and its art away together"); + assert!(s.doc().unwrap().doc.node(copies[0]).is_none()); + + let before = s.doc().unwrap().doc.clone(); + let steps = undo(&s); + for (cmd, p, msg) in [ + ("artboard.new", json!({"beside": 0, "side": "middle"}), "`side` must be"), + ("artboard.new", json!({"beside": 0, "side": 3}), "`side` must be"), + ("artboard.new", json!({"beside": 0}), "`beside` needs `side`"), + ("artboard.new", json!({"side": "left", "width": 100}), "leave out x, y, width and height"), + ("artboard.new", json!({"beside": 9, "side": "left"}), "no such artboard"), + ("artboard.duplicate", json!({"index": 0, "side": "inside"}), "`side` must be"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{cmd} {p}: {e}"); + assert_eq!(*s.doc().unwrap().doc, *before, "{cmd} {p} changes nothing"); + assert_eq!(undo(&s), steps, "{cmd} {p}"); + } +} + /// 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 diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index c3710c1bd..4ffd435af 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -21,8 +21,17 @@ //! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]). A locked artboard (the //! Artboards panel's lock column) is selected by a click, but a drag or an arrow key doesn't move it //! or copy it, it shows no handles, and Delete leaves it. +//! +//! While one artboard is selected and nothing is being dragged, a button with a plus sits just +//! outside the middle of each of its edges where an artboard of the same size fits on that side +//! ([`vectorcraft_doc::Document::room_beside_artboard`]: within the canvas, 20 pt away, overlapping +//! no other artboard). Clicking one adds an empty artboard there (`artboard.new {beside, side}`); +//! Alt-clicking duplicates the artboard with its art there (`artboard.duplicate {index, side}`), +//! except on a locked artboard, where an Alt-click does nothing. Each is one undo step, and the new +//! artboard becomes the active one. use serde_json::{Value, json}; +use vectorcraft_doc::ArtboardSide; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; @@ -282,6 +291,44 @@ fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 { Vec2::new(if ax { d.x } else { 0.0 }, if ay { d.y } else { 0.0 }) } +/// How far outside the middle of an edge its add button sits, in screen pixels: past the handle +/// on that edge. +const EDGE_BUTTON_OFFSET: f64 = 18.0; +/// The add button's radius in screen pixels: a press this near its center takes it. +const EDGE_BUTTON_RADIUS: f64 = 7.0; + +impl ArtboardTool { + /// The add buttons on the active artboard's edges, each with its side and center: shown while + /// it is the only one selected and nothing is being dragged, and only on the sides with room + /// for an artboard of the same size ([`vectorcraft_doc::Document::room_beside_artboard`]). + fn edge_buttons(&self, cx: &ToolContext) -> Vec<(ArtboardSide, Point)> { + let Some(r) = self.active_rect(cx) else { return vec![] }; + if self.drag.is_some() || self.selected(cx).len() != 1 { + return vec![]; + } + let off = cx.tol(EDGE_BUTTON_OFFSET); + let c = r.center(); + ArtboardSide::ALL + .into_iter() + .filter(|side| cx.doc.room_beside_artboard(self.active, *side).is_some()) + .map(|side| { + let at = match side { + ArtboardSide::Left => Point::new(r.x0 - off, c.y), + ArtboardSide::Right => Point::new(r.x1 + off, c.y), + ArtboardSide::Top => Point::new(c.x, r.y0 - off), + ArtboardSide::Bottom => Point::new(c.x, r.y1 + off), + }; + (side, at) + }) + .collect() + } + + /// The add button under `p`, if any. + fn edge_button_at(&self, cx: &ToolContext, p: Point) -> Option { + self.edge_buttons(cx).into_iter().find(|(_, at)| at.distance(p) <= cx.tol(EDGE_BUTTON_RADIUS)).map(|(side, _)| side) + } +} + /// The grid's snapping step (gridline spacing over subdivisions), as `guides::snap_draw` uses. impl Tool for ArtboardTool { fn id(&self) -> &'static str { @@ -305,6 +352,21 @@ impl Tool for ArtboardTool { match (ev.kind, self.drag) { (PointerKind::Down, _) => { self.click_art = None; + // An add button: an empty artboard of the same size on that side, or with Alt a + // copy of the artboard and its art there; the new one becomes active. An Alt-click + // on a locked artboard's button does nothing: the tool doesn't copy a locked artboard. + if let Some(side) = self.edge_button_at(cx, p) { + let index = self.active; + if m.alt && cx.doc.artboards.get(index).is_some_and(|a| a.locked) { + return vec![]; + } + let (id, v) = if m.alt { + ("artboard.duplicate", json!({ "index": index, "side": side.name(), "art": true })) + } else { + ("artboard.new", json!({ "beside": index, "side": side.name() })) + }; + return vec![Action::Exec(id.into(), v), Action::Notify("created".into())]; + } if let Some(r) = self.resizable_rect(cx) && let Some(handle) = hit_handle(r, p, cx.tol(5.0)) { @@ -593,10 +655,24 @@ impl Tool for ArtboardTool { if self.drag.is_some() { o.extend(self.guides.iter().cloned()); } + // The add buttons: a ring with a plus in it. + let (radius, arm) = (cx.tol(EDGE_BUTTON_RADIUS), cx.tol(3.5)); + for (_, at) in self.edge_buttons(cx) { + let ring: Vec = (0..24).map(|k| at + Vec2::from_angle(f64::from(k) * std::f64::consts::TAU / 24.0) * radius).collect(); + let mut path = polygon(&ring, true); + for arm in [Vec2::new(arm, 0.0), Vec2::new(0.0, arm)] { + path.move_to(at - arm); + path.line_to(at + arm); + } + o.push(Overlay::Path { path, color: BLUE, width: 1.0, dashed: false }); + } o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + if self.edge_button_at(cx, p).is_some() { + return Cursor::Arrow; + } if let Some(r) = self.resizable_rect(cx) && let Some(h) = hit_handle(r, p, cx.tol(5.0)) { diff --git a/crates/tools/src/xform/artboard/tests.rs b/crates/tools/src/xform/artboard/tests.rs index 3c8900ff5..98410703f 100644 --- a/crates/tools/src/xform/artboard/tests.rs +++ b/crates/tools/src/xform/artboard/tests.rs @@ -562,3 +562,97 @@ fn alt_drag_moves_a_copy_of_the_artboard() { assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]); assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]); } + +/// The sides of the active artboard that show an add button. +fn button_sides(t: &ArtboardTool, c: &ToolContext) -> Vec { + t.edge_buttons(c).into_iter().map(|(side, _)| side).collect() +} + +/// The rings the add buttons draw. +fn button_rings(t: &ArtboardTool, c: &ToolContext) -> usize { + t.overlays(c).iter().filter(|o| matches!(o, Overlay::Path { dashed: false, .. })).count() +} + +#[test] +fn add_buttons_show_only_on_sides_with_room_for_an_artboard_of_the_same_size() { + let mut d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let t = ArtboardTool::default(); + // Artboard 1 (0–500): a copy on its right (520–1020) would overlap artboard 2 (600–800). + let c = cx(&d, &s, &p); + assert_eq!(button_sides(&t, &c), vec![ArtboardSide::Top, ArtboardSide::Bottom, ArtboardSide::Left]); + assert_eq!(button_rings(&t, &c), 3); + // Artboard 2 (600–800 × 0–200): its left would overlap artboard 1, the rest has room. + let t2 = ArtboardTool { active: 1, ..Default::default() }; + assert_eq!(button_sides(&t2, &c), vec![ArtboardSide::Top, ArtboardSide::Right, ArtboardSide::Bottom]); + // At the canvas's edge there is no room past it. + let m = vectorcraft_doc::MAX_COORD; + d.artboards[1].rect = Rect::new(m - 200.0, 0.0, m, 200.0); + let c = cx(&d, &s, &p); + assert_eq!(button_sides(&t2, &c), vec![ArtboardSide::Top, ArtboardSide::Bottom, ArtboardSide::Left]); + // The buttons sit just outside the middle of each edge. + let off = c.tol(EDGE_BUTTON_OFFSET); + assert_eq!(t.edge_buttons(&c)[0].1, Point::new(250.0, -off)); +} + +#[test] +fn add_buttons_hide_with_several_artboards_selected_or_during_a_drag() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let t = ArtboardTool { active: 1, others: vec![0], ..Default::default() }; + assert!(button_sides(&t, &c).is_empty()); + assert_eq!(button_rings(&t, &c), 0); + let off = c.tol(EDGE_BUTTON_OFFSET); + assert_eq!(t.edge_button_at(&c, Point::new(700.0, 200.0 + off)), None, "no button takes a press"); + let mut t = ArtboardTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + t.pointer(&c, &ev(PointerKind::Drag, 720.0, 100.0)); + assert!(button_sides(&t, &c).is_empty(), "none while an artboard is dragged"); +} + +#[test] +fn an_add_button_adds_an_empty_artboard_and_alt_duplicates_on_that_side() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let off = c.tol(EDGE_BUTTON_OFFSET); + let below = Point::new(250.0, 500.0 + off); + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, below, Mods::default()), Cursor::Arrow); + let a = t.pointer(&c, &ev(PointerKind::Down, below.x, below.y)); + assert_eq!(a, vec![Action::Exec("artboard.new".into(), json!({"beside": 0, "side": "bottom"})), Action::Notify("created".into())]); + // The release that follows adds nothing more. + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, below.x, below.y)), vec![]); + let alt = Mods { alt: true, ..Mods::default() }; + let left = Point::new(-off, 250.0); + let a = t.pointer(&c, &ev(PointerKind::Down, left.x, left.y).with_mods(alt)); + assert_eq!( + a, + vec![Action::Exec("artboard.duplicate".into(), json!({"index": 0, "side": "left", "art": true})), Action::Notify("created".into())] + ); + // The new artboard, last in the document, becomes the active one. + let mut d3 = d.clone(); + d3.artboards.push(d.artboards[0].clone()); + t.notify(&cx(&d3, &s, &p), "created"); + assert_eq!((t.active, t.others.clone()), (2, vec![])); +} + +/// On a locked artboard, a click on an add button still adds an empty artboard beside it, but an +/// Alt-click does nothing: the tool doesn't copy a locked artboard. +#[test] +fn an_alt_click_on_a_locked_artboards_add_button_does_not_duplicate_it() { + let mut d = two_boards(); + d.artboards[0].locked = true; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let off = c.tol(EDGE_BUTTON_OFFSET); + let left = Point::new(-off, 250.0); + let mut t = ArtboardTool::default(); + let alt = Mods { alt: true, ..Mods::default() }; + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, left.x, left.y).with_mods(alt)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, left.x, left.y).with_mods(alt)), vec![]); + assert_eq!((t.active, t.others.clone()), (0, vec![])); + let a = t.pointer(&c, &ev(PointerKind::Down, left.x, left.y)); + assert_eq!(a, vec![Action::Exec("artboard.new".into(), json!({"beside": 0, "side": "left"})), Action::Notify("created".into())]); +} diff --git a/docs/mcp.md b/docs/mcp.md index b5a6a2d3c..1a6189800 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4186,6 +4186,15 @@ selects them. `edit.paste {artboardsAt: [x, y]}` instead centers the copied artb value that is not `[x, y]` fails with `` `artboardsAt` must be [x, y] ``. `artboard.duplicate {index? | indices?, art?}` does the same in one step → `{index, indices, ids}`. Window › Artboards › Duplicate Artboards passes the selected artboards (those the Artboard tool has selected, else the active one) as `indices`, and a row dragged onto New Artboard passes its artboard as `index`. +`artboard.new {beside?, side}` adds an empty artboard the size of artboard `beside` (default: the last), 20 pt from +that side of it, and `artboard.duplicate {index, side}` puts the copies there instead, together as they lay on that side +of the artboards they copy; `side` is `"left"`, `"right"`, `"top"` or `"bottom"`. Another `side` fails with +`` `side` must be "left", "right", "top" or "bottom" ``, `beside` without `side` fails with `` `beside` needs `side` ``, +and `artboard.new` refuses `side` given with `x`, `y`, `width` or `height`; each leaves the document and its undo +history as they were. With the Artboard tool and one artboard selected, a button with a plus shows just outside the +middle of each edge where an artboard of the same size fits on that side (within the canvas and overlapping no other +artboard): a click runs `artboard.new {beside, side}`, an Alt-click `artboard.duplicate {index, side, art: true}`, and +the new artboard becomes the active one. An Alt-click on a locked artboard's button does nothing. `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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c43638fe..615976277 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -620,6 +620,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Escape in the distort tools:** Escape during a Puppet Warp pin drag or turn, a Perspective Grid tool handle or plane widget drag, a Perspective Selection move, resize or plane widget drag, or a Liquify stroke cancels it: the art goes back to how the press found it, no undo step is added, and the tool stays chosen. Puppet Warp also selects again the pins selected before the press, and Escape during a Liquify Alt-drag puts the brush size back. Regression tests: `escape_cancels_*` in `crates/tools/src/distort/puppet.rs`, `perspective.rs` and `liquify.rs`, and `escape_cancels_a_puppet_warp_perspective_or_liquify_drag` in `tests/distortkeys.rs`. - **Shift with the Artboard tool:** a Shift-click adds an artboard to the selection or takes it out when the button comes up. A Shift-drag inside an artboard draws a new artboard there, and a Shift-drag on the pasteboard is a marquee that toggles the artboards it touches, as the [default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (a Shift-drag draws an artboard within an existing one) and [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy) describe. Shift no longer keeps an artboard move at 45° steps or a drawn artboard square. Escape during a marquee or while a new artboard is being drawn cancels it and keeps the Artboard tool. Pointer and key tests in `crates/tools/src/xform/artboard.rs` cover the four gestures and Escape. +- **Artboard edge buttons:** with the Artboard tool and one artboard selected, a button with a plus shows just outside the middle of each edge where an artboard of the same size fits on that side; clicking it adds an empty artboard of that size there (`artboard.new {beside, side}`), and Alt-clicking duplicates the artboard with its art there (`artboard.duplicate {index, side}`), each in one undo step, with the new artboard active. An Alt-click on a locked artboard's button does nothing, as the tool doesn't copy a locked artboard. The 20 pt gap and the room rule (within the canvas, overlapping no other artboard) are VectorCraft's own. Pointer tests in `crates/tools/src/xform/artboard/tests.rs` and an engine test in `tests/panelcmds.rs` cover it. Public references: [Add artboards instantly](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html) and [Duplicate artboards instantly](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/duplicate-artboards.html). - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/). - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending.