Edit: Copy with a property selected copies that property, and Paste sets it on the selected layers (#493)
Cause: edit.copy handled text editing, selected keyframes, effects and shape items, then fell back to copying the selected layers. Selecting a property selects its keys, so an animated property copied, but an unanimated one (or the Transform group, whose keys prop.select doesn't select) copied the whole layer, and Paste added a duplicate layer instead of setting the property. Fix: with properties (or the Transform group) selected, Edit > Copy copies, as After Effects does, each animated property's keys (the selected ones, else all of them, through keys.copy) and each unanimated property's value into a new value clipboard keyed by match path. Edit > Paste sets those values on the same properties of the selected layers (a key at the CTI where the target is animated) and pastes the keys at the CTI, as one undo step (keys.paste now honours `merge`). Keys alone still answer with their count, as before. Test: copy_a_selected_property_pastes_onto_the_same_property (static Position, then the Transform group with animated Opacity: values and keys land on layer B, no layer is added, one undo step each). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit 8267519ebe20e889196e538de23b85b04a1fa76a)
This commit is contained in:
parent
09b3dbaad9
commit
7e0e641606
|
|
@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
||||
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<Value> {
|
|||
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<Option<Value>> {
|
||||
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<Value> {
|
||||
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<String> {
|
||||
let cid = s.active_comp_id()?;
|
||||
|
|
@ -392,6 +495,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue