From d680914140791b0f0598f73249a848c8b2531605 Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan <126534658+scks1@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:21:59 +0000 Subject: [PATCH] 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. --- crates/engine/src/cmd/edit.rs | 39 +++++++++++++++++++++ crates/engine/src/tests.rs | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) 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 5d2310328..91c603d55 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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::::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 {