diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3d087e07..3ea27b0d 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { if plan.is_empty() { return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); } - s.edit("Paste Keyframes", None, |proj, st| { + s.edit("Paste Keyframes", merge_p(p), |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut sel = vec![]; for (lid, uid, ci) in &plan { diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 45ab1395..e5d2f912 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -6,7 +6,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p}; -use crate::{EngineError, LinkClip, Result, Session, cmd}; +use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } @@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { && s.state.effect_clipboard.is_empty() && s.state.contents_clipboard.is_empty() && s.state.link_clipboard.is_none() + && s.state.value_clipboard.is_empty() { Err("the clipboard is empty".into()) } else { @@ -210,14 +211,21 @@ fn clear_clipboards(s: &mut Session) { s.state.effect_clipboard.clear(); s.state.contents_clipboard.clear(); s.state.link_clipboard = None; + s.state.value_clipboard.clear(); s.state.clip_is_keys = false; } -/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers. +/// Edit ▸ Copy: selected properties copy their keyframes and values, selected keyframes go +/// to the keyframe clipboard, else layers. fn copy(s: &mut Session, p: &Value) -> Result { if s.state.text_edit.is_some() && p.get("layers").is_none() { return super::text_edit::copy(s); } + if p.get("layers").is_none() + && let Some(r) = copy_properties(s)? + { + return Ok(r); + } // Keyframes selected → copy keys (pasted at the CTI). if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { s.state.link_clipboard = None; @@ -247,6 +255,101 @@ fn copy(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.clipboard.len())) } +/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After +/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and +/// an unanimated one its value, for Paste onto the same properties of other layers (#493). +/// `None` when no property (or Transform group) is selected. +fn copy_properties(s: &mut Session) -> Result> { + let Some(comp) = s.active_comp() else { return Ok(None) }; + let mut leaves = vec![]; + for (lid, uid) in &s.state.selected_props { + let Some(l) = comp.layer(*lid) else { continue }; + if l.props.find(*uid).is_some() { + leaves.push((*lid, *uid)); + } else if let Some(g) = l.props.find_group(*uid) + && l.props.match_path_of(*uid).as_deref() == Some("transform") + { + g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid))); + } + } + if leaves.is_empty() { + return Ok(None); + } + let mut keys = s.state.selected_keys.clone(); + let mut values = vec![]; + for (lid, uid) in &leaves { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(pr) = l.props.find(*uid) else { continue }; + if pr.keys.is_empty() { + if let Some(path) = l.props.match_path_of(*uid) + && !values.iter().any(|(p, _)| *p == path) + { + values.push((path, pr.value.clone())); + } + } else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { + keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); + } + } + clear_clipboards(s); + let mut n = 0; + if !keys.is_empty() { + let selected = std::mem::replace(&mut s.state.selected_keys, keys); + let r = s.execute("keys.copy", json!({})); + s.state.selected_keys = selected; + n = r?.as_u64().unwrap_or(0); + } + let v = values.len(); + s.state.value_clipboard = values; + // Keys alone answer as `keys.copy` does (their count). + Ok(Some(if v == 0 { json!(n) } else { json!({"keys": n, "values": v}) })) +} + +/// Paste copied property values (and keyframes, at the CTI) onto the same properties of the +/// selected layers, as one undo step. +fn paste_properties(s: &mut Session, p: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let ids = s.state.selected_layers.clone(); + if ids.is_empty() { + return Err(EngineError::Other("select a layer to paste into".into())); + } + let values = s.state.value_clipboard.clone(); + let keys = s.state.clip_is_keys && !s.state.key_clipboard.is_empty(); + let t = s.time(); + // The values and the keys are one step: the keys' paste merges into the values' step. + let merge = "edit.paste.properties"; + s.history.merge_key = None; + let n = s.edit("Paste", keys.then_some(merge), |proj, _| { + let mut n = 0; + for lid in &ids { + let l = super::layer_mut(proj, cid, *lid)?; + if l.switches.locked { + continue; + } + let lt = l.layer_time(t); + for (path, v) in &values { + if let Some(pr) = l.props.prop_mut(path) + && let Some(v) = pr.value.coerce_json(&v.to_json()) + { + pr.set_value_at(lt, v); + n += 1; + } + } + } + Ok(n) + })?; + let mut r = json!({"values": n}); + if keys { + let mut kp = p.clone(); + if let Some(o) = kp.as_object_mut() { + o.insert("merge".into(), json!(merge)); + } + let pasted = s.execute("keys.paste", kp); + s.history.merge_key = None; + r["keys"] = pasted?; + } + Ok(r) +} + /// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`. pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option { let cid = s.active_comp_id()?; @@ -392,6 +495,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + if !s.state.value_clipboard.is_empty() { + return paste_properties(s, p); + } if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { return s.execute("keys.paste", p.clone()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d09cbf2b..eae96a81 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -216,6 +216,10 @@ pub struct EditorState { /// The last copy was keyframes (Paste pastes keys at the CTI). #[serde(skip)] pub clip_is_keys: bool, + /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; + /// Paste sets the same properties of the selected layers. + #[serde(skip)] + pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, /// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the /// selected layers. #[serde(skip)] diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index df0af369..c7252acb 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -346,6 +346,53 @@ fn keyframe_clipboard_paste_and_paste_reversed() { assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0)); } +/// #493: Edit ▸ Copy with a property selected copies that property (its value when it isn't +/// animated, its keys when it is), and Paste sets the same property of the selected layer, as +/// in After Effects, instead of copying the whole layer. +#[test] +fn copy_a_selected_property_pastes_onto_the_same_property() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let n_layers = s.active_comp().unwrap().layers.len(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.state.clipboard.is_empty(), "the layer isn't copied"); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + assert_eq!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().value.as_f64(), 0.0, "only the copied property"); + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(s.history.undo.len(), undo); + // The Transform group with an animated Opacity: its keys (at the CTI) and the other values, + // in one undo step. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap(); + let transform = layer(&s, a).props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let l = layer(&s, b); + let keys = &l.props.prop("transform/opacity").unwrap().keys; + assert_eq!(keys.len(), 2); + assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[1].value.as_f64() == 0.0); + assert_eq!(l.props.prop("transform/rotation").unwrap().value.as_f64(), 30.0); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + s.execute("edit.undo", json!({})).unwrap(); + let l = layer(&s, b); + assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty() && l.props.prop("transform/rotation").unwrap().value.as_f64() == 0.0); +} + #[test] fn property_links_and_expression_only() { let mut s = comp();