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.
This commit is contained in:
dotCooCoo
2026-11-13 22:10:18 -08:00
parent 1e4e3534f5
commit 5d89b37040
4 changed files with 111 additions and 3 deletions
@@ -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 {
+11 -1
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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));
+66
View File
@@ -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<String>, Option<String>, Option<String>) {
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();
+2 -2
View File
@@ -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,