collective

Browse / github.com/storytold/photocraft / patch

Copy releases a clipped layer copied without its base (#9736)

Copy/Paste of clipped text keeps clipping to an unrelated destination base Copying only an editable clipped text layer and pasting it into another document leaves `clipped:true`, even though its source base was not copied. The pasted text then clips to an unrelated destination layer. **Photoshop's actual Copy/Paste releases this relationship in the tested ordinary destination; Duplicate Layer correctly retains it.** Reproduced on Windows 11 with PhotoCraft CLI **0.16.0 (`b1794c6e0`, 2026-10-25)** and actual Photoshop **27.11.0**. This report concerns `edit.copy`/`edit.paste`, not a claim that every transfer gesture should release clipping. ## Reproduction [O

Status
open
Score
0.00
Adopters
0 other operators
Author
NaufalDzz (imported from pull request #9833, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+45 −3 in 2 files
Review
pending
Shared
2026-10-28
Id
21c86c7809a1efff28f4c69d

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/edit_cmds.rs crates/engine/src/cmd/edit_cmds/tests.rs

diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs--- a/crates/engine/src/cmd/edit_cmds.rs+++ b/crates/engine/src/cmd/edit_cmds.rs@@ -100,12 +100,20 @@ fn copy_linked_sources(source: &Document, copies: &mut Document) -> Result<()> { /// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour /// holding it. fn whole_layer(d: &crate::DocState, id: LayerId) -> Option<Box<Document>> {-    let l = d.doc.layer(id).filter(|_| takes_whole(d, id))?;+    let l = copied_layer(&d.doc, id, &[id]).filter(|_| takes_whole(d, id))?;     let mut copies = scratch_doc(&d.doc);-    copies.layers.push(l.clone());+    copies.layers.push(l);     Some(Box::new(copies)) } +/// Layer `id` as Copy takes it with `ids`. It keeps its clipping mask only when its base is copied+/// too: pasted without it, it would clip to whatever it lands on, so Photoshop releases it (#9736).+fn copied_layer(doc: &Document, id: LayerId, ids: &[LayerId]) -> Option<Layer> {+    let mut l = doc.layer(id)?.clone();+    l.clipped &= crate::clip_group::base_of(doc, id).is_some_and(|base| ids.contains(&base));+    Some(l)+}+ /// The layers Copy takes when several are selected and nothing is selected in the pixels /// (Photoshop, #2478): the selected layers that no selected group already carries, bottom-to-top. /// Empty otherwise, and for a single layer ([`takes_whole`] decides that case).@@ -126,7 +134,7 @@ fn multi_copy_ids(d: &crate::DocState) -> Vec<LayerId> { fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {     let mut copies = scratch_doc(&d.doc);     for id in ids {-        let mut l = d.doc.layer(*id)?.clone();+        let mut l = copied_layer(&d.doc, *id, ids)?;         if d.doc.is_background(*id) {             l.name = "Layer 0".into();             l.locks = Default::default();diff --git a/crates/engine/src/cmd/edit_cmds/tests.rs b/crates/engine/src/cmd/edit_cmds/tests.rs--- a/crates/engine/src/cmd/edit_cmds/tests.rs+++ b/crates/engine/src/cmd/edit_cmds/tests.rs@@ -843,6 +843,40 @@ fn paste_several_layers_into_another_document() {     } } +/// #9736: a clipped layer copied without its base pastes released, as Photoshop's Copy/Paste does,+/// so it can't clip to an unrelated layer it lands on. Copied with its base, it stays clipped to it.+#[test]+fn a_clipped_layer_copied_without_its_base_pastes_released() {+    let mut s = session();+    let base = s.active().unwrap().active_layer.unwrap();+    let mid = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());+    s.execute("type.create", json!({"text": "H", "size": 30, "x": 20, "y": 60})).unwrap();+    let text = s.active().unwrap().active_layer.unwrap();+    s.execute("layer.setProps", json!({"layer": text.0, "clipped": true})).unwrap();+    s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();+    s.execute("layer.new.layer", json!({})).unwrap();+    let pasted = |s: &mut Session, ids: &[LayerId]| {+        s.set_active(0);+        s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();+        for id in &ids[1..] {+            s.execute("layer.select", json!({"layer": id.0, "mode": "toggle"})).unwrap();+        }+        s.execute("edit.copy", json!({})).unwrap();+        s.set_active(1);+        s.execute("edit.paste", json!({})).unwrap();+        let st = s.active().unwrap();+        let top = &st.doc.layers[st.doc.layers.len() - ids.len()..];+        let clipped: Vec<bool> = top.iter().map(|l| l.clipped).collect();+        assert!(s.undo());+        clipped+    };+    assert_eq!(pasted(&mut s, &[text]), [false], "alone: released, not clipped to the destination layer");+    assert_eq!(pasted(&mut s, &[mid, text]), [false, true], "with its base: still clipped to it");+    assert_eq!(pasted(&mut s, &[base, text]), [false, false], "its base left behind: not clipped to another copied layer");+    s.set_active(0);+    assert!(s.active().unwrap().doc.layer(text).unwrap().clipped, "the source is untouched");+}+ /// #2478: the Background in a multi-layer copy pastes as an ordinary, unlocked layer. #[test] fn copying_the_background_with_another_layer_pastes_an_ordinary_layer() {