mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 07:38:16 +00:00
[MM4] A group's box follows its children after a command moves one
shape.move or shape.setBounds on a group's child left the group's box as it was, so inspect_slide reported stale bounds. After any edit that changes a child's box or the child list, the group's box is refitted to the union of its children (its child space too, so every child keeps its place on the slide). A group that was itself moved or resized, and SmartArt groups (regenerated inside their own box), are left alone. PowerPoint: a group's bounding box always hugs its children; dragging one child out grows the group, dragging it back shrinks it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55e5ee56b4
commit
2cad063d1d
3 files changed
+116
No files matched your search
@@ -229,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Shape>>, 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<ShapeId, &Shape> = 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<Xfrm> = 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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
Reference in new issue
Block a user