diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 5b2a44124..9ccdb4d87 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "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 { } } 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::>() })) + Ok(json!({ "index": index, "moved": art.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 9ca4f43e6..dd477fae5 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -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(); diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 19b351d33..d07e4ba30 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -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]); + } }