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
@@ -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();@@ -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() {