Browse / github.com/storytold/photocraft / patch
Offset layer copies carry unlinked masks with the content (#9684)
Cross-document layer copies leave unlinked bitmap masks at source coordinates Copying a complete layer into another document with a nonzero offset moves its text/raster content but leaves an **unlinked bitmap mask at the source coordinates**. A masked source showing an 11-pixel-wide crop becomes the full 28-pixel-wide H immediately after copying. The unlink flag survives; the mask's position relative to its content is lost. Actual Photoshop 27.11.0 Copy/Paste preserves the original crop and the unlinked flag. Ordinary Move is a separate operation: it correctly leaves an unlinked mask stationary. ### Reproduce PhotoCraft CLI **0.16.0 (`b1794c6e0`)**, Windows, static A
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- NaufalDzz (imported from pull request #9844, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +41 −0 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9a413f9b0f56a2ec651ec728
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/layer_copy_cmds.rs crates/engine/src/cmd/layer_copy_cmds/tests.rs
@@ -135,6 +135,23 @@ pub(crate) fn translate_copies(copies: &mut Document, destination: &Document, dx for l in &mut copies.layers { crate::commands::translate_layer(&snapshot, l, dx, dy); crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));+ translate_unlinked_masks(l, dx, dy);+ }+ }+}++/// Moves the masks the Move tool leaves in place: a copy carries all of a layer, so an unlinked+/// mask keeps its place relative to the content, as Photoshop's Copy/Paste keeps it (#9684).+fn translate_unlinked_masks(l: &mut Layer, dx: i32, dy: i32) {+ if let Some(m) = l.mask.as_mut().filter(|m| !m.linked) {+ m.surface = crate::layer_multi_cmds::shift_surface(&m.surface, dx, dy);+ }+ if let Some(vm) = l.vector_mask.as_mut().filter(|vm| !vm.linked) {+ vm.path = vm.path.transform(&photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy)));+ }+ if let LayerContent::Group(g) = &mut l.content {+ for c in &mut g.children {+ translate_unlinked_masks(c, dx, dy); } } }@@ -88,6 +88,30 @@ fn places_the_copies_centred_on_the_canvas_on_a_point_or_offset() { assert_eq!(doc(&s, 1).layers.len(), 4); } +/// #9684: an unlinked mask stays put when the layer moves, but a copy carries the whole layer, so+/// an offset copy keeps its masks where they were on the content, as Photoshop's Copy/Paste does.+#[test]+fn an_offset_copy_carries_unlinked_masks_with_the_content() {+ let (mut s, paint) = two_docs(40, 30, 8);+ s.execute("select.rect", json!({"x": 9, "y": 0, "width": 31, "height": 30})).unwrap();+ s.execute("layer.layerMask.revealSelection", json!({})).unwrap();+ s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();+ let square = json!({"subpaths": [{"closed": true, "knots": [[0, 8], [40, 8], [40, 30], [0, 30]]}]});+ s.execute("layer.vectorMask.add", json!({"path": square})).unwrap();+ s.execute("layer.vectorMask.linked", json!({"linked": false})).unwrap();+ let r = s.execute("layer.copyToDocument", json!({"document": 1, "layers": [paint.0], "offset": [20, 10]})).unwrap();+ let l = doc(&s, 1).layer(copied(&r)).unwrap();+ let mask = l.mask.as_ref().unwrap();+ let reveals = |x: i32, y: i32| mask.surface.read_region(Rect::new(x, y, x + 1, y + 1))[0] > 0.5;+ assert!(!reveals(28, 20) && reveals(29, 20), "the bitmap mask's edge moved from x 9 to 29");+ assert!(!mask.linked, "and it stays unlinked");+ let vm = l.vector_mask.as_ref().unwrap();+ assert_eq!(vm.path.control_bounds(), Some((20.0, 18.0, 60.0, 40.0)), "the vector mask moved with it");+ assert!(!vm.linked);+ let src = doc(&s, 0).layer(paint).unwrap();+ assert!(src.mask.as_ref().unwrap().surface.read_region(Rect::new(9, 20, 10, 21))[0] > 0.5, "the source is untouched");+}+ #[test] fn a_copied_background_is_an_ordinary_layer_and_several_layers_keep_their_order() { let (mut s, paint) = two_docs(40, 30, 8);