diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 735d721335..d7cae868ed 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -20,7 +20,9 @@ use vectorcraft_doc::{Node, NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_render::effects; +use super::super::appearance::series_children_in_paint_order; use super::super::edit::selected_roots; +use super::super::graph::capture_series_object_opacity; use super::super::menucmds::whole_or; use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; use super::super::typecmd::refresh_bounds_of; @@ -90,7 +92,7 @@ pub fn specs() -> Vec { "Randomize Opacity", ["Object", "Plug-ins", "Arrange Tools"], None, - "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}", + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}", has_selection, randomize_opacity ), @@ -99,7 +101,7 @@ pub fn specs() -> Vec { "Sort Objects", ["Object", "Plug-ins", "Arrange Tools"], None, - "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md). A run that changes nothing makes no step → {count}", has_multi, sort ), @@ -396,17 +398,30 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { } let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); s.edit("Randomize Opacity", |d, _| { + let mut changed = vec![]; for id in &ids { let o = rng.range(lo, hi); - if let Some(n) = d.node_mut(*id) { + if let Some(n) = d.node_mut(*id) + && n.opacity != o + { n.opacity = o; + changed.push(*id); } } + keep_series_opacity(d, &changed); Ok(()) })?; Ok(json!({ "count": ids.len() })) } +/// Store on their graphs the opacity just given to the paths among `changed` (objects whose opacity changed) that are +/// directly in a graph series, with their blend mode, so that every path of the series has them when the graph is drawn +/// again; the frontmost of them in a series sets them. +fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) { + let marks = series_children_in_paint_order(d, changed); + capture_series_object_opacity(d, &marks); +} + /// An object Sort Objects sorts: its layer or group, its place in it, its box as /// `object.setBounds` measures it and its opacity (%). struct Sorted { @@ -510,6 +525,8 @@ fn sort(s: &mut Session, p: &Value) -> Result { n.opacity = (v / 100.0) as f32; } } + let changed: Vec = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); + keep_series_opacity(d, &changed); Ok(()) })?; return Ok(count); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 5a129f6d32..4c662b200e 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -798,12 +798,18 @@ pub(super) fn capture_series_writes( /// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series, /// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed. pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + let marks = series_children_in_paint_order(d, ids); + !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) +} + +/// Each of `ids` directly in a series group, from the back to the front: a later mark's capture replaces an earlier +/// one's, so the frontmost one sets what the graph stores. +pub(crate) fn series_children_in_paint_order(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { // Only objects directly in a series group can be marks: other edits skip the search of the document for graphs. let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); - // A later mark's capture replaces an earlier one's: take them in paint order. marks.sort_by_cached_key(|id| d.index_path(*id)); - !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) + marks } /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 98f53b102e..b1d39e5b77 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -49,6 +49,10 @@ //! member with another order, and any other change to the list (items moved, or several added //! or removed at once), takes the model's whole list of fills and strokes. //! +//! A path directly in a graph series that takes an edit from another member (a graph's paths are +//! members only while the graph is isolated) also has what changed stored on its graph, so that +//! every path of the series takes it when the graph is drawn again (docs/mcp.md). +//! //! Members that are hidden or locked at the time are left as they are. An edit that would take any //! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that //! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag @@ -778,9 +782,22 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se } super::object::keep_compound_aligned(d, *id, Scaling::default()); } + keep_series_changes(before, d, &edited); edited } +/// Store on their graphs what the members `edited`, and the objects in them, took on the paths directly in a graph +/// series, for the graph's next redraw. The edited member is left to the edit's own command. +fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) { + let mut taken = vec![]; + for id in edited { + if let Some(n) = d.node(*id) { + n.walk(&mut |c: &Node| taken.push(c.id)); + } + } + super::appearance::capture_series_changes(before, d, &taken); +} + /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas /// highlights them while Global Edit is on. While it is off, with one supported object selected, diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index b1a491424e..5139cde43d 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -572,6 +572,124 @@ fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() { assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none())); } +/// Randomize Opacity on marks of one series stores the opacity of the frontmost of them on the graph in the same undo +/// step, and every mark of the series has it when the graph is drawn again. +#[test] +fn a_random_opacity_on_series_marks_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [marks[1].0, marks[0].0], "min": 30, "max": 60, "seed": 7})).unwrap(); + let front = s.doc().unwrap().doc.node(marks[1]).unwrap().opacity; + assert!(front < 1.0); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(front)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == front), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 1)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the opacities and what the graph stored"); +} + +/// Sort Objects giving series marks new opacities stores the opacity of the frontmost of them on the graph, in place of +/// the one another mark left there. +#[test] +fn an_opacity_sort_objects_gives_series_marks_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[2].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the three columns get 30%, 65% and 100%: the frontmost, the rightmost, is fully opaque. + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.65, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3), "undo takes back the sort and what the graph stored"); +} + +/// Sort Objects stores the opacity of the frontmost series mark whose opacity it changes: a mark in front that keeps its +/// opacity leaves the stored value to the marks it changed. +#[test] +fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 60})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the columns trade 60%, 30% and 100% for 30%, 60% and 100%: the frontmost keeps its 100%. + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity"})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.6, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); +} + +/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark +/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them +/// when the graph is drawn again. +#[test] +fn what_global_edit_gives_series_marks_survives_regeneration() { + let (mut s, id, _, _) = column_marks(); + // Equal values: the first column of each series has the same shape. + s.execute("graph.setData", &json!({"rows": [[3, 3], [5, 2]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, other) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); + s.execute("object.isolate", &json!({"id": id.0})).unwrap(); + s.execute("select.set", &json!({"ids": [first.0]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#e8573f"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [first.0], "color": "#1f4e9c"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [first.0], "weight": 5})).unwrap(); + let mark = s.doc().unwrap().doc.node(other).unwrap().clone(); + assert_eq!((mark.opacity, mark.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other series' mark"); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + assert_eq!(kept.fill, Some(mark.appearance.fill_paint())); + assert_eq!((kept.stroke, kept.stroke_width), (Some(mark.appearance.stroke_paint()), Some(5.0))); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == mark.appearance.fill_paint())); + while s.doc().unwrap().history.undo.len() > steps { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "{:?}", spec(&s, id).series_paints); +} + +/// With a whole series group picked and Global Edit on, the other series groups of the same form take the edit of its +/// marks, and the graph stores it for their series too. +#[test] +fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Series without names draw no legend, so each series group holds only its columns, laid out like the other's. + let csv = ",,\nQ1,3,3\nQ2,5,5"; + let made = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap(); + let id = NodeId(made["id"].as_u64().unwrap()); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, other) = (series(&n, 0).clone(), series(&n, 1).id); + s.execute("object.isolate", &json!({"id": id.0})).unwrap(); + s.execute("select.set", &json!({"ids": [first.id.0]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); + let marks: Vec = first.children().unwrap().iter().map(|m| m.id.0).collect(); + s.execute("paint.setFill", &json!({"ids": marks, "color": "#e8573f"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let given = series(&n, 1).children().unwrap()[0].appearance.fill_paint(); + assert_eq!(given, series(&n, 0).children().unwrap()[0].appearance.fill_paint(), "Global Edit reaches the other group"); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(given.clone())); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given)); +} + /// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of /// the series again; Normal forgets it. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 66c4cc0cff..a6a8889e43 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -798,9 +798,13 @@ color changed by `paint.invert`, `paint.complement`, `edit.colors.invert`, `edit `addon.color.filter`, `addon.color.applyNearestSwatch` or the ink limit of `addon.color.printFixes`. The recolor commands store it too when they are given a series group, the graph or a group that holds the graph. A row a command leaves as it was keeps what the graph stores. When one command changes several paths of a series, the frontmost of -them sets what the graph stores. A gradient is stored with its placement, and every path of the series takes that -placement when the graph is drawn again. The graph does not store a stroke's dashes, caps, joins, arrowheads, width -profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again +them sets what the graph stores. While Global Edit is on, a path directly in a series that takes an edit from another +match (a graph's paths are matches only while the graph is isolated) has what changed on it stored for its series: the +paint, weight, opacity and blend mode of the topmost visible fill and stroke, and the object's opacity and blend mode. +`addon.arrange.randomizeOpacity` and `addon.arrange.sort` with `apply: "opacity"` store the object opacity, with the +blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement, and +every path of the series takes that placement when the graph is drawn again. The graph does not store a stroke's +dashes, caps, joins, arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again with the paint they had. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..0fd5e9ea1b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1090,6 +1090,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. + What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.