From 0048569682bbaded2e3e533872a55ee7d500572e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:21:46 +0700 Subject: [PATCH] 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 --- crates/engine/src/commands/edit.rs | 7 ++++- crates/engine/src/commands/prop_groups.rs | 24 +++++++++++++++ crates/engine/src/tests_menu_cmds.rs | 35 ++++++++++++++++++++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 10 +++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index a15694ca..5e1b7495 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -109,6 +109,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { } 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 { ), 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), diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index 419a2b11..d070713a 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -174,6 +174,30 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result { Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::>()})) } +/// 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 { + 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::>()})) +} + 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_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index eb09ff25..c342f72f 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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 = 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); +} diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 8f11b027..a37bddf3 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -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();