From 5d89b37040ff4505692c05cf116db67fb89860f8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:34:47 -0800 Subject: [PATCH] Paste: graphs bring and follow the styles and swatches their labels keep A graph keeps paints for its series and labels, and character and paragraph style names for its labels, which each redraw gives the marks and labels. Copy did not follow them, so a graph pasted into another document left those swatches and styles behind, and a paste that renamed them left the graph pointing at the old names. Copy now collects them and paste relinks them, as it does for type. Paste without Formatting drops the labels' style names, so it still leaves text styles behind. --- crates/engine/src/cmd/clipboard/resources.rs | 32 ++++++++++ crates/engine/src/cmd/typemenu.rs | 12 +++- crates/engine/src/tests/clipboard.rs | 66 ++++++++++++++++++++ docs/mcp.md | 4 +- 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index bdb238ce83..f457712415 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -127,6 +127,17 @@ fn node_refs(n: &Node, f: Visit) { if let Some(cm) = Document::color_mask_of(m) { f(Ref::Name(Res::Image, &cm.key)); } + // A graph keeps paints and style names for its series and labels, which a redraw gives them. + if let Some(g) = m.extras().graph.as_deref() { + vectorcraft_doc::swatches::graph_paints(m).for_each(|p| paint_refs(p, f)); + let l = &g.label_styles; + for s in [&l.values, &l.categories, &l.legend].into_iter().flatten().filter_map(|st| st.style_name.as_ref()) { + f(Ref::Name(Res::CharStyle, s)); + } + for s in [&l.values_para, &l.categories_para, &l.legend_para].into_iter().flatten().filter_map(|pa| pa.style_name.as_ref()) { + f(Ref::Name(Res::ParaStyle, s)); + } + } match &m.kind { NodeKind::Text(t) => { for r in &t.runs { @@ -671,6 +682,27 @@ impl Relink { self.name(Res::Image, &mut cm.key); self.name(Res::Image, &mut cm.from); } + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + for s in &mut g.series_paints { + s.fill.iter_mut().chain(s.stroke.iter_mut()).for_each(|p| self.paint(p)); + } + let l = &mut g.label_styles; + for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { + self.paint(&mut st.fill); + self.paint(&mut st.stroke); + if let Some(s) = &mut st.style_name { + self.name(Res::CharStyle, s); + } + } + for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() { + if let Some(s) = &mut pa.style_name { + self.name(Res::ParaStyle, s); + } + } + for r in [&mut l.values_rows, &mut l.categories_rows, &mut l.legend_rows].into_iter().flatten() { + r.paint.fill.iter_mut().chain(r.paint.stroke.iter_mut()).for_each(|p| self.paint(p)); + } + } match &mut n.kind { NodeKind::Text(t) => { for r in &mut t.runs { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 87c16bb9b9..c140ba6572 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "Paste without Formatting", ["Edit"], Some("Cmd+Alt+V"), - "{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind → {ids, added, merged, renamed}", + "{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}", has_clipboard, paste_plain ), @@ -1460,6 +1460,16 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) { t.set_all_paras(Default::default()); refresh_bounds(t); } + // A graph keeps the names of its labels' character and paragraph styles for each redraw. + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + let l = &mut g.label_styles; + for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { + st.style_name = None; + } + for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() { + pa.style_name = None; + } + } if let Some(ch) = n.children_mut() { for c in ch.iter_mut() { strip_formatting(std::sync::Arc::make_mut(c)); diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index d0e003ec49..6cbfd909a9 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -468,6 +468,72 @@ fn styles_symbols_and_images_of_the_same_name_are_renamed() { assert_eq!(key(&s, img2), src_key); } +/// A graph whose series fill, value labels and legend rows paint with global swatch "Brand" and +/// whose value labels are set in character style "Emphasis" and paragraph style "Body", as a +/// later redraw would give them to its marks and labels. +fn styled_graph(s: &mut Session) -> NodeId { + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 8.0}})).unwrap(); + let v = s.execute("graph.create", &json!({"type": "column", "x": 200, "y": 200, "width": 60, "height": 40, "csv": ",2024\nQ1,3\nQ2,5"})); + let id = id_of(&v.unwrap()); + let brand = linked(hex("#ff0000"), "Brand", 1.0); + let mut extras = doc_mut(s).node_mut(id).unwrap().extras_mut(); + let g = extras.graph.as_mut().unwrap(); + g.series_paints = vec![vectorcraft_doc::SeriesPaint { fill: Some(brand.clone()), ..Default::default() }]; + let l = &mut g.label_styles; + l.values = Some(vectorcraft_doc::CharStyle { fill: brand.clone(), style_name: Some("Emphasis".into()), ..Default::default() }); + l.values_para = Some(vectorcraft_doc::ParaStyle { style_name: Some("Body".into()), ..Default::default() }); + let rows = vectorcraft_doc::SeriesPaint { stroke: Some(brand), ..Default::default() }; + l.legend_rows = Some(vectorcraft_doc::graph::LabelRows { paint: rows, ..Default::default() }); + id +} + +/// The swatch names a graph's stored paints link to, and its value labels' character and +/// paragraph style names. +fn graph_links(s: &Session, id: NodeId) -> (Vec, Option, Option) { + let n = node(s, id); + let swatches = vectorcraft_doc::swatches::graph_paints(&n).filter_map(|p| match p { + Paint::Solid { swatch: Some(w), .. } => Some(w.clone()), + _ => None, + }); + let l = &n.extras().graph.as_deref().unwrap().label_styles; + let values = l.values.as_ref().and_then(|st| st.style_name.clone()); + (swatches.collect(), values, l.values_para.as_ref().and_then(|pa| pa.style_name.clone())) +} + +/// A pasted graph brings the swatches and text styles its series and labels keep, and follows +/// them when they come in under new names. +#[test] +fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() { + let mut s = session(); + let g = styled_graph(&mut s); + copy(&mut s, &[g]); + + new_doc(&mut s); + let v = s.execute("edit.pasteInPlace", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.swatch("Brand").is_some(), "{v}"); + assert!(d.char_styles.iter().any(|x| x.name == "Emphasis") && d.para_styles.iter().any(|x| x.name == "Body"), "{v}"); + let brand = vec!["Brand".to_string(); 3]; + assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand.clone(), Some("Emphasis".into()), Some("Body".into()))); + + // Into a document that gives each name something else: they come in renamed. + new_doc(&mut s); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 8.0}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 2.0}})).unwrap(); + let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap(); + let brand2 = vec!["Brand 2".to_string(); 3]; + assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand2, Some("Emphasis 2".into()), Some("Body 2".into())), "{v}"); + + // Without formatting: the labels' text styles stay behind. + new_doc(&mut s); + let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); + assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}"); + assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None)); +} + #[test] fn pasting_again_reuses_what_an_earlier_paste_added() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..f65fbe565f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6555,8 +6555,8 @@ number, naming the key, and place nothing; null is the same as leaving the key o `edit.copy` keeps the copied objects with the document resources they use: image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used; never the built-in [Registration] swatch), the gradient swatches their gradients came from, graphic styles the objects are linked to, character and paragraph styles and -brushes. Width profiles live in the preferences, so they need no copying. Every `edit.paste*` command (also `edit.pasteWithoutFormatting`, which leaves -text styles behind) brings them into the active document in the same undo step: an identical resource is reused, +brushes, with the swatches, patterns and text styles a graph keeps for its series and labels. Width profiles live in the preferences, so they need no copying. Every `edit.paste*` command (also `edit.pasteWithoutFormatting`, which leaves +text styles behind, those of graph labels included) brings them into the active document in the same undo step: an identical resource is reused, a missing one added, and one of the same name that differs is added renamed ("Mark 2"; a copy an earlier paste added is reused), which the pasted objects follow. Linked colours show the document's swatch at their tint (a Lab spot colour as its Spot Colors option says). The result reports `{ids, added, merged, renamed: [{kind, from,