diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index b021e4e6..d60daf49 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -147,6 +147,12 @@ fn delete(s: &mut Session, p: &Value) -> Result { if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { return s.execute("effect.remove", json!({})); } + // Masks, shape items, text animators or trackers selected → remove them. + if p.get("layers").is_none() + && let Some(n) = super::prop_groups::remove_selected(s)? + { + return Ok(n); + } let (cid, ids) = layers_p(s, p)?; let ids = super::unlocked(s, cid, ids, "edit.clear")?; // Children of deleted layers are unparented where they are (like After Effects). diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index 7f5f2a2c..6ead8834 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -51,17 +51,38 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { } fn remove(s: &mut Session, p: &Value) -> Result { - let c = "prop.removeGroup"; - let (cid, lid, uid) = group_ref(s, p, c, true)?; + let (cid, lid, uid) = group_ref(s, p, "prop.removeGroup", true)?; + remove_groups(s, cid, &[(lid, uid)])?; + Ok(Value::Null) +} + +/// Remove groups of comp `cid` in one undo step. +fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)]) -> Result<()> { s.edit("Delete", None, |proj, st| { - let l = layer_mut(proj, cid, lid)?; - let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?; - parent.children.retain(|n| n.uid() != uid); - st.selected_props.retain(|(_, u)| *u != uid); - Ok(Value::Null) + for (lid, uid) in groups { + // A group inside one removed before it went with it. + if let Some(parent) = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid) { + parent.children.retain(|n| n.uid() != *uid); + } + } + st.selected_props.retain(|(_, u)| !groups.iter().any(|(_, g)| g == u)); + Ok(()) }) } +/// Edit ▸ Clear with masks, shape items, text animators or trackers selected in the active comp: +/// removes those groups (not their layers) in one undo step. None when none are selected. +pub(crate) fn remove_selected(s: &mut Session) -> Result> { + let (Some(cid), Some(c)) = (s.active_comp_id(), s.active_comp()) else { return Ok(None) }; + let groups: Vec<(LayerId, Uid)> = + s.state.selected_props.iter().filter(|(lid, uid)| c.layer(*lid).and_then(|l| l.props.find_group(*uid)).is_some_and(is_instance)).copied().collect(); + if groups.is_empty() { + return Ok(None); + } + remove_groups(s, cid, &groups)?; + Ok(Some(json!(groups.len()))) +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let c = "prop.duplicateGroup"; let (cid, lid, uid) = group_ref(s, p, c, true)?; diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index 26eedd2a..b9363f65 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -124,3 +124,46 @@ fn property_group_edits() { let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid; assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err()); } + +/// Delete with a text animator, mask or shape group selected removes just those groups, in one +/// undo step, and keeps their layers (#151). A fixed group selected still deletes the layer. +#[test] +fn delete_removes_selected_groups_not_their_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap(); + let t = s.execute("layer.newText", json!({"text": "ABCD"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addMask", json!({"layer": sh, "shape": "ellipse"})).unwrap(); + let groups = |s: &Session, l: u64, path: &str| -> Vec { + s.active_comp().unwrap().layer(LayerId(l)).map_or(vec![], |l| l.props.group(path).unwrap().groups().map(|g| g.uid).collect()) + }; + let anim = groups(&s, t, "text/animators")[0]; + let (mask, shape) = (groups(&s, sh, "masks")[0], groups(&s, sh, "contents")[0]); + let delete = |s: &mut Session| { + let undo = s.history.undo.len(); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + }; + + s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap(); + let before = s.project.clone(); + delete(&mut s); + assert!(groups(&s, t, "text/animators").is_empty(), "the animator is gone"); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "its layer stays"); + assert!(s.state.selected_props.is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project, before); + + s.execute("prop.select", json!({"layer": sh, "prop": mask, "selectKeys": false})).unwrap(); + s.execute("prop.select", json!({"layer": sh, "prop": shape, "selectKeys": false, "add": true})).unwrap(); + delete(&mut s); + assert!(groups(&s, sh, "masks").is_empty()); + assert!(!groups(&s, sh, "contents").contains(&shape)); + assert_eq!(s.active_comp().unwrap().layers.len(), 2); + + let tr = s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": t, "prop": tr, "selectKeys": false})).unwrap(); + delete(&mut s); + assert!(s.active_comp().unwrap().layer(LayerId(t)).is_none(), "a fixed group selected: the layer goes"); +}