Merge pull request #348 from theironchef/fix/artboard-alt-drag-duplicates

Artboard tool: Alt-drag duplicates the artboard (and its art)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 03:53:07 +07:00
commit 2bdb739383
5 files changed
+151 -27

No files matched your search

+1 -1
View File
@@ -111,7 +111,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
+16 -6
View File
@@ -54,17 +54,27 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let index = s.edit("Duplicate Artboard", |d, _| {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
let mut a = src.clone();
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1;
a.name = format!("{} copy", src.name);
let dx = right + 20.0 - src.rect.x0;
a.rect = vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1);
d.artboards.push(a);
Ok(d.artboards.len() - 1)
Ok(push_artboard_copy(d, &src, vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1)))
})?;
Ok(json!({"index": index}))
}
/// Add a copy of artboard `src` at `rect`, named `<name> copy` (`<name> copy 2`… when taken),
/// with an id of its own → its index.
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: vectorcraft_geom::Rect) -> usize {
let mut a = src.clone();
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0).saturating_add(1);
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
a.name = std::iter::once(format!("{} copy", src.name))
.chain((2u64..).map(|i| format!("{} copy {i}", src.name)))
.find(|name| !taken(name))
.unwrap_or_default();
a.rect = rect;
d.artboards.push(a);
d.artboards.len() - 1
}
// ---------- text ----------
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
+24 -9
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Artboard",
[],
None,
"{index, dx, dy, moveArt?: bool} move an artboard (and the unlocked art fully inside it)",
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the unlocked art fully inside it); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
has_doc,
artboard_move
),
@@ -399,15 +399,30 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let scale_strokes = s.prefs.scale_strokes;
s.edit("Move Artboard", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
if !copy {
if let Some(a) = d.artboards.get_mut(i) {
a.rect = src.rect + dv;
}
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
return Ok((i, art.clone()));
}
Ok(())
let index = super::panelcmds::push_artboard_copy(d, &src, src.rect + dv);
let mut copies = Vec::with_capacity(art.len());
for id in &art {
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
let mut c = d.reid(&n);
c.transform(Affine::translate(dv), scale_strokes);
copies.push(d.insert(parent, at + 1, c)?);
}
Ok((index, copies))
})?;
Ok(json!({ "index": i, "moved": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+48
View File
@@ -265,6 +265,54 @@ fn artboard_resize_via_handle_and_move_command() {
assert!(s.execute("artboard.move", &json!({"index": 7, "dx": 1})).is_err());
}
/// Alt-dragging an artboard with the Artboard tool leaves it and its art in place and moves
/// copies of both.
#[test]
fn artboard_alt_drag_duplicates_it_with_its_art() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("select.none", &json!({})).unwrap();
let alt = Mods { alt: true, ..Mods::default() };
gesture(&mut s, "artboard", &[(400.0, 300.0), (600.0, 300.0), (1300.0, 300.0), (1300.0, 300.0)], alt);
let st = s.doc().unwrap();
let d = &st.doc;
assert_eq!(d.artboards.len(), 2);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 800.0, 600.0), "the original stays");
assert_eq!((d.artboards[1].rect, d.artboards[1].name.as_str()), (Rect::new(900.0, 0.0, 1700.0, 600.0), "Artboard 1 copy"));
assert_ne!(d.artboards[1].id, d.artboards[0].id);
let kids = d.layers[0].children().unwrap();
assert_eq!(kids.len(), 2, "the rectangle and its copy");
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 100.0);
let copy = kids.iter().find(|n| n.id != r).unwrap();
assert_eq!(copy.geometric_bounds().unwrap().x0, 1000.0);
assert_eq!(st.history.undo.last().unwrap().label, "Duplicate Artboard");
// One undo takes the copies away.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 1));
}
/// `artboard.move` with `copy` reports the copies it moved, and copies only the artboard when Move/Copy
/// Artwork with Artboard is off.
#[test]
fn artboard_move_copy_reports_the_copies_and_follows_move_art() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let out = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap();
assert_eq!(out["index"], 1);
let copies: Vec<NodeId> = out["moved"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(copies.len(), 1);
assert_ne!(copies[0], r);
assert_eq!(s.doc().unwrap().doc.node(copies[0]).unwrap().geometric_bounds().unwrap().x0, 1000.0);
let out = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 900, "copy": true})).unwrap();
assert_eq!(out["moved"], json!([]));
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (3, 2), "an artboard alone");
assert_eq!(d.artboards[2].name, "Artboard 1 copy 2", "a name of its own");
let ids: std::collections::HashSet<u32> = d.artboards.iter().map(|a| a.id).collect();
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
}
#[test]
fn magic_wand_selects_same_fill() {
let mut s = session();
+62 -11
View File
@@ -1,7 +1,8 @@
//! Artboard tool (Shift+O).
//!
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
//! it (with its artwork when the `moveArt` option is on, Shift constrains), drag a handle resizes it
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
//! leaves the artboard where it was), drag a handle resizes it
//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes
//! the active one and Escape returns to the Selection tool.
@@ -14,9 +15,23 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
#[derive(Clone, Copy, Debug)]
enum Drag {
Move { index: usize, start: Point, began: bool },
Resize { index: usize, handle: Handle, rect: Rect, began: bool },
Create { start: Point, cur: Point },
/// `copy`: Alt was held at the last drag event (the step under way is a duplicate).
Move {
index: usize,
start: Point,
began: bool,
copy: bool,
},
Resize {
index: usize,
handle: Handle,
rect: Rect,
began: bool,
},
Create {
start: Point,
cur: Point,
},
}
pub struct ArtboardTool {
@@ -66,25 +81,33 @@ impl Tool for ArtboardTool {
}
if let Some(i) = (0..cx.doc.artboards.len()).rev().find(|i| cx.doc.artboards[*i].rect.contains(p)) {
self.active = i;
self.drag = Some(Drag::Move { index: i, start: p, began: false });
self.drag = Some(Drag::Move { index: i, start: p, began: false, copy: false });
} else {
let (p, _) = crate::guides::snap_draw(cx, p, &[]);
self.drag = Some(Drag::Create { start: p, cur: p });
}
vec![]
}
(PointerKind::Drag, Some(Drag::Move { index, start, began })) => {
(PointerKind::Drag, Some(Drag::Move { index, start, began, copy })) => {
let mut out = vec![];
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move Artboard".into()));
out.push(Action::Begin(label(m.alt).into()));
} else if m.alt != copy {
// Alt pressed or released mid-drag: start over as a copy or a move.
out.push(Action::Cancel);
out.push(Action::Begin(label(m.alt).into()));
}
self.drag = Some(Drag::Move { index, start, began: true });
self.drag = Some(Drag::Move { index, start, began: true, copy: m.alt });
let d = move_delta(start, p, m.shift);
self.preview = cx.doc.artboards.get(index).map(|a| a.rect + d);
out.push(Action::Preview("artboard.move".into(), json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art })));
out.push(Action::Preview(
"artboard.move".into(),
json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }),
));
out
}
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, began })) => {
@@ -112,7 +135,14 @@ impl Tool for ArtboardTool {
self.drag = None;
self.preview = None;
match d {
Drag::Move { began: true, .. } | Drag::Resize { began: true, .. } => vec![Action::Commit],
Drag::Move { began: true, copy, .. } => {
// The copy, last in the previewed document, becomes the active artboard.
if copy {
self.active = cx.doc.artboards.len().saturating_sub(1);
}
vec![Action::Commit]
}
Drag::Resize { began: true, .. } => vec![Action::Commit],
Drag::Create { start, cur } => {
let r = Rect::from_points(start, cur);
if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) {
@@ -251,7 +281,7 @@ mod tests {
assert_eq!(t.active, 1);
let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0));
assert_eq!(a[0], Action::Begin("Move Artboard".into()));
assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true})));
assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true, "copy": false})));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2")));
// Handle drag resizes.
@@ -270,4 +300,25 @@ mod tests {
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]);
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]);
}
#[test]
fn alt_drag_moves_a_copy_of_the_artboard() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
let moved =
|dx: f64, copy: bool| Action::Preview("artboard.move".into(), json!({"index": 0, "dx": dx, "dy": 0.0, "moveArt": true, "copy": copy}));
let mut t = ArtboardTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0).with_mods(alt));
assert_eq!(a, vec![Action::Begin("Duplicate Artboard".into()), moved(50.0, true)]);
// Alt released mid-drag: a plain move after all, and back.
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 100.0));
assert_eq!(a, vec![Action::Cancel, Action::Begin("Move Artboard".into()), moved(60.0, false)]);
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(alt));
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]);
}
}