From c92dad3838f5fce53572f2b18b38fd0e91db3188 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 13:25:41 -0800 Subject: [PATCH] Edit: a copied compound member takes the compound's graphic style link Make Compound Path leaves each member's graphic style id in place. A member copied or cut on its own took the compound's appearance but kept that old id, so the clipboard carried an unrelated style and the pasted path was linked to a style that did not match its look. The copied member or piece now takes the compound's link while it has that style's look, and no link otherwise (no style on the compound, or a gradient pinned to the compound's bounds). It also takes the compound's knockout setting with the rest of its transparency. --- crates/engine/src/cmd/clipboard.rs | 18 +++++- crates/engine/src/cmd/edit.rs | 2 +- .../engine/tests/it/copy_direct_selection.rs | 60 +++++++++++++++++++ docs/mcp.md | 4 +- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index d387d63d2..5ff09dc28 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -264,6 +264,8 @@ impl Clipboard { .and_then(|p| *compound_bounds.entry(p.id).or_insert_with(|| p.geometric_bounds())); // A compound's member or piece copied on its own takes the compound's look (its paint, // transparency, opacity mask, pixel alignment and fill rule), so it pastes as it looked. + // It is linked to the compound's graphic style while it still has that style's look + // (a gradient pinned to the compound's bounds does not), else to none. let inherit_appearance = |mut piece: Node| { if let Some(parent) = compound_parent && let (NodeKind::Compound { rule: compound_rule, .. }, NodeKind::Path { rule, .. }) = (&parent.kind, &mut piece.kind) @@ -276,11 +278,21 @@ impl Clipboard { piece.opacity = parent.opacity; piece.blend = parent.blend; piece.isolate = parent.isolate; + piece.knockout = parent.knockout; piece.knockout_shape = parent.knockout_shape; let (mask, pixel_aligned) = (parent.extras().mask.clone(), parent.extras().pixel_aligned); - let mut extras = piece.extras_mut(); - extras.mask = mask; - extras.pixel_aligned = pixel_aligned; + { + let mut extras = piece.extras_mut(); + extras.mask = mask; + extras.pixel_aligned = pixel_aligned; + } + let link = parent + .extras() + .graphic_style + .and_then(|g| d.graphic_style_by_id(g)) + .filter(|g| super::style::in_sync(&piece, g)) + .map(|g| g.id); + piece.extras_mut().graphic_style = link; } piece }; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 68fb9b74e..024ba535b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses (a compound-path member selected on its own, whole or in part, with Direct or Group Selection, is copied as an ordinary path with the compound's paint, transparency, opacity mask and fill rule, a gradient keeping the compound's fit; the compound is copied when it is selected, or when every member is selected whole outside isolation mode), and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", + "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses (a compound-path member selected on its own, whole or in part, with Direct or Group Selection, is copied as an ordinary path with the compound's paint, transparency, opacity mask, fill rule and graphic style link (while it has that style's look), a gradient keeping the compound's fit; the compound is copied when it is selected, or when every member is selected whole outside isolation mode), and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index c41a813ee..14c2a45b1 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -604,6 +604,66 @@ fn a_copied_member_with_its_own_appearance_takes_the_compounds() { assert_eq!(s.clipboard.nodes[0].appearance, compound_look); } +/// A member copied or cut on its own, whole or in part, takes the compound's graphic style link +/// with its look, or no link when the compound has none or the member no longer has the style's +/// look (a gradient pinned to the compound's bounds); the style the member had before Make Compound +/// Path does not go with it. +#[test] +fn a_copied_member_takes_the_compounds_graphic_style() { + let style_of = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style; + let clipboard_styles = |s: &Session| s.clipboard.graphic_styles.iter().map(|g| g.name.clone()).collect::>(); + for (compound_styled, gradient) in [(false, false), (true, false), (true, true)] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let m0 = rect(&mut s, 10.0, 10.0, 40.0, 40.0); + run(&mut s, "paint.setFill", json!({"ids": [m0.0], "color": "#00ff00"})); + run(&mut s, "select.set", json!({"ids": [m0.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Member"})); + let member_style = style_of(&s, m0); + assert!(member_style.is_some(), "the path is linked to the style made from it"); + let m1 = rect(&mut s, 60.0, 10.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [m0.0, m1.0]})); + let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap()); + let fill = + if gradient { json!({"ids": [compound.0], "gradient": {"kind": "linear"}}) } else { json!({"ids": [compound.0], "color": "#ff0000"}) }; + run(&mut s, "paint.setFill", fill); + assert_eq!(style_of(&s, m0), member_style, "the member keeps its old link inside the compound"); + let compound_style = if compound_styled { + run(&mut s, "select.set", json!({"ids": [compound.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Compound"})); + style_of(&s, compound) + } else { + None + }; + assert_eq!(compound_style.is_some(), compound_styled); + // A gradient fitted to the compound is pinned to its bounds on the copy, which is no longer + // the style's look. + let link = compound_style.filter(|_| !gradient); + let expected: Vec = if link.is_some() { vec!["Compound".into()] } else { vec![] }; + let case = format!("compound styled: {compound_styled}, gradient: {gradient}"); + + s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap(); + run(&mut s, "select.set", json!({"ids": [m0.0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes[0].id, m0); + assert_eq!(s.clipboard.nodes[0].extras().graphic_style, link, "whole member, {case}"); + assert_eq!(clipboard_styles(&s), expected, "whole member, {case}"); + + run(&mut s, "select.segments", json!({"id": m0.0, "segments": [[0, 0]]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.nodes.len(), 1); + assert_eq!(s.clipboard.nodes[0].extras().graphic_style, link, "one segment, {case}"); + assert_eq!(clipboard_styles(&s), expected, "one segment, {case}"); + + run(&mut s, "select.set", json!({"ids": [m0.0]})); + run(&mut s, "edit.cut", json!({})); + assert_eq!(members_of(&s, compound), Some(vec![m1])); + let pasted = NodeId(run(&mut s, "edit.pasteInPlace", json!({}))["ids"][0].as_u64().unwrap()); + assert_eq!(style_of(&s, pasted), link, "Cut and Paste in Place, {case}"); + check_session(&s).unwrap(); + } +} + #[test] fn every_segment_of_every_member_picked_copies_and_cuts_the_compound() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index a0bf01bba..32014b2f4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1712,8 +1712,8 @@ With `ids`, `guides: true` also deletes the selected ruler guides. The command r boolean. `edit.copy` takes a compound-path member selected on its own (whole or in part, with Direct or Group Selection) as an -ordinary path with the compound's paint, transparency, opacity mask and fill rule. A gradient fitted to the compound -keeps the compound's fit. Copy takes the whole compound when the compound itself is selected, or when every member is +ordinary path with the compound's paint, transparency, opacity mask and fill rule. The path is linked to the compound's +graphic style while it has that style's look, and otherwise to no style. A gradient fitted to the compound keeps the compound's fit. Copy takes the whole compound when the compound itself is selected, or when every member is selected whole outside isolation mode, which includes the one member of a compound of one. In an isolated compound path each member is taken as itself. `edit.cut` removes the same objects it copies, so cutting one member leaves the other members in the compound, a compound of one member included. When Clear deletes every anchor or segment of every