Shapes: Duplicate duplicates the selected shape items, not the layer (#227)

Edit > Duplicate (Ctrl+D) with a shape group or other shape item
selected in the Timeline duplicated the whole shape layer. As in After
Effects it now duplicates the selected items inside their group, each
copy above its original and numbered on ("Rectangle 1" gives
"Rectangle 2"), and selects the copies. With only layers selected the
layers are duplicated as before.

Test: duplicate_with_shape_items_selected_duplicates_them_in_place,
ctrl_d_duplicates_the_selected_shape_group_in_its_layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 12:21:46 +07:00
parent 95bb346c89
commit 0048569682
4 changed files with 75 additions and 1 deletions

View File

@ -109,6 +109,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
return Ok(json!({"effects": out}));
}
// Shape items selected → duplicate them inside their layers.
let items = super::prop_groups::selected_contents(s);
if !items.is_empty() {
return super::prop_groups::duplicate_contents(s, &items);
}
}
let (cid, ids) = layers_p(s, p)?;
let new = s.edit("Duplicate", None, |proj, st| {
@ -701,7 +706,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),

View File

@ -174,6 +174,30 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and
/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected.
pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
let c = "edit.duplicate";
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let copies = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
let mut out = vec![];
for (lid, uid) in items {
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?;
let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue };
let mut g = g.clone();
g.reassign_uids(&mut next);
g.name = super::effect::unique_name(parent, &g.name);
out.push((*lid, g.uid));
parent.children.insert(i, Node::Group(g));
}
proj.next_id = next + 1;
st.selected_props = out.clone();
Ok(out)
})?;
Ok(json!({"contents": copies.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
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

@ -1111,3 +1111,38 @@ fn shape_contents_copy_cut_and_paste_between_shape_layers() {
s.execute("layer.select", json!({"layers": [sol]})).unwrap();
assert!(s.execute("edit.paste", json!({})).is_err());
}
/// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the
/// original and named "Rectangle 2", not the layer (#227).
#[test]
fn duplicate_with_shape_items_selected_duplicates_them_in_place() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let n = s.active_comp().unwrap().layers.len();
let rect = contents_item(&s, a, "Rectangle 1");
s.execute("prop.select", json!({"layer": a, "prop": rect})).unwrap();
let r = s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(contents_names(&s, a), ["Rectangle 2", "Rectangle 1"]);
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
let copy = r["contents"][0].as_u64().unwrap();
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(a), copy)]);
let l = layer(&s, a);
let path_uid = |g: u64| l.props.find_group(g).unwrap().sub("contents").unwrap().groups().next().unwrap().uid;
assert_ne!(path_uid(copy), path_uid(rect), "the copy has its own properties");
// Again, with the copy selected: Rectangle 3, above it.
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(contents_names(&s, a), ["Rectangle 3", "Rectangle 2", "Rectangle 1"]);
s.undo();
s.undo();
assert_eq!(contents_names(&s, a), ["Rectangle 1"]);
// An item inside a group is duplicated in that group.
let fill = layer(&s, a).props.find_group(rect).unwrap().sub("contents").unwrap().groups().find(|g| g.match_id == "fill").unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": fill})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
let inner: Vec<String> = layer(&s, a).props.find_group(rect).unwrap().sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(inner, ["Rectangle Path 1", "Fill 2", "Fill 1"]);
// With only the layer selected, the layer is duplicated as before.
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), n + 1);
}

View File

@ -171,6 +171,16 @@ fn ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer() {
assert_eq!(contents_names(&h, a), ["Rectangle 1"]);
}
#[test]
fn ctrl_d_duplicates_the_selected_shape_group_in_its_layer() {
let (mut h, a, _, rect) = two_shape_layers();
click(&mut h, &format!("timeline.group.{rect}.name"));
h.input_mut().events.push(Event::Key { key: Key::D, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 2, "no new layer");
assert_eq!(contents_names(&h, a), ["Rectangle 2", "Rectangle 1"]);
}
#[test]
fn toolbar_stroke_edits_the_selected_shape_layer() {
let mut s = session();