engine: a shape copied from a group pastes where it was drawn

edit.copy and edit.cut put a shape inside a group on the clipboard with
its box in the group's own space. Pasted, it landed where it would be
drawn without the group's move, stretch, turn or flip: a shape from a
group moved 200 pt to the right pasted 200 pt left of where it was
drawn, and one from a group stretched to twice its width pasted half as
wide.

The copied shape now carries the box it is drawn in on the slide, mapped
out through each group around it as Ungroup maps a freed shape. It
pastes at the slide's top level where it was drawn, offset as any paste
on the same slide is. Cut still deletes the shape from its group.
A turned shape in a group stretched more one way than the other is drawn
skewed, which a box cannot hold; it pastes with the unskewed box Ungroup
gives it. Copying with a text caret is unchanged.

Test: a_shape_copied_from_a_group_pastes_where_it_was_drawn.
This commit is contained in:
Simon Koch-Sultan committed 2026-11-08 01:21:59 +00:00
1 parent fc35ad67c6
commit d680914140
2 files changed
+103

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()))
}
+64
View File
@@ -214,6 +214,70 @@ 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!(
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 {