Merge pull request #1968 from scks1/fix/copy-group-member-slide-box

engine: a shape copied from a group pastes where it was drawn
This commit is contained in:
FredApps authored and GitHub committed 2026-11-13 10:39:13 +01:00
commit 0a75620a73
2 files changed
+104

No files matched your search

+39
View File
@@ -120,6 +120,12 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
})
})
.collect();
// A shape in a group is copied with the box it is drawn in, so it pastes where it was seen.
for sh in &mut shapes {
if let Some(x) = sh.xfrm.and_then(|x| box_on_slide(&st.doc, &st.selection, st.shapes(), sh.id, &x)) {
sh.xfrm = Some(x);
}
}
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
// paste can be in another deck. Slide Master view pastes the originals instead.
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
@@ -147,6 +153,39 @@ pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::
}
}
/// Where shape `id` is drawn on the slide when it is inside a group: `x`, its box in its group's
/// space, mapped out through each group around it, as Ungroup places a freed shape. `None` for a
/// shape on the top level or not found. A turned shape in a group stretched more one way than the
/// other is drawn skewed, which a box cannot hold, so it gets the unskewed box Ungroup gives it.
fn box_on_slide(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
shapes: &[Shape],
id: ShapeId,
x: &deckcraft_geom::Xfrm,
) -> Option<deckcraft_geom::Xfrm> {
fn rec(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
shapes: &[Shape],
id: ShapeId,
x: &deckcraft_geom::Xfrm,
depth: usize,
) -> Option<deckcraft_geom::Xfrm> {
if depth > 64 {
return None;
}
shapes.iter().find_map(|sh| {
let deckcraft_model::ShapeKind::Group { children, child } = &sh.kind else {
return None;
};
let inner = if children.iter().any(|c| c.id == id) { *x } else { rec(doc, sel, children, id, x, depth + 1)? };
Some(super::arrange::child_to_parent(&xfrm_of(doc, sel, sh), child, &inner))
})
}
rec(doc, sel, shapes, id, x, 0)
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
+65
View File
@@ -241,6 +241,71 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty());
}
/// A shape copied or cut from inside a group pastes where it was drawn: on another slide in the
/// same place, on its own slide offset as any paste there is. Its box is mapped out through each
/// group around it, however the groups were moved, stretched, turned or flipped. Before, the
/// clipboard kept the box inside the group, so the shape pasted where it had been before the group
/// was moved or stretched.
#[test]
fn a_shape_copied_from_a_group_pastes_where_it_was_drawn() {
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
}
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
let sites = |s: &Session, id: ShapeId| {
let st = s.doc().unwrap();
connect::sites_of(&st.doc, &st.selection, st.shapes(), id).unwrap()
};
type Edits<'a> = &'a [(&'a str, Value)];
let edit = |s: &mut Session, id: u64, edits: Edits<'_>| {
for (cmd, p) in edits {
let mut p = p.clone();
p["ids"] = json!([id]);
s.execute(cmd, &p).unwrap();
}
};
// Edits to the shape's group, then to a group around that one (none: no outer group).
let cases: [(Edits<'_>, Edits<'_>); 4] = [
(&[("shape.move", json!({"dx": 200}))], &[]),
(&[("shape.resize", json!({"w": 600}))], &[]),
(&[("shape.rotate", json!({"deg": 30})), ("shape.flip", json!({"axis": "vertical"})), ("shape.move", json!({"dx": 50, "dy": 20}))], &[]),
(&[("shape.move", json!({"dx": 200}))], &[("shape.resize", json!({"w": 450})), ("shape.move", json!({"dy": 60}))]),
];
let off = session().prefs.duplicate_offset;
for (n, (inner_edits, outer_edits)) in cases.into_iter().enumerate() {
for (cmd, same_slide) in [("edit.copy", false), ("edit.cut", false), ("edit.copy", true)] {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0));
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
edit(&mut s, g, inner_edits);
if !outer_edits.is_empty() {
let c = rect(&mut s, 100.0, 300.0);
let outer = s.execute("arrange.group", &json!({"ids": [g, c]})).unwrap()["id"].as_u64().unwrap();
edit(&mut s, outer, outer_edits);
}
let was = sites(&s, sid(a));
s.doc_mut().unwrap().selection.shapes = vec![sid(a)];
s.execute(cmd, &json!({})).unwrap();
assert_eq!(s.doc().unwrap().shape(sid(a)).is_some(), cmd == "edit.copy", "case {n}: {cmd}");
if !same_slide {
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
}
let r = s.execute("edit.paste", &json!({})).unwrap();
let pasted = sid(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().shapes().iter().any(|x| x.id == pasted), "case {n}: {cmd} pastes on the top level");
let d = if same_slide { off } else { 0.0 };
let now = sites(&s, pasted);
assert_eq!(was.len(), now.len(), "case {n}: {cmd}");
assert!(
was.iter().zip(&now).all(|(p, q)| (q.x - p.x - d).hypot(q.y - p.y - d) < 1e-6),
"case {n}: {cmd} on the same slide {same_slide}: drawn at {was:?}, pasted at {now:?}"
);
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
}
}
}
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
fn resolved_look(s: &Session, id: ShapeId) -> String {