Merge pull request #2024 from Muratv07/feature/move-art-and-guides-together

feat: move and copy ruler guides and art together when selected
This commit is contained in:
BFlat
2026-10-16 04:35:38 +07:00
committed by GitHub
5 changed files with 176 additions and 7 deletions
+2 -2
View File
@@ -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<Value> {
// 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
};
+11
View File
@@ -659,6 +659,17 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
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 {
+40 -5
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
["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<NodeId>, 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<NodeId>, 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<NodeId>, 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<NodeId>, 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<NodeId>, 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<Option<Arc<crate::PreviewCopies>>> {
pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: bool) -> Result<Option<Arc<crate::PreviewCopies>>> {
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))
+10
View File
@@ -181,6 +181,9 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
/// 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(())
})
}
+113
View File
@@ -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]