mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Fix #2091 copy/paste masks between layers
Edit > Copy with masks selected copied the layer, so Paste duplicated it instead of adding the mask. Masks now go to their own clipboard and Paste appends them, with unique names, to the selected unlocked layers. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fbb5488214
commit
edb984a75a
6 files changed
+123
No files matched your search
@@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
&& s.state.key_clipboard.is_empty()
|
||||
&& s.state.effect_clipboard.is_empty()
|
||||
&& s.state.contents_clipboard.is_empty()
|
||||
&& s.state.mask_clipboard.is_empty()
|
||||
&& s.state.link_clipboard.is_none()
|
||||
&& s.state.value_clipboard.is_empty()
|
||||
{
|
||||
@@ -285,6 +286,7 @@ fn clear_clipboards(s: &mut Session) {
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.effect_clipboard.clear();
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.mask_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.value_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
@@ -311,6 +313,15 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
|
||||
return s.execute("effect.copy", json!({}));
|
||||
}
|
||||
// Masks selected → copy them (not their layer), for Paste onto other layers (#2091).
|
||||
if p.get("layers").is_none() {
|
||||
let masks = super::prop_groups::copy_masks(s);
|
||||
if !masks.is_empty() {
|
||||
clear_clipboards(s);
|
||||
s.state.mask_clipboard = masks;
|
||||
return Ok(json!({"masks": s.state.mask_clipboard.len()}));
|
||||
}
|
||||
}
|
||||
// Shape items selected in a shape layer's Contents → copy them (not their layer).
|
||||
if p.get("layers").is_none() {
|
||||
let items = super::prop_groups::copy_contents(s);
|
||||
@@ -597,6 +608,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !s.state.contents_clipboard.is_empty() {
|
||||
return super::prop_groups::paste_contents(s, p);
|
||||
}
|
||||
if !s.state.mask_clipboard.is_empty() {
|
||||
return super::prop_groups::paste_masks(s, p);
|
||||
}
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let clip = s.state.clipboard.clone();
|
||||
let new = s.edit("Paste", None, |proj, st| {
|
||||
|
||||
@@ -851,6 +851,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.mask_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
let n = groups.len();
|
||||
|
||||
@@ -135,6 +135,59 @@ pub(crate) fn copy_contents(s: &Session) -> Vec<PropGroup> {
|
||||
selected_contents(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect()
|
||||
}
|
||||
|
||||
/// Masks selected on the active comp's layers, top to bottom (#2091).
|
||||
pub(crate) fn selected_masks(s: &Session) -> Vec<(LayerId, Uid)> {
|
||||
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return vec![] };
|
||||
c.layers
|
||||
.iter()
|
||||
.flat_map(|l| {
|
||||
let masks = l.masks().into_iter().flat_map(|m| m.groups());
|
||||
masks.filter(|m| s.state.selected_props.contains(&(l.id, m.uid))).map(|m| (l.id, m.uid))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copies of the selected masks (Edit ▸ Copy), top to bottom.
|
||||
pub(crate) fn copy_masks(s: &Session) -> Vec<PropGroup> {
|
||||
let Some(c) = s.active_comp() else { return vec![] };
|
||||
selected_masks(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect()
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste with masks on the clipboard: each is added, with a unique name, below the masks of
|
||||
/// every selected unlocked layer that can have masks, and the copies are selected (#2091).
|
||||
pub(crate) fn paste_masks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "edit.paste";
|
||||
let clip = s.state.mask_clipboard.clone();
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let targets: Vec<LayerId> = ids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| l.masks().is_some() && !l.switches.locked)).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(bad(c, "select an unlocked layer that can have masks to paste masks into"));
|
||||
}
|
||||
let label = match clip.as_slice() {
|
||||
[g] => format!("Paste {}", g.name),
|
||||
_ => "Paste".to_string(),
|
||||
};
|
||||
let pasted = s.edit(&label, None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut out = vec![];
|
||||
for lid in &targets {
|
||||
let masks = layer_mut(proj, cid, *lid)?.props.sub_mut("masks").ok_or_else(|| bad(c, "the layer can't have masks"))?;
|
||||
for g in &clip {
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
g.name = super::effect::unique_name(masks, &g.name);
|
||||
out.push((*lid, g.uid));
|
||||
masks.children.push(g.into());
|
||||
}
|
||||
}
|
||||
proj.next_id = next + 1;
|
||||
st.selected_props = out.clone();
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"masks": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Edit ▸ Cut's removal of the selected shape items (once copied).
|
||||
pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
|
||||
@@ -98,6 +98,9 @@ pub struct EditorState {
|
||||
/// a shape layer's Contents); Paste adds them to the selected shape layers.
|
||||
#[serde(skip)]
|
||||
pub contents_clipboard: Vec<effectcraft_project::PropGroup>,
|
||||
/// Copied masks (Edit ▸ Copy with masks selected); Paste adds them to the selected layers.
|
||||
#[serde(skip)]
|
||||
pub mask_clipboard: Vec<effectcraft_project::PropGroup>,
|
||||
/// Selected mask vertices (viewer Selection tool / pen).
|
||||
#[serde(default)]
|
||||
pub selected_vertices: Vec<VertexRef>,
|
||||
|
||||
@@ -1943,3 +1943,53 @@ fn old_guides_load_as_pixel_guides_and_percent_round_trips() {
|
||||
// A zero-sized comp never divides by zero.
|
||||
assert!(g.px(0, 0).is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_masks_between_layers() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let n = s.active_comp().unwrap().layers.len();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("layer.addMask", json!({})).unwrap();
|
||||
s.execute("layer.addMask", json!({"shape": "ellipse"})).unwrap();
|
||||
let names = |s: &Session, id: u64| layer(s, id).masks().unwrap().groups().map(|m| m.name.clone()).collect::<Vec<_>>();
|
||||
let uids = |s: &Session, id: u64| layer(s, id).masks().unwrap().groups().map(|m| m.uid).collect::<Vec<_>>();
|
||||
let first = uids(&s, a)[0];
|
||||
s.execute("prop.select", json!({"layer": a, "prop": first})).unwrap();
|
||||
// Copy the mask: it is the mask that is copied, not its layer.
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["masks"], json!(1));
|
||||
assert!(s.state.clipboard.is_empty(), "not the layer");
|
||||
assert!(s.is_enabled("edit.paste"));
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
|
||||
assert_eq!(names(&s, b), ["Mask 1"]);
|
||||
let pasted = r["masks"][0].as_u64().unwrap();
|
||||
assert_eq!(uids(&s, b), [pasted]);
|
||||
assert_ne!(pasted, first);
|
||||
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(b), pasted)]);
|
||||
assert_eq!(names(&s, a), ["Mask 1", "Mask 2"], "the source keeps its masks");
|
||||
// Pasting again onto the same layer keeps names unique.
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(names(&s, b), ["Mask 1", "Mask 2"]);
|
||||
// One undo step per paste.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(names(&s, b), ["Mask 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_masks_skips_locked_layers() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("layer.addMask", json!({})).unwrap();
|
||||
let mask = layer(&s, a).masks().unwrap().groups().next().unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": a, "prop": mask})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({})).is_err());
|
||||
assert_eq!(layer(&s, b).masks().unwrap().groups().count(), 0);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ fn clipboard_text(s: &effectcraft_engine::Session) -> String {
|
||||
n(st.effect_clipboard.len(), "effect", "effects")
|
||||
} else if !st.contents_clipboard.is_empty() {
|
||||
n(st.contents_clipboard.len(), "shape item", "shape items")
|
||||
} else if !st.mask_clipboard.is_empty() {
|
||||
n(st.mask_clipboard.len(), "mask", "masks")
|
||||
} else if st.link_clipboard.is_some() {
|
||||
"EffectCraft: property links".into()
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user