Edit: Delete removes selected animators, masks and shape groups (#151)

Selecting a property group also selects its layer, and edit.clear only
looked for selected keys, mask vertices, puppet pins and effects before
deleting the selected layers, so Delete with a text animator, mask or
shape group selected deleted the whole layer.

edit.clear now removes the selected removable groups (text animators,
masks, shape items, trackers) of the active comp in one undo step before
falling back to layers. It shares the removal with prop.removeGroup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-07 13:21:45 +07:00
parent 2448deeb40
commit 31fc517baa
3 changed files with 77 additions and 7 deletions

View File

@ -147,6 +147,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
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).

View File

@ -51,17 +51,38 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
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<Option<Value>> {
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<Value> {
let c = "prop.duplicateGroup";
let (cid, lid, uid) = group_ref(s, p, c, true)?;

View File

@ -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<u64> {
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");
}