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();