Fix #346: Artboard tool: Alt-drag duplicates the artboard

Alt-dragging an artboard moved it like a plain drag. artboard.move now
takes copy: it leaves the artboard (and the art it carries) where it
is and moves a copy, named "<name> copy", with copies of that art. The
Artboard tool previews it while Alt is held, as "Duplicate Artboard"
(Alt pressed or released mid-drag switches between copy and move),
and makes the copy the active artboard, as in the reference app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
theironchef
2026-10-08 03:22:50 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 3b2376af9c
commit 41c7495844
3 changed files with 114 additions and 18 deletions
+25 -7
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 leaves them and moves a duplicate → {index}",
has_doc,
artboard_move
),
@@ -399,15 +399,33 @@ 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;
let copy = bool_or(p, "copy", false);
let index = 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.
let target = if copy {
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);
d.artboards.push(a);
d.artboards.len() - 1
} else {
i
};
if let Some(a) = d.artboards.get_mut(target) {
a.rect = src.rect + dv;
}
for id in &art {
if let Some(n) = d.node_mut(*id) {
if copy {
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);
d.insert(parent, at + 1, c)?;
} else if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
Ok(())
Ok(target)
})?;
Ok(json!({ "index": i, "moved": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(json!({ "index": index, "moved": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+27
View File
@@ -265,6 +265,33 @@ 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));
}
#[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]);
}
}