diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 743aa8362..56dd8bd82 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -120,6 +120,12 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { }) }) .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 = 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 { + fn rec( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + shapes: &[Shape], + id: ShapeId, + x: &deckcraft_geom::Xfrm, + depth: usize, + ) -> Option { + 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())) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b3ea86699..1c8d8e188 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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::::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 {