diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 4157a1cf..75c9b79d 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -229,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result { }) } +/// After an edit: refit every group whose children's geometry changed to the union of its +/// children (PowerPoint keeps a group's box on its children, so moving one child grows or +/// shrinks the group). The children keep their place on the slide: the group's box and its child +/// space change together. `old` is the presentation before the edit; only slides the edit +/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to +/// that edit. +pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) { + // Each slide, master and layout is compared with its own earlier state, found by id: a move, + // insertion or deletion shifts the indices, and shape ids repeat across slides. + let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect(); + for slide in new.slides.iter_mut() { + let Some(before) = old_slides.get(&slide.id) else { continue }; + if std::sync::Arc::ptr_eq(before, slide) { + continue; + } + let shapes = &mut std::sync::Arc::make_mut(slide).shapes; + refit_list(Some(&before.shapes), shapes, 0); + } + // A child edited in Slide Master, layout, notes or handout view refits its group there too. + for master in new.masters.iter_mut() { + if let Some(before) = old.masters.iter().find(|m| m.id == master.id) + && !std::sync::Arc::ptr_eq(before, master) + { + refit_master(before, std::sync::Arc::make_mut(master)); + } + } + for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] { + if let (Some(before), Some(after)) = (before, after) + && !std::sync::Arc::ptr_eq(before, after) + { + refit_master(before, std::sync::Arc::make_mut(after)); + } + } +} + +fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) { + refit_list(Some(&old.shapes), &mut new.shapes, 0); + for layout in new.layouts.iter_mut() { + refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0); + } +} + +fn refit_list(old: Option<&Vec>, list: &mut [Shape], depth: usize) { + // Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting + // is bounded like the model's other walks. + if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) { + return; + } + let by_id: std::collections::HashMap = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default(); + for sh in list.iter_mut() { + let before = by_id.get(&sh.id).copied(); + let old_children = before.and_then(|b| match &b.kind { + ShapeKind::Group { children, .. } => Some(children), + _ => None, + }); + let own_before = before.map(|b| { + ( + b.xfrm, + match &b.kind { + ShapeKind::Group { child, .. } => Some(*child), + _ => None, + }, + ) + }); + let smart = sh.diagram.is_some(); + let ShapeKind::Group { children, child } = &mut sh.kind else { continue }; + refit_list(old_children, children, depth + 1); + if smart { + // A SmartArt graphic's box is its own; its shapes are regenerated inside it. + continue; + } + let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue }; + if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() { + continue; + } + let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm); + if same { + continue; + } + let Some(group) = sh.xfrm else { continue }; + let xs: Vec = children.iter().filter_map(|c| c.xfrm).collect(); + let Some(u) = union_bounds(&xs) else { continue }; + if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) { + continue; + } + let union = Xfrm::new(u.x0, u.y0, u.width(), u.height()); + let placed = child_to_parent(&group, child, &union); + sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v }); + *child = union; + } +} + /// A child's xfrm in slide space, given its group. pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm { let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3f645cc..510c0bd0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -695,6 +695,7 @@ impl Session { let mut doc = (*st.doc).clone(); let mut sel = st.selection.clone(); let r = f(&mut doc, &mut sel)?; + cmd::arrange::refit_groups(&st.doc, &mut doc); connect::reroute(&mut doc, &sel); st.doc = Arc::new(doc); st.selection = sel; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4dfc21f4..849705b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5646,3 +5646,26 @@ fn invalid_ids_array_never_falls_back_to_the_selection() { assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}"); } } + +/// MM4: moving a group's child through a command refits the group's box to its children, as +/// PowerPoint does, and the children stay where they were drawn. +#[test] +fn moving_a_group_child_refits_the_group() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let ins = |s: &mut Session, p: serde_json::Value| s.execute("shape.insert", &p).unwrap()["id"].as_u64().unwrap(); + let a = ins(&mut s, json!({"preset": "rect", "rect": [100, 100, 100, 50]})); + let b = ins(&mut s, json!({"preset": "rect", "rect": [300, 100, 100, 50]})); + let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let gb = |s: &Session| s.doc().unwrap().shape(ShapeId(g as u32)).unwrap().xfrm.unwrap(); + assert_eq!((gb(&s).x, gb(&s).w), (100.0, 300.0)); + s.execute("shape.move", &json!({"id": b, "dx": 100, "dy": 60})).unwrap(); + let x = gb(&s); + assert_eq!((x.x, x.y, x.w, x.h), (100.0, 100.0, 400.0, 110.0), "group follows its children: {x:?}"); + // The child is where it was moved to on the slide. + let r = s.execute("slide.inspect", &json!({})).unwrap(); + assert!(r.to_string().contains("400"), "{r}"); + s.execute("shape.setBounds", &json!({"ids": [a], "x": 20, "y": 20, "w": 100, "h": 50})).unwrap(); + let x = gb(&s); + assert_eq!((x.x, x.y, x.w), (20.0, 20.0, 480.0), "{x:?}"); +}