From 41c749584437031d886bd7c078625f83a4c01cf6 Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:36:10 -0700 Subject: [PATCH 1/2] 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 " 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 --- crates/engine/src/cmd/xform.rs | 32 ++++++++++--- crates/engine/src/tests_xform.rs | 27 +++++++++++ crates/tools/src/xform/artboard.rs | 73 +++++++++++++++++++++++++----- 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 5b2a4412..9ccdb4d8 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 9ca4f43e..dd477fae 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 19b351d3..d07e4ba3 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]); + } } From 17e654a738c4b56840fce533b420a7a6fe9df060 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 01:40:23 +0700 Subject: [PATCH 2/2] Artboard tool: share the artboard copy with Duplicate Artboard, report the copies (review of #348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit artboard.move with copy and artboard.duplicate now make the copy with one helper: a new id that can't overflow and a name of its own (` copy`, then ` copy 2`…, so two copies of one artboard no longer share a name). `moved` lists the copies a copy moved rather than the originals it left. Test it with and without moveArt, and note Alt-drag in the ROADMAP. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/panelcmds.rs | 22 +++++++++++---- crates/engine/src/cmd/xform.rs | 45 ++++++++++++++---------------- crates/engine/src/tests_xform.rs | 21 ++++++++++++++ 4 files changed, 59 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f014330a..895f0e2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 36efb5fd..f896ef68 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -54,17 +54,27 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { 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 ` copy` (` 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 { diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 9ccdb4d8..ef0b86f1 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, copy?: bool} move an artboard (and the unlocked art fully inside it); copy leaves them and moves a duplicate → {index}", + "{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 ), @@ -400,32 +400,29 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result { } let scale_strokes = s.prefs.scale_strokes; let copy = bool_or(p, "copy", false); - let index = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| { + 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. - 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 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); + 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(target) + 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": index, "moved": art.iter().map(|i| i.0).collect::>() })) + Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index dd477fae..03f1ee55 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -292,6 +292,27 @@ fn artboard_alt_drag_duplicates_it_with_its_art() { 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 = 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 = 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();