diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 44acad8a..46e10ee0 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -115,6 +115,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { if !items.is_empty() { return super::prop_groups::duplicate_contents(s, &items); } + // Text animators or their selectors selected → duplicate them inside their text layers. + let items = super::prop_groups::selected_animator_items(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| { diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index e4d80b16..3a4c9657 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -100,6 +100,22 @@ pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> { out } +/// Text animators and their selectors selected on the active comp's text layers, top to bottom. +/// A selector inside a selected animator isn't listed, since it is copied with it. +pub(crate) fn selected_animator_items(s: &Session) -> Vec<(LayerId, Uid)> { + let mut out = vec![]; + let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out }; + for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Text)) { + if let Some(animators) = l.props.group("text/animators") { + let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u)); + let mut uids = vec![]; + collect_selected(animators, &selected, &mut uids, 0); + out.extend(uids.into_iter().map(|u| (l.id, u))); + } + } + out +} + fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec, depth: usize) { if depth > super::shape_stroke::MAX_DEPTH { return; @@ -174,8 +190,9 @@ 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. +/// Edit ▸ Duplicate with shape items, text animators or selectors 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)?; @@ -183,7 +200,7 @@ pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> R 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 parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the group 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); diff --git a/crates/engine/src/tests_text.rs b/crates/engine/src/tests_text.rs index a57f87a7..84f88fd3 100644 --- a/crates/engine/src/tests_text.rs +++ b/crates/engine/src/tests_text.rs @@ -2,7 +2,7 @@ //! undo/redo and serde round-trips). use effectcraft_keyframe::Value as KV; -use effectcraft_project::{Node, Project, PropGroup}; +use effectcraft_project::{LayerId, Node, Project, PropGroup}; use serde_json::json; use crate::Session; @@ -244,3 +244,27 @@ fn menus_have_text_animation_entries() { assert!(s.is_enabled("layer.enablePerChar3D")); assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator"); } + +/// Duplicate with a Range Selector selected under a text animator copies the selector, not the +/// whole text layer (#1665); a selected animator is copied the same way. +#[test] +fn duplicate_copies_selected_text_selector_not_the_layer() { + let (mut s, t) = session(); + s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap(); + let anims = |s: &Session| s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.group("text/animators").unwrap().clone(); + let selectors = |s: &Session| -> Vec { anims(s).groups().next().unwrap().sub("selectors").unwrap().groups().map(|g| g.uid).collect() }; + let sel = selectors(&s)[0]; + + s.execute("prop.select", json!({"layer": t, "prop": sel, "selectKeys": false})).unwrap(); + s.execute("edit.duplicate", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "no second text layer"); + assert_eq!(selectors(&s).len(), 2, "the selector is copied"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(selectors(&s), vec![sel]); + + let anim = anims(&s).groups().next().unwrap().uid; + s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap(); + s.execute("edit.duplicate", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(anims(&s).groups().count(), 2, "the animator is copied"); +}