Select: Start Global Edit edits the objects of the same shape together, each in proportion

Squashed from #3781 onto main.
This commit is contained in:
bflatastic
2026-10-24 03:09:12 +07:00
parent 342d6e055d
commit 6e3ecc3adf
39 changed files with 1526 additions and 18 deletions
+2
View File
@@ -412,6 +412,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
st.sync_new_art();
super::maskedit::follow_history(st, editing);
leave_isolation_if_gone(st);
super::globaledit::follow_history(st);
Ok(json!({ "undone": label }))
}
@@ -438,6 +439,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
st.sync_new_art();
super::maskedit::follow_history(st, editing);
leave_isolation_if_gone(st);
super::globaledit::follow_history(st);
Ok(json!({ "redone": label }))
}
+587
View File
@@ -0,0 +1,587 @@
//! Select › Start Global Edit / Stop Global Edit, and the Global Edit Options.
//!
//! Behaviour from the public help pages "Edit similar objects"
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>)
//! and "Update assets quickly with Global Edit"
//! (<https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html>), in our words:
//!
//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the
//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance
//! (the same fill and stroke too) and Size (the same width and height too). The search covers
//! every artboard and the canvas around them by default; it can be limited to a range of
//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type,
//! masks, linked files and selections of more than one object are not supported.
//! - While Global Edit is on, an edit made to one of the found objects is made to all of them,
//! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt
//! match to 10 pt. Stop Global Edit (or Escape) ends it.
//!
//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the
//! object that was selected, so that the Transform panel and the bounding box measure that object
//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps
//! the first object's outline onto its own. After every edit ([`propagate`], run inside
//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit
//! changed is the model: what changed about it is given to every other member, and nothing else,
//! so members found by shape alone keep their own fills and strokes where the edit didn't touch
//! them:
//!
//! - its outline (and anchors), placed through that transform, so in proportion to each member's
//! size. Only geometry is placed so: no stroke weight is scaled to a member's size;
//! - its opacity, blending, knockout and the object's effects, each when it changed;
//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending,
//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the
//! same rank (the second fill from the bottom to the second fill), and a member without one is
//! left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure a
//! stroke was drawn with stays each member's own;
//! - a fill or stroke added or removed: a member whose fills and strokes lie in the model's
//! former order gains the same item at the same place (or loses the one at that place). A
//! 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.
//!
//! 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
//! or scrub, an edit that failed), the members it lacks (or no longer supports) leave the Global
//! Edit, and it ends when fewer than two are left ([`follow_history`]), so that an object made
//! later under a freed id is never edited. Rotated copies, groups (the help page "Edit groups with
//! similar objects") and excluding a member with a Shift-click are not covered.
use serde::Serialize;
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape};
use super::wand::{fill_of, stroke_of};
use super::*;
const C: &str = "select.globalEdit";
const OPTIONS: &str = "select.globalEditOptions";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"select.globalEdit",
"Start Global Edit",
["Select"],
None,
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object, with the objects of the same shape (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard) → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
can_toggle,
toggle
),
cmd!(
"select.globalEditOptions",
"Global Edit Options",
[],
None,
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} set the options Start Global Edit matches with (defaults: shape only, all artboards, objects on the canvas included) → {appearance, size, artboards, includeCanvas, count: the objects Start Global Edit would take now (0 unless one supported object is selected)}",
has_doc,
set_options
),
]
}
/// The Global Edit Options: what a similar object must share with the selected one besides its
/// shape, and where it may lie. Kept for the session.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Options {
/// Match the fill and stroke too.
pub appearance: bool,
/// Match the width and height too.
pub size: bool,
/// The artboards to search (1-based range as typed); `None`: all of them.
pub artboards: Option<String>,
/// Include the objects that lie on no artboard.
pub include_canvas: bool,
}
impl Default for Options {
fn default() -> Self {
Self { appearance: false, size: false, artboards: None, include_canvas: true }
}
}
impl Options {
/// These options with the ones `p` gives, checked against `d`'s artboards.
fn with(&self, p: &Value, d: &Document, cmd: &str) -> Result<Self> {
let mut o = self.clone();
o.appearance = bool_or(p, "appearance", o.appearance);
o.size = bool_or(p, "size", o.size);
o.include_canvas = bool_or(p, "includeCanvas", o.include_canvas);
match p.get("artboards") {
None | Some(Value::Null) => {}
Some(Value::String(s)) if s.trim().is_empty() || s.trim().eq_ignore_ascii_case("all") => o.artboards = None,
Some(Value::String(s)) => {
vectorcraft_doc::range::parse_range(s, d.artboards.len()).map_err(|e| bad(cmd, e))?;
o.artboards = Some(s.trim().to_string());
}
Some(v) => return Err(bad(cmd, format!("`artboards`: all or a range such as 1, 3-5, not {v}"))),
}
Ok(o)
}
fn json(&self, count: usize) -> Value {
json!({
"appearance": self.appearance,
"size": self.size,
"artboards": self.artboards.as_deref().unwrap_or("all"),
"includeCanvas": self.include_canvas,
"count": count,
})
}
}
/// A Global Edit in progress (per document; not saved, not an undo step).
#[derive(Clone, Debug, PartialEq)]
pub struct GlobalEdit {
/// The objects edited together, the one selected at the start first, each with the transform
/// (a move and a uniform scale) that maps that first object's outline onto its own.
pub members: Vec<(NodeId, Affine)>,
}
/// An outline reduced to its form: the anchors and handles placed in a unit box, with what tells
/// such outlines apart (kind, fill rule, subpaths and their anchor counts, open or closed).
struct Form {
tag: Vec<u64>,
symbol: Option<String>,
points: Vec<Point>,
/// The unit box → the object.
frame: Affine,
}
impl Form {
fn of(n: &Node) -> Option<Self> {
let mut tag = vec![];
let mut points = vec![];
let mut symbol = None;
let mut push = |path: &PathData, tag: &mut Vec<u64>| {
tag.push(path.subpaths.len() as u64);
for sp in &path.subpaths {
tag.extend([u64::from(sp.closed), sp.anchors.len() as u64]);
points.extend(sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]));
}
};
let rule = |r: vectorcraft_geom::FillRule| u64::from(r == vectorcraft_geom::FillRule::EvenOdd);
match &n.kind {
NodeKind::Path { path, rule: r, clipping: false, guide: false, .. } => {
tag.extend([0, rule(*r)]);
push(path, &mut tag);
}
NodeKind::Compound { children, rule: r } => {
tag.extend([1, rule(*r), children.len() as u64]);
for c in children {
push(c.path_data()?, &mut tag);
}
}
NodeKind::SymbolInstance { symbol: s, xf } => {
tag.push(2);
symbol = Some(s.clone());
points.extend([*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)]);
}
_ => return None,
}
let first = *points.first()?;
let b = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
let side = b.width().max(b.height());
if !(side.is_finite() && side > 1e-9) {
return None;
}
let frame = Affine::translate(b.origin().to_vec2()) * Affine::scale(side);
let back = frame.inverse();
points.iter_mut().for_each(|p| *p = back * *p);
Some(Self { tag, symbol, points, frame })
}
fn same(&self, o: &Form) -> bool {
self.tag == o.tag
&& self.symbol == o.symbol
&& self.points.len() == o.points.len()
&& self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6)
}
}
/// Global Edit supports paths, compound paths and symbol instances; not images, type, linked
/// files, objects with an opacity mask, clipping paths or guides (the help page lists images,
/// text, masks and links as unsupported).
fn supported(n: &Node) -> bool {
n.extras().mask.is_none() && Form::of(n).is_some()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0)
}
/// The members a Global Edit started on the one selected object would take, with `o`: that
/// object first, then the others in paint order.
fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
let st = s.doc()?;
let d = &st.doc;
let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) };
let key = d.node(*id).ok_or_else(|| bad(C, "nothing selected"))?;
let form = match Form::of(key) {
Some(f) if supported(key) => f,
_ => return Err(bad(C, "Global Edit works on paths, compound paths and symbol instances")),
};
let symbols = d.symbol_boxes();
let size = |n: &Node| n.geometric_bounds_with(&symbols).map(|b| (b.width(), b.height()));
let key_size = size(key);
let (fill, stroke) = (fill_of(key), stroke_of(key));
let boards: Option<Vec<Rect>> = match &o.artboards {
None => None,
Some(r) => Some(
vectorcraft_doc::range::parse_range(r, d.artboards.len())
.map_err(|e| bad(C, e))?
.into_iter()
.filter_map(|i| d.artboards.get(i).map(|a| a.rect))
.collect(),
),
};
let on = |r: Rect, b: Rect| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center());
let in_scope = |n: &Node| {
let Some(b) = n.geometric_bounds_with(&symbols) else { return false };
if !d.artboards.iter().any(|a| on(a.rect, b)) {
return o.include_canvas;
}
boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b)))
};
let mut out = vec![(*id, Affine::IDENTITY)];
let back = form.frame.inverse();
super::select::walk_selectable(d, st.isolation, |n| {
if n.id == *id {
return false;
}
match &n.kind {
// Look into layers and plain groups (clip groups too: their art, not their clipping path).
NodeKind::Layer { .. } => return true,
NodeKind::Group { .. } => {
let x = n.extras();
return x.graph.is_none() && x.shaper.is_none() && x.dimension.is_none() && x.objects_on_path.is_none() && x.trace.is_none();
}
_ => {}
}
if let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n))
&& (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)))
&& (!o.appearance || {
let (f2, s2) = (fill_of(n), stroke_of(n));
super::select::same_paint(&fill, &f2, true) && super::select::same_paint(&stroke.0, &s2.0, true) && close(stroke.1, s2.1)
})
&& in_scope(n)
{
out.push((n.id, f.frame * back));
}
// Compound paths, symbol instances and the other objects are taken whole.
false
});
Ok(out)
}
fn can_toggle(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.global_edit.is_some() {
return Ok(());
}
let [id] = st.selection.objects.as_slice() else {
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
};
match st.doc.node(*id) {
Some(n) if supported(n) => Ok(()),
_ => Err("Global Edit works on paths, compound paths and symbol instances".into()),
}
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.global_edit.is_some() {
let st = s.doc_mut()?;
st.global_edit = None;
st.revision += 1;
return Ok(json!({ "active": false }));
}
let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?;
let members = matches(s, &o)?;
if members.len() < 2 {
return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again"));
}
let ids: Vec<u64> = members.iter().map(|(id, _)| id.0).collect();
let st = s.doc_mut()?;
st.global_edit = Some(GlobalEdit { members });
st.revision += 1;
Ok(json!({ "active": true, "count": ids.len(), "ids": ids }))
}
fn set_options(s: &mut Session, p: &Value) -> Result<Value> {
let o = s.global_edit_options.with(p, &s.doc()?.doc, OPTIONS)?;
s.global_edit_options = o.clone();
Ok(o.json(match_count(s)))
}
/// How many objects Start Global Edit would take now with the session's options (the Result(s)
/// line of the options): 0 unless one supported object is selected.
pub fn match_count(s: &Session) -> usize {
match s.active() {
Some(st) if st.global_edit.is_none() => matches(s, &s.global_edit_options).map_or(0, |m| m.len()),
Some(st) => st.global_edit.as_ref().map_or(0, |g| g.members.len()),
None => 0,
}
}
/// Is `id` there, shown and unlocked, with every layer and group it is in?
fn editable(d: &Document, id: NodeId) -> bool {
d.ancestry(id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())))
}
/// After the document of `st` was put back to an earlier one (Undo, Redo, a cancelled interaction
/// or undo group, a failed or panicked command, a preview starting again, Discard Empty Type): the
/// members it lacks (or no longer supports) leave the Global Edit, so that an object made later
/// under a freed id is never edited; with fewer than two left, Global Edit ends.
pub(crate) fn follow_history(st: &mut crate::DocState) {
let Some(ge) = &mut st.global_edit else { return };
let d = &st.doc;
ge.members.retain(|(id, _)| d.node(*id).is_some_and(supported));
if ge.members.len() < 2 {
st.global_edit = None;
}
}
/// What an edit changed about a member: what [`propagate`] gives the others.
#[derive(Default)]
struct Changed {
outline: bool,
/// The member's appearance before and after the edit, when it changed.
appearance: Option<[Shared<Appearance>; 2]>,
opacity: bool,
blend: bool,
isolate: bool,
knockout: bool,
knockout_shape: bool,
}
impl Changed {
fn between(a: &Node, b: &Node) -> Self {
Self {
outline: a.kind != b.kind || a.bbox_angle != b.bbox_angle,
appearance: (a.appearance != b.appearance).then(|| [a.appearance.clone(), b.appearance.clone()]),
opacity: a.opacity != b.opacity,
blend: a.blend != b.blend,
isolate: a.isolate != b.isolate,
knockout: a.knockout != b.knockout,
knockout_shape: a.knockout_shape != b.knockout_shape,
}
}
fn any(&self) -> bool {
self.outline || self.appearance.is_some() || self.opacity || self.blend || self.isolate || self.knockout || self.knockout_shape
}
/// Give `n` what changed, from `model` (the changed member placed on `n`).
fn apply(&self, model: Node, n: &mut Node) {
if self.outline {
n.kind = model.kind;
n.bbox_angle = model.bbox_angle;
}
if let Some([a, b]) = &self.appearance {
give_appearance(a, b, &model.appearance, &mut n.appearance);
}
if self.opacity {
n.opacity = model.opacity;
}
if self.blend {
n.blend = model.blend;
}
if self.isolate {
n.isolate = model.isolate;
}
if self.knockout {
n.knockout = model.knockout;
}
if self.knockout_shape {
n.knockout_shape = model.knockout_shape;
}
}
}
/// Set each field of `$n` that differs between `$a` and `$b` to the binding of the same name.
macro_rules! give_fields {
($a:ident, $b:ident, $n:ident: $($f:ident),+) => {
$(
if $a.$f != $b.$f {
$n.$f = Clone::clone($f);
}
)+
};
}
/// Give `n` what changed about the model's appearance from `a` (before the edit) to `b` (after),
/// with the values of `m` (`b` placed on `n`'s object); the module doc gives the rules.
fn give_appearance(a: &Appearance, b: &Appearance, m: &Appearance, n: &mut Shared<Appearance>) {
let Appearance { items, effects, contents_index } = m;
if a.effects != b.effects || a.contents_index != b.contents_index {
let n = &mut **n;
give_fields!(a, b, n: effects, contents_index);
}
if a.items == b.items {
return;
}
let kinds = |v: &[AppearanceItem]| v.iter().map(AppearanceItem::is_fill).collect::<Vec<_>>();
if kinds(&a.items) == kinds(&b.items) {
// Fills and strokes kept in order: each changed one goes to the member's of its kind and
// rank.
for (k, ((p, q), mq)) in a.items.iter().zip(&b.items).zip(items).enumerate() {
if p == q {
continue;
}
let rank = a.items.iter().take(k).filter(|x| x.is_fill() == p.is_fill()).count();
if let Some(t) = n.items.iter_mut().filter(|x| x.is_fill() == p.is_fill()).nth(rank) {
give_item(p, q, mq, t);
}
}
return;
}
// One item added (`grew`) or removed at `at`, the others kept in order; the member must hold
// the model's former kinds in order.
let one = |long: &[AppearanceItem], short: &[AppearanceItem]| {
if long.len() != short.len() + 1 {
return None;
}
let at = long.iter().zip(short).position(|(x, y)| x != y).unwrap_or(short.len());
let mut rest = kinds(long);
rest.remove(at);
(rest == kinds(short)).then_some(at)
};
let step = match (one(&b.items, &a.items), one(&a.items, &b.items)) {
(Some(at), _) => Some((at, true)),
(None, Some(at)) => Some((at, false)),
_ => None,
};
match step {
Some((at, grew)) if kinds(&n.items) == kinds(&a.items) => {
let n = &mut n.items;
for (i, (p, t)) in a.items.iter().zip(n.iter_mut()).enumerate() {
// The model's item after the edit that was `p` before it.
let j = match (grew, i < at) {
(_, true) => i,
(true, false) => i + 1,
(false, false) if i == at => continue,
(false, false) => i - 1,
};
if let (Some(q), Some(mq)) = (b.items.get(j), items.get(j))
&& p != q
{
give_item(p, q, mq, t);
}
}
match (grew, items.get(at)) {
(true, Some(new)) => n.insert(at.min(n.len()), new.clone()),
(false, _) if at < n.len() => {
n.remove(at);
}
_ => {}
}
}
_ => n.items = items.clone(),
}
}
/// Give the member's fill or stroke `n` the settings of the model's that changed from `a` to `b`,
/// with the values of `m` (`b` placed on the member). A stroke's pen pressure stays its own.
fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut AppearanceItem) {
use AppearanceItem::{Fill, Stroke};
match (a, b, m, n) {
(Fill(a), Fill(b), Fill(m), Fill(n)) => {
let FillLayer { paint, opacity, blend, visible, effects, overprint } = m;
give_fields!(a, b, n: paint, opacity, blend, visible, effects, overprint);
}
(Stroke(a), Stroke(b), Stroke(m), Stroke(n)) => {
let StrokeLayer {
paint,
width,
cap,
join,
miter_limit,
align,
dash,
start_arrow,
end_arrow,
arrow_scale,
profile,
brush,
opacity,
blend,
visible,
effects,
arrow_align,
overprint,
gradient_mode,
pressure: _,
} = m;
give_fields!(a, b, n: paint, width, cap, join, miter_limit, align, dash, start_arrow, end_arrow, arrow_scale, profile, brush, opacity, blend, visible, effects, arrow_align, overprint, gradient_mode);
}
_ => {}
}
}
/// Give the children of a copy placed on another object ids of that object's: its own children's
/// in order, new ids for any more and for everything deeper.
fn renumber(children: &mut [std::sync::Arc<Node>], own: &[NodeId], d: &mut Document) {
for (i, c) in children.iter_mut().enumerate() {
let c = std::sync::Arc::make_mut(c);
c.id = own.get(i).copied().unwrap_or_else(|| d.alloc_id());
if let Some(ch) = c.children_mut() {
renumber(ch, &[], d);
}
}
}
/// After an edit made while Global Edit is on (`before` → `d`): the first member selected before
/// or after the edit that it changed is the model, and every other member that is shown and
/// unlocked gets what changed about it, placed by the members' transforms. → the members it
/// changed so (which [`Session::edit`] checks stay within the canvas, as it checks the selection).
pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, selected: [&[NodeId]; 2]) -> Vec<NodeId> {
let model = ge.members.iter().filter(|(id, _)| selected.iter().any(|s| s.contains(id))).find_map(|(id, xf)| {
let (a, b) = (before.node(*id)?, d.node(*id)?);
if std::ptr::eq(a, b) {
return None;
}
let changed = Changed::between(a, b);
changed.any().then(|| (*id, *xf, changed, b.clone()))
});
let Some((model_id, model_xf, changed, model)) = model else { return vec![] };
let back = model_xf.inverse();
let mut edited = vec![];
for (id, xf) in &ge.members {
if *id == model_id || !editable(d, *id) {
continue;
}
let mut placed = model.clone();
placed.transform(*xf * back, Scaling::default());
if changed.outline {
let own: Vec<NodeId> = d.node(*id).and_then(Node::children).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
if let Some(ch) = placed.children_mut() {
renumber(ch, &own, d);
}
}
if let Some(n) = d.node_mut(*id) {
changed.apply(placed, n);
edited.push(*id);
}
}
edited
}
/// The outlines of the members of the active document's Global Edit that are not selected, in
/// their layer's colour: the canvas highlights them while Global Edit is on.
pub fn highlights(s: &Session) -> Vec<(BezPath, [u8; 3])> {
let Some(st) = s.active() else { return vec![] };
let Some(ge) = &st.global_edit else { return vec![] };
let symbols = st.doc.symbol_boxes();
ge.members
.iter()
.filter(|(id, _)| !st.selection.contains(*id))
.filter_map(|(id, _)| {
let n = st.doc.node(*id)?;
let path = match n.stroke_path() {
Some(p) => p,
None => n.geometric_bounds_with(&symbols)?.to_path(0.1),
};
Some((path, st.doc.layer_color(*id)))
})
.collect()
}
+2
View File
@@ -28,6 +28,7 @@ pub mod flatten;
pub mod fontfiles;
mod fonts;
pub(crate) mod freeform;
pub mod globaledit;
pub(crate) mod gradient;
pub(crate) mod graph;
mod halftone;
@@ -215,6 +216,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(object::specs());
v.extend(path::specs());
v.extend(select::specs());
v.extend(globaledit::specs());
v.extend(wand::specs());
v.extend(paint::specs());
// Split out of `paint` (M3.8); kept next to it so lists in registry order (the command
+1 -1
View File
@@ -559,7 +559,7 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
/// Select › Same › Fill/Stroke Color: is `a` the same colour as `b`? Tints of one global or spot
/// swatch are, unless `tint` (General › Select Same Tint %) asks for the same tint too.
fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool {
pub(crate) fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool {
match (a, b) {
(Paint::Solid { swatch: Some(x), tint: ta, .. }, Paint::Solid { swatch: Some(y), tint: tb, .. }) if x == y => !tint || (ta - tb).abs() < 1e-4,
_ => a == b,
+1
View File
@@ -1217,6 +1217,7 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
st.doc = created.doc.clone();
st.history.undo.truncate(k);
st.selection.prune(&st.doc);
super::globaledit::follow_history(st);
st.revision += 1;
return Ok(json!({"removed": true}));
}
+35 -7
View File
@@ -348,6 +348,9 @@ pub struct DocState {
pub variables_highlight: Option<String>,
/// Isolation mode container.
pub isolation: Option<NodeId>,
/// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape
/// ([`cmd::globaledit`]). Editing state: not saved, not undoable.
pub global_edit: Option<cmd::globaledit::GlobalEdit>,
pub interaction: Option<Interaction>,
/// Edits made while this is open are one undo step (a scrubbed numeric field).
pub undo_group: Option<UndoGroup>,
@@ -551,6 +554,7 @@ impl DocState {
layer_rows: vec![],
variables_highlight: None,
isolation: None,
global_edit: None,
interaction: None,
undo_group: None,
last_transform: None,
@@ -644,6 +648,7 @@ impl DocState {
self.doc = it.doc;
self.selection = Arc::unwrap_or_clone(it.selection);
self.sync_new_art();
cmd::globaledit::follow_history(self);
self.active_layer = it.active_layer;
self.layer_rows = it.layer_rows;
self.variables_highlight = it.variables_highlight;
@@ -861,16 +866,22 @@ const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catal
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
fn doc_sane(d: &Document, sel: &Selection) -> bool {
let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
d.ruler_origin.is_finite()
&& d.ruler_origin.x.abs() <= MAX_COORD
&& d.ruler_origin.y.abs() <= MAX_COORD
&& d.artboards.iter().all(|a| ok(a.rect))
&& d.artboards.iter().all(|a| rect_in_range(a.rect))
&& d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD)
&& {
let paths = d.index_paths(sel.objects.iter().copied());
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
}
&& objects_in_range(d, &sel.objects)
}
fn rect_in_range(r: vectorcraft_geom::Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)
}
/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)?
fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool {
let paths = d.index_paths(ids.iter().copied());
ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range))
}
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).
@@ -1456,6 +1467,9 @@ pub struct Session {
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
pub link_scan: Option<link_watch::LinkScan>,
/// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not
/// saved.
pub global_edit_options: cmd::globaledit::Options,
}
impl Default for Session {
@@ -1538,6 +1552,7 @@ impl Session {
search_threads: None,
link_stamps: Default::default(),
link_scan: None,
global_edit_options: Default::default(),
}
}
@@ -1839,6 +1854,7 @@ impl Session {
st.doc = doc;
st.selection = selection;
st.interaction = interaction;
cmd::globaledit::follow_history(st);
st.revision += 1;
}
if active.is_some_and(|i| i < self.docs.len()) {
@@ -1868,8 +1884,14 @@ impl Session {
let before = st.doc.clone();
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
// The Global Edit members the edit reached besides the selection.
let mut spread = vec![];
// Objects attached to a path follow it, and dimensions follow the art they measure.
let result = match f(doc, &mut st.selection).and_then(|v| {
// Global Edit: the other members take what the edit changed about the one it edited.
if let Some(ge) = &st.global_edit {
spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]);
}
cmd::objects_on_path::refresh(&before, doc);
cmd::dimension::refresh(&before, doc)?;
Ok(v)
@@ -1902,7 +1924,7 @@ impl Session {
if st.doc.too_deep(Some(&before)) {
// Deeper trees overflow the stack of what walks them.
Err(EngineError::Other(TOO_DEEP.into()))
} else if doc_sane(&st.doc, &st.selection) {
} else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
@@ -1934,6 +1956,7 @@ impl Session {
Err(e) => {
st.doc = before;
st.selection = before_sel;
cmd::globaledit::follow_history(st);
Err(e)
}
}
@@ -2052,6 +2075,7 @@ impl Session {
if selection_changed {
st.selection.clone_from(&it.selection);
}
cmd::globaledit::follow_history(st);
}
// The previous preview's paint is undone with it.
self.pending_paint = None;
@@ -2208,6 +2232,7 @@ impl Session {
deselect_guides(&st.doc, &mut st.selection);
}
st.sync_new_art();
cmd::globaledit::follow_history(st);
st.revision += 1;
// The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile.
journal.extend(g.journal);
@@ -2285,6 +2310,7 @@ impl Session {
}
st.doc = doc;
st.selection = selection;
cmd::globaledit::follow_history(st);
if guides_locked {
deselect_guides(&st.doc, &mut st.selection);
}
@@ -2429,6 +2455,8 @@ mod tests_fontlist;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_globaledit;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_gradpanel;
+445
View File
@@ -0,0 +1,445 @@
//! Select › Start Global Edit (`select.globalEdit`, `select.globalEditOptions`): which objects
//! count as similar, and edits made to one of them reaching the others in proportion, as one undo
//! step. Behaviour: <https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Affine, PathData, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn bounds(s: &Session, id: NodeId) -> Rect {
node(s, id).geometric_bounds().unwrap()
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
fn start(s: &mut Session, key: NodeId, p: Value) -> Vec<NodeId> {
sel(s, &[key]);
let r = s.execute("select.globalEdit", &p).unwrap();
assert_eq!(r["active"], true);
ids(&r)
}
fn fill(s: &Session, id: NodeId) -> String {
node(s, id).appearance.fill_paint().color().unwrap().to_hex()
}
fn stroke(s: &Session, id: NodeId) -> (String, f64) {
let n = node(s, id);
(n.appearance.stroke_paint().color().unwrap().to_hex(), n.appearance.stroke_width())
}
fn members(s: &Session) -> Option<Vec<NodeId>> {
s.doc().unwrap().global_edit.as_ref().map(|g| g.members.iter().map(|m| m.0).collect())
}
fn close(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6)
}
/// `b`'s outline is `a`'s placed by `xf`, anchor for anchor.
fn placed(s: &Session, a: NodeId, b: NodeId, xf: Affine) -> bool {
let (pa, pb): (PathData, PathData) = (node(s, a).path_data().unwrap().transformed(xf), node(s, b).path_data().unwrap().clone());
pa.subpaths.len() == pb.subpaths.len()
&& pa.subpaths.iter().zip(&pb.subpaths).all(|(x, y)| {
x.closed == y.closed
&& x.anchors.len() == y.anchors.len()
&& x.anchors
.iter()
.zip(&y.anchors)
.all(|(m, n)| m.p.distance(n.p) < 1e-6 && m.h_in.distance(n.h_in) < 1e-6 && m.h_out.distance(n.h_out) < 1e-6)
})
}
/// A 100 pt square, a copy of it, a 20 pt square (the same shape, smaller), a 100 × 50 rectangle
/// and a 100 pt circle (other shapes).
fn squares(s: &mut Session) -> [NodeId; 5] {
[rect(s, 0.0, 0.0, 100.0, 100.0), rect(s, 300.0, 0.0, 100.0, 100.0), rect(s, 500.0, 0.0, 20.0, 20.0), rect(s, 0.0, 300.0, 100.0, 50.0), {
let r = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}]
}
#[test]
fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() {
let mut s = session();
let [a, b, c, wide, circle] = squares(&mut s);
let members = start(&mut s, a, json!({}));
assert_eq!(members, [a, b, c], "the copy and the smaller square; not the rectangle {wide} nor the circle {circle}");
// The selected object stays the selection; the others are highlighted on the canvas.
assert_eq!(s.doc().unwrap().selection.objects, [a]);
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2);
let r = s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(r["active"], false, "the second run is Stop Global Edit");
assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty());
}
#[test]
fn size_and_appearance_options_narrow_the_match() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "the smaller square has another size");
s.execute("select.globalEdit", &json!({})).unwrap();
sel(&mut s, &[b]);
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
assert_eq!(start(&mut s, a, json!({"appearance": true})), [a, c], "the copy has another fill now");
s.execute("select.globalEdit", &json!({})).unwrap();
// The options are kept for the session, and report how many objects they take.
sel(&mut s, &[a]);
let r = s.execute("select.globalEditOptions", &json!({"size": true, "appearance": true})).unwrap();
assert_eq!((r["size"].clone(), r["appearance"].clone(), r["count"].clone()), (json!(true), json!(true), json!(1)));
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
assert!(e.contains("no similar objects"), "{e}");
s.execute("select.globalEditOptions", &json!({"size": false, "appearance": false})).unwrap();
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
}
#[test]
fn an_edit_reaches_every_member_in_proportion_as_one_undo_step() {
let mut s = session();
let [a, b, c, wide, circle] = squares(&mut s);
start(&mut s, a, json!({}));
let before = (*s.doc().unwrap().doc).clone();
let steps = s.doc().unwrap().history.undo.len();
let (wide_was, circle_was) = (node(&s, wide), node(&s, circle));
// Height 100 → 50 from the top left (the Transform panel): the 20 pt square goes to 10 pt.
s.execute("object.setBounds", &json!({"height": 50, "reference": 0})).unwrap();
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 50.0)), "{:?}", bounds(&s, a));
assert!(close(bounds(&s, b), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, b));
assert!(close(bounds(&s, c), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, c));
assert_eq!((node(&s, wide), node(&s, circle)), (wide_was, circle_was), "objects of other shapes are left alone");
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
crate::tests_large_edits::undo_gives_back(&mut s, &before);
}
#[test]
fn an_anchor_moved_on_one_member_moves_on_the_others() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
start(&mut s, a, json!({}));
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap();
assert!(placed(&s, a, b, Affine::translate((300.0, 0.0))));
assert!(placed(&s, a, c, Affine::translate((500.0, 0.0)) * Affine::scale(0.2)));
assert!(!placed(&s, a, c, Affine::translate((500.0, 0.0))), "the smaller square's anchor moves a fifth as far");
}
#[test]
fn a_drag_moves_the_members_with_each_preview_and_commits_one_step() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
start(&mut s, a, json!({}));
let before = (*s.doc().unwrap().doc).clone();
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
s.preview("object.move", &json!({"dx": 40, "dy": 0})).unwrap();
assert!(close(bounds(&s, b), Rect::new(340.0, 0.0, 440.0, 100.0)), "the second preview replaces the first: {:?}", bounds(&s, b));
s.commit_interaction().unwrap();
assert!(close(bounds(&s, c), Rect::new(508.0, 0.0, 528.0, 20.0)), "{:?}", bounds(&s, c));
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move");
crate::tests_large_edits::undo_gives_back(&mut s, &before);
}
#[test]
fn any_selected_member_leads_and_fills_reach_the_others_only_when_they_change() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[c]);
s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap();
start(&mut s, a, json!({}));
let c_fill = fill(&s, c);
// Moving the copy (selected during Global Edit) moves the others in proportion and keeps
// their own fills.
sel(&mut s, &[b]);
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)), "{:?}", bounds(&s, a));
assert!(close(bounds(&s, c), Rect::new(510.0, 0.0, 530.0, 20.0)), "{:?}", bounds(&s, c));
assert_eq!(fill(&s, c), c_fill);
// A new fill reaches all of them, and the stroke weight stays the model's, not scaled.
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
for id in [a, b, c] {
assert_eq!(fill(&s, id), "#ff0000");
assert_eq!(node(&s, id).appearance.stroke_width(), 4.0);
}
// Selection changes alone edit nothing; after Stop Global Edit an edit stays on its object.
s.execute("select.globalEdit", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)));
}
#[test]
fn locked_and_hidden_objects_are_left_out() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
let locked = rect(&mut s, 0.0, 450.0, 100.0, 100.0);
let hidden = rect(&mut s, 150.0, 450.0, 100.0, 100.0);
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
assert_eq!(start(&mut s, a, json!({})), [a, b, c], "locked and hidden squares are not found");
// A member locked after the start is not edited either.
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
let b_was = node(&s, b);
sel(&mut s, &[a]);
s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap();
assert_eq!(node(&s, b), b_was);
assert!(close(bounds(&s, c), Rect::new(500.0, 2.0, 520.0, 22.0)), "{:?}", bounds(&s, c));
assert_eq!(bounds(&s, locked), Rect::new(0.0, 450.0, 100.0, 550.0));
assert_eq!(bounds(&s, hidden), Rect::new(150.0, 450.0, 250.0, 550.0));
}
#[test]
fn artboard_options_limit_where_similar_objects_are_found() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
let key = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
let same_board = rect(&mut s, 200.0, 10.0, 50.0, 50.0);
let second = rect(&mut s, 1100.0, 10.0, 50.0, 50.0);
let canvas = rect(&mut s, 2000.0, 2000.0, 50.0, 50.0);
assert_eq!(start(&mut s, key, json!({})), [key, same_board, second, canvas], "all artboards and the canvas by default");
s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(start(&mut s, key, json!({"includeCanvas": false})), [key, same_board, second]);
s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(start(&mut s, key, json!({"artboards": "2"})), [key, second, canvas]);
s.execute("select.globalEdit", &json!({})).unwrap();
assert_eq!(start(&mut s, key, json!({"artboards": "2", "includeCanvas": false})), [key, second]);
s.execute("select.globalEdit", &json!({})).unwrap();
let e = s.execute("select.globalEdit", &json!({"artboards": "3"})).unwrap_err().to_string();
assert!(e.contains("1 to 2"), "{e}");
}
#[test]
fn compound_paths_match_whole_and_keep_their_own_member_ids() {
let mut s = session();
let compound = |s: &mut Session, x: f64, k: f64| {
let (p, q) = (rect(s, x, 0.0, 100.0 * k, 100.0 * k), rect(s, x + 25.0 * k, 25.0 * k, 50.0 * k, 50.0 * k));
sel(s, &[p, q]);
NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap())
};
let (one, two) = (compound(&mut s, 0.0, 1.0), compound(&mut s, 300.0, 0.5));
let lone = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
assert_eq!(start(&mut s, one, json!({})), [one, two], "not the square {lone} alone");
let kids = |s: &Session, id: NodeId| node(s, id).children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
let two_kids = kids(&s, two);
s.execute("object.setBounds", &json!({"width": 200, "reference": 0})).unwrap();
assert!(close(bounds(&s, two), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, two));
assert_eq!(kids(&s, two), two_kids);
assert!(matches!(node(&s, two).kind, NodeKind::Compound { .. }));
}
#[test]
fn symbol_instances_of_one_symbol_match() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
let made = s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap();
let first = NodeId(made["id"].as_u64().unwrap());
let placed = s.execute("symbol.place", &json!({"name": "Tile", "x": 300, "y": 300})).unwrap();
let second = NodeId(placed["id"].as_u64().unwrap());
let other = rect(&mut s, 500.0, 500.0, 40.0, 40.0);
assert_eq!(start(&mut s, first, json!({})), [first, second], "not the plain square {other}");
let xf = |s: &Session, id: NodeId| match node(s, id).kind {
NodeKind::SymbolInstance { xf, .. } => xf,
_ => panic!("not an instance"),
};
let was = xf(&s, second);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
let (now, want) = (xf(&s, second).as_coeffs(), (Affine::translate((10.0, 0.0)) * was).as_coeffs());
assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}");
}
#[test]
fn global_edit_needs_one_supported_object() {
let mut s = session();
let [a, b, ..] = squares(&mut s);
sel(&mut s, &[a, b]);
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "a selection of two");
let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
sel(&mut s, &[t]);
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type");
let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0);
// A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the
// message says so.
let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0);
sel(&mut s, &[turned]);
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
sel(&mut s, &[lone]);
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
assert!(s.doc().unwrap().global_edit.is_none());
// A group of two squares is refused, though another such group exists.
let group = |s: &mut Session, x: f64| {
let (p, q) = (rect(s, x, 100.0, 10.0, 10.0), rect(s, x + 20.0, 100.0, 10.0, 10.0));
sel(s, &[p, q]);
NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap())
};
let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0));
sel(&mut s, &[g]);
let e = s.execute("select.globalEdit", &json!({})).unwrap_err();
assert!(matches!(e, EngineError::Disabled(..)) && e.to_string().contains("paths, compound paths and symbol instances"), "{e}");
}
#[test]
fn global_edit_is_not_saved() {
let mut s = session();
let [a, ..] = squares(&mut s);
start(&mut s, a, json!({}));
let saved = s.execute("document.serialize", &json!({})).unwrap().to_string();
assert!(!saved.to_ascii_lowercase().contains("globaledit"));
}
#[test]
fn a_stroke_weight_change_leaves_each_members_own_fill_and_stroke_colour() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[b]);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
start(&mut s, a, json!({}));
let (a_fill, c_fill) = (fill(&s, a), fill(&s, c));
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
assert_eq!(fill(&s, b), "#0000ff", "the copy keeps its own fill");
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 5.0), "and its own stroke colour, with the new weight");
assert_eq!(stroke(&s, c).1, 5.0, "the weight is the model's, not scaled");
assert_eq!((fill(&s, a), fill(&s, c)), (a_fill, c_fill));
}
#[test]
fn a_fill_colour_change_reaches_the_fills_and_not_the_strokes() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[b]);
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
start(&mut s, a, json!({}));
let (a_stroke, c_stroke) = (stroke(&s, a), stroke(&s, c));
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
for id in [a, b, c] {
assert_eq!(fill(&s, id), "#ff0000");
}
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 3.0), "the copy keeps its own stroke");
assert_eq!((stroke(&s, a), stroke(&s, c)), (a_stroke, c_stroke));
}
#[test]
fn a_fill_or_stroke_added_or_removed_reaches_the_members_that_keep_their_others() {
let mut s = session();
let [a, b, c, ..] = squares(&mut s);
sel(&mut s, &[b]);
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
// The small square has two fills: not the model's fills and strokes in order.
sel(&mut s, &[c]);
s.execute("appearance.addFill", &json!({})).unwrap();
start(&mut s, a, json!({}));
let kinds = |s: &Session, id| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
s.execute("appearance.addStroke", &json!({})).unwrap();
assert_eq!(kinds(&s, a), ["fill", "stroke", "stroke"]);
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy gains the stroke");
assert_eq!(fill(&s, b), "#0000ff", "and keeps its own fill");
assert_eq!(kinds(&s, c), kinds(&s, a), "a member with other fills and strokes takes the model's list");
s.execute("appearance.removeItem", &json!({"index": 0})).unwrap();
assert_eq!(kinds(&s, a), ["stroke", "stroke"]);
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy loses its fill");
}
#[test]
fn undo_and_redo_drop_the_members_the_document_lacks() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
// Undoing the making of the small square takes it out of the Global Edit, so a square made
// under its freed id is not edited with the others.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(members(&s), Some(vec![a, b]));
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(d, c, "the id is used again");
let d_was = node(&s, d);
sel(&mut s, &[a]);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
assert_eq!(node(&s, d), d_was);
// Redoing the deletion of a member takes it out too; with one left, Global Edit ends.
sel(&mut s, &[b]);
s.execute("edit.clear", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(members(&s), Some(vec![a, b]), "every member is there again");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(members(&s), None);
}
/// Two 100 pt squares and a 20 pt one made after `begin`, all three in a Global Edit; `put_back`
/// takes the document back to before the small one. The small square leaves the Global Edit, and
/// a square made under its freed id is not edited with the others.
fn put_back_drops_the_member<T>(begin: impl FnOnce(&mut Session) -> T, put_back: impl FnOnce(&mut Session, T)) {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
let t = begin(&mut s);
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
put_back(&mut s, t);
assert_eq!(members(&s), Some(vec![a, b]));
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
assert_eq!(d, c, "the id is used again");
let d_was = node(&s, d);
sel(&mut s, &[a]);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
assert_eq!(node(&s, d), d_was);
}
#[test]
fn a_cancelled_drag_scrub_or_undo_scope_drops_the_members_it_takes_away() {
put_back_drops_the_member(|s| s.begin_interaction("Draw").unwrap(), |s, ()| s.cancel_interaction().unwrap());
put_back_drops_the_member(Session::begin_undo_group, |s, ()| s.end_undo_group(true));
put_back_drops_the_member(
|s| {
s.begin_undo_group();
s.begin_undo_scope().unwrap()
},
|s, scope| {
s.end_undo_scope(scope, true);
s.end_undo_group(false);
},
);
}
#[test]
fn an_edit_that_takes_a_member_past_the_canvas_is_refused_whole() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 2.0, 2.0);
let big = rect(&mut s, 0.0, 0.0, 2000.0, 2000.0);
assert_eq!(start(&mut s, a, json!({})), [a, big]);
let before = (node(&s, a), node(&s, big));
let steps = s.doc().unwrap().history.undo.len();
// 4000 pt keeps the small square in range; the square 1000 times its size moves 1000 times as
// far, past `MAX_COORD`.
let e = s.execute("object.move", &json!({"dx": 4000, "dy": 0})).unwrap_err().to_string();
assert!(e.contains("out of range"), "{e}");
assert_eq!((node(&s, a), node(&s, big)), before, "nothing moved");
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
s.execute("object.move", &json!({"dx": 3000, "dy": 0})).unwrap();
assert!(close(bounds(&s, big), Rect::new(3.0e6, 0.0, 3.0e6 + 2000.0, 2000.0)), "{:?}", bounds(&s, big));
}
+4
View File
@@ -489,6 +489,10 @@ impl Session {
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place));
}
// Global Edit: the objects edited with the selected one are highlighted.
for (path, color) in crate::cmd::globaledit::highlights(self) {
v.push(Overlay::Path { path, color, width: 1.0, dashed: false });
}
if let Some((cmd, p)) = deferred_preview {
v.extend(self.deferred_preview_overlays_from_command(&cmd, &p));
}