diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 9639d0225..669c15d43 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -960,7 +960,7 @@ fn persp_edit( return Err(EngineError::Other("select the objects to transform in perspective".into())); } let mut g = grid_of(&s.doc()?.doc); - let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids)? } else { None }; + let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None }; s.edit(label, |d, sel| { g.adopt_stored_attachments(d); let targets = match &kept { @@ -1164,7 +1164,7 @@ fn plane_move(s: &mut Session, p: &Value) -> Result { // lists attachments of its own (`stores_attachments`). let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) { let on = on_plane(&s.doc()?.doc); - super::object::preview_copies(s, &on)? + super::object::preview_copies(s, &on, false)? } else { None }; diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 174fc5b0d..70240db1e 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -659,6 +659,17 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { return Err(bad("guide.move", "no guide selected (or give an index)")); } let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false)); + if !s.doc()?.selection.is_empty() { + let ids = super::edit::selected_roots(s)?; + return super::object::apply_transform( + s, + if copy { "Copy Guide" } else { "Move Guide" }, + ids, + vectorcraft_geom::Affine::translate((dx, dy)), + p, + true, + ); + } s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| { let mut copies = vec![]; for &i in &moving { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a41287441..8a6db151c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { ["Object", "Transform"], Some("Cmd+Shift+M"), "{dx, dy, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", - has_selection, + has_selection_or_guides, move_cmd ), cmd!( @@ -297,11 +297,16 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf let objects = transforms_objects(p); let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; sc.patterns = transform_patterns(s, p, &ids)?; + let c = xf.as_coeffs(); + let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; + let delta = Vec2::new(c[4], c[5]); + let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; + let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); // A transform of ids that name no object, or an identity transform without a copy, moves // nothing: the document and its undo history stay as they are, and Transform Again repeats it. // With a copy, the reply lists the copies, and there are none. let d = &s.doc()?.doc; - let none = d.index_paths(ids.iter().copied()).is_empty(); + let none = d.index_paths(ids.iter().copied()).is_empty() && !has_guides; let ids = if anchors && objects && !copy && !s.doc()?.selection.anchors.is_empty() { if xf != Affine::IDENTITY { super::path::transform_anchors(s, label, xf, sc)?; @@ -310,7 +315,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf } else if none || (!copy && xf == Affine::IDENTITY) { if copy { vec![] } else { ids } } else { - let kept = if copy { preview_copies(s, &ids)? } else { None }; + let kept = if copy { preview_copies(s, &ids, with_guides)? } else { None }; s.edit(label, |d, sel| { let targets = match &kept { // The document becomes the snapshot with the kept copies, untransformed, and the @@ -320,7 +325,21 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf sel.clone_from(&k.selection); k.ids.clone() } - None if copy => duplicate_in(d, sel, &ids, Affine::IDENTITY)?, + None if copy => { + let guide_copies = if with_guides { + let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + (!copies.is_empty()).then_some(copies) + } else { + None + }; + let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; + if let Some(copies) = guide_copies { + let first = d.guides.len(); + d.guides.extend(copies); + sel.guides = (first..d.guides.len()).collect(); + } + new_ids + } None => ids.clone(), }; // Transforms don't move nodes in the tree, so the paths found up front stay right. @@ -332,6 +351,13 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf transform_node(n, xf, sc, objects); } } + if with_guides && (delta.x != 0.0 || delta.y != 0.0) { + for i in sel.guides.clone() { + if let Some(g) = d.guides.get_mut(i) { + *g = g.moved(delta); + } + } + } Ok(targets) })? }; @@ -355,21 +381,30 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf /// untransformed, and the interaction keeps that clone. It returns `None` outside an interaction, /// and when the document is no longer the snapshot, as for a command run in a typing session or a /// script batch after an earlier edit there: the command then copies the current document. -pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId]) -> Result>> { +pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: bool) -> Result>> { let st = s.doc_mut()?; let Some(it) = &mut st.interaction else { return Ok(None) }; if !Arc::ptr_eq(&st.doc, &it.doc) { return Ok(None); } + let has_guide_copies = with_guides && !st.selection.guides.is_empty(); if let Some(k) = &it.copies && Arc::ptr_eq(&k.from, &it.doc) && k.roots == roots + && (!k.selection.guides.is_empty() == has_guide_copies) { return Ok(Some(k.clone())); } let mut doc = it.doc.clone(); let mut selection = st.selection.clone(); let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?; + if has_guide_copies { + let d = Arc::make_mut(&mut doc); + let first = d.guides.len(); + let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + d.guides.extend(copies); + selection.guides = (first..d.guides.len()).collect(); + } let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids }); it.copies = Some(k.clone()); Ok(Some(k)) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index bf1dcc106..e8de362e1 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -181,6 +181,9 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { /// objects selected whole (those with no entry in the selection's `anchors`). pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_geom::Affine, sc: Scaling) -> Result<()> { let sel = s.doc()?.selection.clone(); + let c = xf.as_coeffs(); + let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; + let delta = vectorcraft_geom::Vec2::new(c[4], c[5]); s.edit(label, |doc, _| { for id in &sel.objects { match sel.anchors.get(id) { @@ -199,6 +202,13 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } } } + if is_translation && (delta.x != 0.0 || delta.y != 0.0) { + for &i in &sel.guides { + if let Some(g) = doc.guides.get_mut(i) { + *g = g.moved(delta); + } + } + } Ok(()) }) } diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index e1edfebf5..7855d884f 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -1579,6 +1579,119 @@ fn guides_and_art_are_shift_selected_together() { assert_eq!(guides(&s).len(), 2); } +/// When ruler guides and art are selected together, moving the art moves the guides as well; +/// copy leaves both in place and moves the copies; single undo reverts both. +#[test] +fn guides_and_art_move_together_when_selected() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + + // Select art and both guides. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); + assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0, 1])); + + // 1. Moving via object.transform moves both art and guides in one step. + let n = undo_len(&s); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 25, 35]})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!((b.x0, b.y0), (35.0, 45.0)); + assert_eq!(guides(&s), vec![(true, 225.0), (false, 335.0)]); + assert_eq!(undo_len(&s), n + 1); + + // Undo restores both in one step. + s.execute("edit.undo", &json!({})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!((b.x0, b.y0), (10.0, 10.0)); + assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + + // Redo reapplies both. + s.execute("edit.redo", &json!({})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!((b.x0, b.y0), (35.0, 45.0)); + assert_eq!(guides(&s), vec![(true, 225.0), (false, 335.0)]); + + // Undo back to start. + s.execute("edit.undo", &json!({})).unwrap(); + + // 2. object.move also moves both. + s.execute("object.move", &json!({"dx": 15, "dy": 20})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!((b.x0, b.y0), (25.0, 30.0)); + assert_eq!(guides(&s), vec![(true, 215.0), (false, 320.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + + // 3. Arrow-key nudge moves both. + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + let k = s.prefs.keyboard_increment; + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!(b.x0, 10.0 + k); + assert_eq!(guides(&s)[0], (true, 200.0 + k)); + assert_eq!(guides(&s)[1], (false, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + + // 4. Copy (e.g. Alt-drag) duplicates both art and guides, leaving originals in place. + let res = s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 50, 60], "copy": true})).unwrap(); + let copied_id = NodeId(res["ids"][0].as_u64().unwrap()); + assert_ne!(copied_id, a); + let b_orig = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + let b_copy = s.doc().unwrap().doc.bounds_of(&[copied_id], false).unwrap(); + assert_eq!((b_orig.x0, b_orig.y0), (10.0, 10.0)); + assert_eq!((b_copy.x0, b_copy.y0), (60.0, 70.0)); + assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0), (true, 250.0), (false, 360.0)]); + assert_eq!((selected(&s), selected_guides(&s)), (vec![copied_id], vec![2, 3])); + + // Undo takes back both copies in one step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + assert!(s.doc().unwrap().doc.node(copied_id).is_none()); + assert!(s.doc().unwrap().doc.node(a).is_some()); + + // 5. Explicit ids: only the specified art moves, guides stay put. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); + s.execute("object.transform", &json!({"ids": [a.0], "matrix": [1, 0, 0, 1, 40, 50]})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!((b.x0, b.y0), (50.0, 60.0)); + assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + + // 6. Interactive preview copies: dragging with Alt previews the copies and commit keeps them. + s.begin_interaction("Move").unwrap(); + s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 10, 20], "copy": true})).unwrap(); + s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 40], "copy": true})).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(guides(&s).len(), 4); + assert_eq!(guides(&s)[2], (true, 230.0)); + assert_eq!(guides(&s)[3], (false, 340.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + + // 7. Dragging a guide (guide.move) when art is also selected moves both art and guides. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); + s.execute("guide.move", &json!({"dx": 10, "dy": 20})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!((b.x0, b.y0), (20.0, 30.0)); + assert_eq!(guides(&s), vec![(true, 210.0), (false, 320.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert_eq!((b.x0, b.y0), (10.0, 10.0)); + assert_eq!(guides(&s), vec![(true, 200.0), (false, 300.0)]); + + // 8. Copy-dragging a guide (guide.move { copy: true }) when art is also selected duplicates both. + s.execute("guide.move", &json!({"dx": 10, "dy": 20, "copy": true})).unwrap(); + assert_eq!(guides(&s).len(), 4); + assert_eq!(guides(&s)[2], (true, 210.0)); + assert_eq!(guides(&s)[3], (false, 320.0)); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); +} + /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, /// copied and deleted with it; canvas guides stay put. #[test]