Merge pull request #1730

# Conflicts:
#	crates/engine/src/commands/prop_groups.rs
This commit is contained in:
Brandon Thomas
2026-10-18 09:36:40 -04:00
2 changed files with 60 additions and 2 deletions
+26 -2
View File
@@ -143,8 +143,9 @@ pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<
}
/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's
/// Contents, above its topmost selected item (in that item's group), else on top. Names stay
/// unique in their group ("Rectangle 2"…), and the copies are selected.
/// Contents, above its topmost selected item (in that item's group), else on top (paints and path
/// operations alone: at the bottom). Names stay unique in their group ("Rectangle 2"…), and the
/// copies are selected.
pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.paste";
let clip = s.state.contents_clipboard.clone();
@@ -156,6 +157,9 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(c, "select a shape layer to paste shape items into"));
}
let sel = selected_contents(s);
let acts_on_above = clip.iter().all(|g| !matches!(g.match_id.as_str(), "group" | "rect" | "ellipse" | "star" | "path"));
// Merge Paths clears the draws above it, so at the bottom it would erase the paints (#1681).
let has_merge = clip.iter().any(|g| g.match_id == "merge");
let label = match clip.as_slice() {
[g] => format!("Paste {}", g.name),
_ => "Paste".to_string(),
@@ -170,11 +174,19 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
let parent = l.props.parent_of(*u)?;
Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?))
});
let unselected = spot.is_none();
let (parent, at) = match spot {
Some((pu, i)) => (l.props.find_group_mut(pu), i),
None => (l.props.sub_mut("contents"), 0),
};
let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?;
// Trim Paths, Wiggle Paths, fills… act on the items above them (as Add places them),
// so with nothing selected they go to the bottom: on top they'd affect nothing (#1681).
let at = match unselected {
true if has_merge => merge_spot(parent),
true if acts_on_above => parent.children.len(),
_ => at,
};
for (k, g) in clip.iter().enumerate() {
let mut g = g.clone();
g.reassign_uids(&mut next);
@@ -190,6 +202,18 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
/// Where Merge Paths goes in `contents` when nothing is selected: before the first paint, so the
/// paints still draw the merged path (the renderer drops the draws above a Merge Paths), else after
/// the last path item.
fn merge_spot(contents: &PropGroup) -> usize {
let is = |n: &Node, ids: &[&str]| matches!(n, Node::Group(g) if ids.contains(&g.match_id.as_str()));
let paints = ["fill", "stroke", "gfill", "gstroke"];
match contents.children.iter().position(|n| is(n, &paints)) {
Some(i) => i,
None => contents.children.iter().rposition(|n| is(n, &["rect", "ellipse", "star", "path"])).map_or(0, |i| i + 1),
}
}
/// 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.
+34
View File
@@ -1595,6 +1595,40 @@ fn shape_contents_copy_cut_and_paste_between_shape_layers() {
assert!(s.execute("edit.paste", json!({})).is_err());
}
/// Pasting Trim Paths into another shape layer with nothing selected puts it below the shapes,
/// where it acts on them (it acts on the items above it); on top it would trim nothing (#1681, #1629).
#[test]
fn pasted_path_operator_goes_below_the_shapes_it_acts_on() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let trim = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "trim"})).unwrap()["uid"].as_u64().unwrap();
let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": a, "prop": trim})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Polystar 1", "Trim Paths 1"]);
}
/// Pasting Merge Paths onto a painted layer with nothing selected keeps the paint: the renderer
/// drops the draws above a Merge Paths, so it goes between the path and the paints (#1681).
#[test]
fn pasted_merge_paths_keeps_the_targets_paint() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "none", "name": "A"})).unwrap()["layer"].as_u64().unwrap();
let merge = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "merge"})).unwrap()["uid"].as_u64().unwrap();
let b = s.execute("layer.newShape", json!({"kind": "none", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "rect"})).unwrap();
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "fill"})).unwrap();
let before = opaque_centroid(&s).2;
s.execute("prop.select", json!({"layer": a, "prop": merge})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Rectangle Path 1", "Merge Paths 1", "Fill 1"]);
assert_eq!(opaque_centroid(&s).2, before);
}
/// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the
/// original and named "Rectangle 2", not the layer (#227).
#[test]