Merge pull request #3781 from Taha-Mahmoodi/feat/start-global-edit

Select: Start Global Edit edits the objects of the same shape together, each in proportion
This commit is contained in:
BFlat
2026-10-24 03:13:36 +07:00
committed by GitHub
39 changed files with 1539 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 }))
}
+600
View File
@@ -0,0 +1,600 @@
//! 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,
})
}
}
/// Outlines with their layer colours ([`highlights`]).
pub type Outlines = Vec<(BezPath, [u8; 3])>;
/// 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)>,
/// [`highlights`] as of a document revision: the canvas asks every frame.
outlines: std::cell::RefCell<Option<(u64, Outlines)>>,
}
/// 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, outlines: Default::default() });
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) -> Outlines {
let Some(st) = s.active() else { return vec![] };
let Some(ge) = &st.global_edit else { return vec![] };
if let Some((rev, out)) = &*ge.outlines.borrow()
&& *rev == st.revision
{
return out.clone();
}
let symbols = st.doc.symbol_boxes();
let out: Vec<_> = 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();
*ge.outlines.borrow_mut() = Some((st.revision, out.clone()));
out
}
+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));
}
+7
View File
@@ -5430,6 +5430,13 @@ axis Y Axis Y ос
The plug-in is computing… Приставката изчислява…
Inspect Plug-in Проверка на приставка
Cancel Plug-in Run Отказ от изпълнението на приставката
Stop Global Edit Край на глобалното редактиране
Global Edit Options Опции за глобално редактиране
Match Съвпадение
On Artboards В работните области
All Artboards Всички работни области
Include Objects On Canvas Включване на обекти от платното
Result(s): {count} Резултат(и): {count}
Add to Symbols Добавяне към символи
Basic Shapes Основни фигури
Open Symbol Library Отваряне на библиотека със символи
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5481,6 +5481,17 @@ axis Y Axis osa Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy`
@msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí
Stop Global Edit Ukončit globální úpravy
Global Edit Options Možnosti globálních úprav
Match Shoda
On Artboards Na pracovních plochách
All Artboards Všechny pracovní plochy
Include Objects On Canvas Zahrnout objekty na plátně
Result(s): {count} Výsledky: {count}
@msg select one object vyberte jeden objekt
@msg Global Edit works on paths, compound paths and symbol instances Globální úpravy pracují s cestami, složenými cestami a instancemi symbolů
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (otočené a zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all nebo rozsah, například 1, 3-5, ne {v}
Add to Symbols Přidat do symbolů
Basic Shapes Základní tvary
Open Symbol Library Otevřít knihovnu symbolů
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5446,6 +5446,13 @@ axis Y Axis Y-akse
The plug-in is computing… Plug-in'en regner…
Inspect Plug-in Undersøg plug-in
Cancel Plug-in Run Annullér plug-in-kørsel
Stop Global Edit Stop global redigering
Global Edit Options Indstillinger for global redigering
Match Match
On Artboards På tegneflader
All Artboards Alle tegneflader
Include Objects On Canvas Medtag objekter på lærredet
Result(s): {count} Resultat(er): {count}
Add to Symbols Føj til symboler
Basic Shapes Grundlæggende former
Open Symbol Library Åbn symbolbibliotek
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5064,6 +5064,17 @@ axis Y Axis Y-Achse
@msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein
@msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt
Stop Global Edit Globale Bearbeitung beenden
Global Edit Options Optionen für globale Bearbeitung
Match Übereinstimmung
On Artboards Auf Zeichenflächen
All Artboards Alle Zeichenflächen
Include Objects On Canvas Objekte auf der Leinwand einschließen
Result(s): {count} Ergebnis(se): {count}
@msg select one object wählen Sie ein Objekt aus
@msg Global Edit works on paths, compound paths and symbol instances Globale Bearbeitung funktioniert mit Pfaden, zusammengesetzten Pfaden und Symbolinstanzen
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gedrehte oder gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all oder ein Bereich wie 1, 3-5, nicht {v}
Add to Symbols Zu Symbolen hinzufügen
Basic Shapes Grundformen
Open Symbol Library Symbol-Bibliothek öffnen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5433,6 +5433,17 @@ axis Y Axis Άξονας Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy`
@msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης
Stop Global Edit Τερματισμός καθολικής επεξεργασίας
Global Edit Options Επιλογές καθολικής επεξεργασίας
Match Αντιστοίχιση
On Artboards Σε πίνακες σχεδίασης
All Artboards Όλοι οι πίνακες σχεδίασης
Include Objects On Canvas Συμπερίληψη αντικειμένων στον καμβά
Result(s): {count} Αποτέλεσμα(τα): {count}
@msg select one object επιλέξτε ένα αντικείμενο
@msg Global Edit works on paths, compound paths and symbol instances Η καθολική επεξεργασία λειτουργεί σε διαδρομές, σύνθετες διαδρομές και παρουσίες συμβόλων
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα περιστραμμένα ή κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ή ένα εύρος όπως 1, 3-5, όχι {v}
Add to Symbols Προσθήκη στα σύμβολα
Basic Shapes Βασικά σχήματα
Open Symbol Library Άνοιγμα βιβλιοθήκης συμβόλων
Can't render this file because it contains an unexpected character in line 2066 and column 19.
+11
View File
@@ -5423,6 +5423,17 @@ axis Y Axis Eje Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy`
@msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte
Stop Global Edit Detener edición global
Global Edit Options Opciones de edición global
Match Coincidir
On Artboards En mesas de trabajo
All Artboards Todas las mesas de trabajo
Include Objects On Canvas Incluir objetos en el lienzo
Result(s): {count} Resultado(s): {count}
@msg select one object seleccione un objeto
@msg Global Edit works on paths, compound paths and symbol instances La edición global funciona con trazados, trazados compuestos e instancias de símbolos
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias giradas o reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervalo como 1, 3-5, no {v}
Add to Symbols Agregar a símbolos
Basic Shapes Formas básicas
Open Symbol Library Abrir biblioteca de símbolos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5512,6 +5512,17 @@ axis Y Axis Y-akseli
@msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy`
@msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy
Stop Global Edit Lopeta yleismuokkaus
Global Edit Options Yleismuokkauksen asetukset
Match Vastaavuus
On Artboards Piirtoalustoilla
All Artboards Kaikki piirtoalustat
Include Objects On Canvas Sisällytä kankaalla olevat objektit
Result(s): {count} Tulokset: {count}
@msg select one object valitse yksi objekti
@msg Global Edit works on paths, compound paths and symbol instances Yleismuokkaus toimii poluille, yhdistelmäpoluille ja symbolien esiintymille
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (kierrettyjä tai peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all tai alue, kuten 1, 3-5, ei {v}
Add to Symbols Lisää symboleihin
Basic Shapes Perusmuodot
Open Symbol Library Avaa symbolikirjasto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5480,6 +5480,17 @@ axis Y Axis Axe Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy`
@msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte
Stop Global Edit Arrêter la modification globale
Global Edit Options Options de modification globale
Match Correspondance
On Artboards Sur les plans de travail
All Artboards Tous les plans de travail
Include Objects On Canvas Inclure les objets sur la toile
Result(s): {count} Résultat(s) : {count}
@msg select one object sélectionnez un seul objet
@msg Global Edit works on paths, compound paths and symbol instances La modification globale s’applique aux tracés, aux tracés transparents et aux occurrences de symboles
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies pivotées ou symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards` : all ou une plage telle que 1, 3-5, et non {v}
Add to Symbols Ajouter aux symboles
Basic Shapes Formes de base
Open Symbol Library Ouvrir une bibliothèque de symboles
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5467,6 +5467,17 @@ axis Y Axis Y tengely
@msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy`
@msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett
Stop Global Edit Globális szerkesztés befejezése
Global Edit Options Globális szerkesztés beállításai
Match Egyezés
On Artboards Rajztáblákon
All Artboards Minden rajztábla
Include Objects On Canvas Vásznon lévő objektumok is
Result(s): {count} Eredmény(ek): {count}
@msg select one object jelöljön ki egy objektumot
@msg Global Edit works on paths, compound paths and symbol instances A globális szerkesztés útvonalakon, összetett útvonalakon és szimbólumpéldányokon működik
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nem található hasonló objektum (az elforgatott vagy tükrözött másolatok nem számítanak egyezésnek); módosítsa a globális szerkesztés beállításait, és próbálja újra
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all vagy egy tartomány, például 1, 3-5, nem {v}
Add to Symbols Hozzáadás a szimbólumokhoz
Basic Shapes Alapalakzatok
Open Symbol Library Szimbólumkönyvtár megnyitása
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5446,6 +5446,13 @@ axis Y Axis Sumbu Y
The plug-in is computing… Plug-in sedang menghitung…
Inspect Plug-in Periksa plug-in
Cancel Plug-in Run Batalkan jalannya plug-in
Stop Global Edit Hentikan edit global
Global Edit Options Opsi edit global
Match Cocokkan
On Artboards Di artboard
All Artboards Semua artboard
Include Objects On Canvas Sertakan objek di kanvas
Result(s): {count} Hasil: {count}
Add to Symbols Tambahkan ke simbol
Basic Shapes Bentuk dasar
Open Symbol Library Buka pustaka simbol
Can't render this file because it contains an unexpected character in line 3837 and column 19.
+11
View File
@@ -5098,6 +5098,17 @@ axis Y Axis Asse Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy`
@msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente
Stop Global Edit Interrompi modifica globale
Global Edit Options Opzioni modifica globale
Match Corrispondenza
On Artboards Sulle tavole da disegno
All Artboards Tutte le tavole da disegno
Include Objects On Canvas Includi oggetti sulla tela
Result(s): {count} Risultati: {count}
@msg select one object selezionate un oggetto
@msg Global Edit works on paths, compound paths and symbol instances La modifica globale funziona su tracciati, tracciati composti e istanze di simboli
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nessun oggetto simile trovato (le copie ruotate o riflesse non vengono riconosciute); cambiate le opzioni di modifica globale e riprovate
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervallo come 1, 3-5, non {v}
Add to Symbols Aggiungi ai simboli
Basic Shapes Forme di base
Open Symbol Library Apri libreria simboli
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5160,6 +5160,17 @@ axis Y Axis Y 軸
@msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません
@msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください
Stop Global Edit グローバル編集を終了
Global Edit Options グローバル編集オプション
Match 一致
On Artboards アートボード上
All Artboards すべてのアートボード
Include Objects On Canvas カンバス上のオブジェクトを含める
Result(s): {count} 結果: {count}
@msg select one object オブジェクトを 1 つ選択してください
@msg Global Edit works on paths, compound paths and symbol instances グローバル編集はパス、複合パス、シンボルインスタンスに使用できます
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 類似するオブジェクトが見つかりません(回転または反転したコピーは一致しません)。グローバル編集オプションを変更してもう一度試してください
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all または 1, 3-5 のような範囲を指定してください ({v} ではなく)
Add to Symbols シンボルに追加
Basic Shapes 基本シェイプ
Open Symbol Library シンボルライブラリを開く
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5397,6 +5397,17 @@ axis Y Axis Y ღერძი
@msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy`
@msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით
Stop Global Edit გლობალური რედაქტირების დასრულება
Global Edit Options გლობალური რედაქტირების პარამეტრები
Match დამთხვევა
On Artboards სამუშაო დაფებზე
All Artboards ყველა სამუშაო დაფა
Include Objects On Canvas ტილოზე არსებული ობიექტების ჩართვა
Result(s): {count} შედეგ(ებ)ი: {count}
@msg select one object აირჩიეთ ერთი ობიექტი
@msg Global Edit works on paths, compound paths and symbol instances გლობალური რედაქტირება მუშაობს კონტურებზე, შედგენილ კონტურებსა და სიმბოლოს ეგზემპლარებზე
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again მსგავსი ობიექტები ვერ მოიძებნა (შემობრუნებული ან არეკლილი ასლები არ ემთხვევა); შეცვალეთ გლობალური რედაქტირების პარამეტრები და სცადეთ ხელახლა
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ან დიაპაზონი, მაგალითად 1, 3-5, და არა {v}
Add to Symbols სიმბოლოებში დამატება
Basic Shapes ძირითადი ფიგურები
Open Symbol Library სიმბოლოების ბიბლიოთეკის გახსნა
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5061,6 +5061,17 @@ axis Y Axis Y축
@msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다
@msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요
Stop Global Edit 전체 편집 중지
Global Edit Options 전체 편집 옵션
Match 일치
On Artboards 아트보드에서
All Artboards 모든 아트보드
Include Objects On Canvas 캔버스의 개체 포함
Result(s): {count} 결과: {count}
@msg select one object 개체 하나를 선택하십시오
@msg Global Edit works on paths, compound paths and symbol instances 전체 편집은 패스, 컴파운드 패스, 심볼 인스턴스에서 작동합니다
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 유사한 개체를 찾을 수 없습니다(회전하거나 반사한 복사본은 일치하지 않습니다). 전체 편집 옵션을 변경한 후 다시 시도하십시오
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all 또는 1, 3-5 같은 범위여야 합니다({v}이(가) 아님)
Add to Symbols 심볼에 추가
Basic Shapes 기본 모양
Open Symbol Library 심볼 라이브러리 열기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5489,6 +5489,13 @@ axis Y Axis Y-as
The plug-in is computing… De plug-in is aan het rekenen…
Inspect Plug-in Plug-in inspecteren
Cancel Plug-in Run Plug-in-run annuleren
Stop Global Edit Globale bewerking stoppen
Global Edit Options Opties voor globale bewerking
Match Overeenkomst
On Artboards Op tekengebieden
All Artboards Alle tekengebieden
Include Objects On Canvas Objecten op het canvas opnemen
Result(s): {count} Resultaat/resultaten: {count}
Add to Symbols Aan symbolen toevoegen
Basic Shapes Basisvormen
Open Symbol Library Symboolbibliotheek openen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5333,6 +5333,17 @@ axis Y Axis Oś Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy`
@msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi
Stop Global Edit Zakończ edycję globalną
Global Edit Options Opcje edycji globalnej
Match Dopasowanie
On Artboards Na obszarach kompozycji
All Artboards Wszystkie obszary kompozycji
Include Objects On Canvas Uwzględnij obiekty na płótnie
Result(s): {count} Wyniki: {count}
@msg select one object zaznacz jeden obiekt
@msg Global Edit works on paths, compound paths and symbol instances Edycja globalna działa na ścieżkach, ścieżkach złożonych i wystąpieniach symboli
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nie znaleziono podobnych obiektów (obrócone i odbite kopie nie są dopasowywane); zmień opcje edycji globalnej i spróbuj ponownie
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all lub zakres, np. 1, 3-5, a nie {v}
Add to Symbols Dodaj do symboli
Basic Shapes Kształty podstawowe
Open Symbol Library Otwórz bibliotekę symboli
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5398,6 +5398,17 @@ axis Y Axis Eixo Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy`
@msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar
Stop Global Edit Parar Edição Global
Global Edit Options Opções de Edição Global
Match Corresponder
On Artboards Nas Mesas de Trabalho
All Artboards Todas as Mesas de Trabalho
Include Objects On Canvas Incluir Objetos na Tela
Result(s): {count} Resultado(s): {count}
@msg select one object selecione um objeto
@msg Global Edit works on paths, compound paths and symbol instances A edição global funciona com demarcadores, demarcadores compostos e instâncias de símbolos
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nenhum objeto semelhante encontrado (cópias giradas ou refletidas não são correspondidas); altere as opções de edição global e tente novamente
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ou um intervalo como 1, 3-5, não {v}
Add to Symbols Adicionar aos Símbolos
Basic Shapes Formas Básicas
Open Symbol Library Abrir Biblioteca de Símbolos
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11
View File
@@ -5094,6 +5094,17 @@ axis Y Axis Ось Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy`
@msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится
Stop Global Edit Завершить глобальное редактирование
Global Edit Options Параметры глобального редактирования
Match Совпадение
On Artboards На монтажных областях
All Artboards Все монтажные области
Include Objects On Canvas Включать объекты на холсте
Result(s): {count} Результаты: {count}
@msg select one object выберите один объект
@msg Global Edit works on paths, compound paths and symbol instances Глобальное редактирование работает с контурами, составными контурами и экземплярами символов
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again похожие объекты не найдены (повёрнутые или отражённые копии не учитываются); измените параметры глобального редактирования и повторите попытку
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all или диапазон, например 1, 3-5, а не {v}
Add to Symbols Добавить в символы
Basic Shapes Базовые фигуры
Open Symbol Library Открыть библиотеку символов
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5437,6 +5437,17 @@ axis Y Axis Y-axel
@msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy`
@msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt
Stop Global Edit Avsluta global redigering
Global Edit Options Alternativ för global redigering
Match Matcha
On Artboards På ritytor
All Artboards Alla ritytor
Include Objects On Canvas Ta med objekt på arbetsytan
Result(s): {count} Resultat: {count}
@msg select one object markera ett objekt
@msg Global Edit works on paths, compound paths and symbol instances Global redigering fungerar på banor, sammansatta banor och symbolinstanser
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again inga liknande objekt hittades (roterade eller speglade kopior matchas inte); ändra alternativen för global redigering och försök igen
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all eller ett intervall som 1, 3-5, inte {v}
Add to Symbols Lägg till i symboler
Basic Shapes Grundformer
Open Symbol Library Öppna symbolbibliotek
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -5444,6 +5444,17 @@ axis Y Axis Y ekseni
@msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz
@msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin
Stop Global Edit Genel Düzenlemeyi Durdur
Global Edit Options Genel Düzenleme Seçenekleri
Match Eşleştir
On Artboards Çalışma Yüzeylerinde
All Artboards Tüm Çalışma Yüzeyleri
Include Objects On Canvas Tuvaldeki Nesneleri Dahil Et
Result(s): {count} Sonuç(lar): {count}
@msg select one object bir nesne seçin
@msg Global Edit works on paths, compound paths and symbol instances Genel düzenleme yollar, bileşik yollar ve sembol örnekleri üzerinde çalışır
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again benzer nesne bulunamadı (döndürülmüş veya yansıtılmış kopyalar eşleşmez); Genel Düzenleme Seçeneklerini değiştirip yeniden deneyin
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ya da 1, 3-5 gibi bir aralık olmalı, {v} değil
Add to Symbols Sembollere Ekle
Basic Shapes Temel Şekiller
Open Symbol Library Sembol Kitaplığını Aç
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -5078,6 +5078,17 @@ axis Y Axis Вісь Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy`
@msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться
Stop Global Edit Завершити глобальне редагування
Global Edit Options Параметри глобального редагування
Match Збіг
On Artboards На монтажних областях
All Artboards Усі монтажні області
Include Objects On Canvas Включати об’єкти на полотні
Result(s): {count} Результати: {count}
@msg select one object виберіть один об’єкт
@msg Global Edit works on paths, compound paths and symbol instances Глобальне редагування працює з контурами, складеними контурами та екземплярами символів
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again схожих об’єктів не знайдено (повернуті чи віддзеркалені копії не враховуються); змініть параметри глобального редагування та спробуйте ще раз
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all або діапазон, наприклад 1, 3-5, а не {v}
Add to Symbols Додати до символів
Basic Shapes Базові фігури
Open Symbol Library Відкрити бібліотеку символів
Can't render this file because it contains an unexpected character in line 747 and column 40.
+11
View File
@@ -5078,6 +5078,17 @@ axis Y Axis Trục Y
@msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy`
@msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý
Stop Global Edit Dừng chỉnh sửa toàn cục
Global Edit Options Tùy chọn chỉnh sửa toàn cục
Match Khớp
On Artboards Trên các bảng vẽ
All Artboards Tất cả bảng vẽ
Include Objects On Canvas Bao gồm đối tượng trên vùng vẽ
Result(s): {count} Kết quả: {count}
@msg select one object hãy chọn một đối tượng
@msg Global Edit works on paths, compound paths and symbol instances Chỉnh sửa toàn cục hoạt động trên đường dẫn, đường dẫn phức hợp và phiên bản biểu tượng
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again không tìm thấy đối tượng tương tự (các bản sao đã xoay hoặc lật không được tính); hãy thay đổi tùy chọn chỉnh sửa toàn cục rồi thử lại
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all hoặc một phạm vi như 1, 3-5, không phải {v}
Add to Symbols Thêm vào ký hiệu
Basic Shapes Hình cơ bản
Open Symbol Library Mở thư viện ký hiệu
Can't render this file because it contains an unexpected character in line 4051 and column 10.
+11
View File
@@ -5321,6 +5321,17 @@ axis Y Axis Y 轴
@msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy`
@msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装
Stop Global Edit 停止全局编辑
Global Edit Options 全局编辑选项
Match 匹配
On Artboards 在画板上
All Artboards 所有画板
Include Objects On Canvas 包括画布上的对象
Result(s): {count} 结果:{count}
@msg select one object 请选择一个对象
@msg Global Edit works on paths, compound paths and symbol instances 全局编辑适用于路径、复合路径和符号实例
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 未找到相似对象(旋转或镜像的副本不会匹配);请更改全局编辑选项后重试
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:应为 all 或 1, 3-5 这样的范围,而不是 {v}
Add to Symbols 添加到符号
Basic Shapes 基本形状
Open Symbol Library 打开符号库
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -5117,6 +5117,17 @@ axis Y Axis Y 軸
@msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy`
@msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget`
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝
Stop Global Edit 停止整體編輯
Global Edit Options 整體編輯選項
Match 符合
On Artboards 在工作區域上
All Artboards 所有工作區域
Include Objects On Canvas 包含畫布上的物件
Result(s): {count} 結果:{count}
@msg select one object 請選取一個物件
@msg Global Edit works on paths, compound paths and symbol instances 整體編輯適用於路徑、複合路徑和符號實體
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 找不到相似的物件(旋轉或鏡像的副本不會相符);請變更整體編輯選項後再試一次
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:應為 all 或 1, 3-5 這類範圍,而不是 {v}
Add to Symbols 新增至符號
Basic Shapes 基本形狀
Open Symbol Library 開啟符號資料庫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6 -1
View File
@@ -2128,6 +2128,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
});
if editing { "Edit Envelope" } else { "Edit Contents" }.into()
}
"select.globalEdit" => {
if app.session.active().is_some_and(|d| d.global_edit.is_some()) { "Stop Global Edit" } else { "Start Global Edit" }.into()
}
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
@@ -3062,7 +3065,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
c("Area Text Objects", "select.object.areaText"),
],
),
todo("Start Global Edit"),
c("Start Global Edit", "select.globalEdit"),
Sep,
c("Save Selection…", "select.save"),
c("Update Selection", "select.update"),
@@ -4239,6 +4242,8 @@ pub const DYNAMIC_LABELS: &[&str] = &[
"Show Print Tiling",
"Edit Envelope",
"Edit Contents",
"Start Global Edit",
"Stop Global Edit",
"Undo",
"Redo",
"Unlock Grid",
+148
View File
@@ -145,6 +145,80 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
}
global_edit_row(app, ui);
}
/// Start Global Edit (Stop Global Edit while it is on) with the Global Edit Options beside it, in
/// Quick Actions as the help page places them
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>).
fn global_edit_row(app: &mut VectorcraftApp, ui: &mut Ui) {
let on = app.session.active().is_some_and(|d| d.global_edit.is_some());
if !crate::menus::enabled(app, "select.globalEdit") {
return;
}
ui.horizontal(|ui| {
let label = if on { tl!("Stop Global Edit") } else { tl!("Start Global Edit") };
if widgets::flat_button(ui, label, ui.available_width() - 26.0).clicked() {
crate::menus::invoke(app, "select.globalEdit", json!({}));
}
let options = widgets::icon_button_enabled(ui, "chevron-down", tl!("Global Edit Options"), false, !on, 20.0);
egui::Popup::menu(&options).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| global_edit_options(app, ui));
});
}
/// The Global Edit Options: what else similar objects must share (Match), where they may lie (On
/// Artboards) and how many objects that takes now.
fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.set_min_width(220.0);
let o = app.session.global_edit_options.clone();
let set = |app: &mut VectorcraftApp, p: serde_json::Value| crate::menus::invoke(app, "select.globalEditOptions", p);
widgets::subheader(ui, tl!("Match"));
if widgets::check(ui, tl!("Appearance"), o.appearance, true) {
set(app, json!({ "appearance": !o.appearance }));
}
if widgets::check(ui, tl!("Size"), o.size, true) {
set(app, json!({ "size": !o.size }));
}
widgets::subheader(ui, tl!("On Artboards"));
if widgets::check(ui, tl!("All Artboards"), o.artboards.is_none(), true) {
set(app, json!({ "artboards": if o.artboards.is_some() { "all" } else { "1" } }));
}
if let Some(range) = &o.artboards {
ui.horizontal(|ui| {
ui.label(tl!("Range:"));
if let Some(r) = widgets::text_field(ui, "global-edit-range", Some(range), 120.0, 1) {
set(app, json!({ "artboards": r }));
}
});
}
if widgets::check(ui, tl!("Include Objects On Canvas"), o.include_canvas, true) {
set(app, json!({ "includeCanvas": !o.include_canvas }));
}
let count = global_edit_count(app, ui.ctx()).to_string();
dim_label(ui, &crate::i18n::fmt(tl!("Result(s): {count}"), &[("count", &count)]));
}
/// The cached Result(s) count: the (document, revision, options, one selected object) it was
/// found for, and the count.
type GlobalEditCount = Option<((u64, u64, vectorcraft_engine::cmd::globaledit::Options, Option<u64>), usize)>;
/// How many objects Start Global Edit would take now (the options' Result(s) line), cached per
/// document revision, options and selected object, as finding them walks the whole document.
fn global_edit_count(app: &VectorcraftApp, ctx: &egui::Context) -> usize {
let Some(st) = app.session.active() else { return 0 };
let selected = match st.selection.objects.as_slice() {
[id] => Some(id.0),
_ => None,
};
let key = (st.uid, st.revision, app.session.global_edit_options.clone(), selected);
match pstate::<GlobalEditCount>(ctx, "props-global-edit-count") {
Some((k, count)) if k == key => count,
_ => {
let count = vectorcraft_engine::cmd::globaledit::match_count(&app.session);
set_pstate::<GlobalEditCount>(ctx, "props-global-edit-count", Some((key, count)));
count
}
}
}
/// The one selected clip group's Edit Clipping Path and Edit Contents, at the top of the panel as in
@@ -802,6 +876,80 @@ mod tests {
assert_eq!(selected(&app), std::slice::from_ref(&mask), "Edit Clipping Path selects the clipping path");
}
/// Start Global Edit in Quick Actions starts it on the one selected path, the button and the
/// Select menu item then read Stop Global Edit, and Escape (the app's key handling) stops it.
/// Selected type has no Start Global Edit button.
#[test]
fn start_global_edit_quick_action_and_escape() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
let ctx = egui::Context::default();
// Tall enough for Quick Actions at the foot of the panel.
let tall = |app: &mut VectorcraftApp, events: Vec<Event>| {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
_ => None,
})
.collect::<Vec<_>>()
};
let on = |app: &VectorcraftApp| app.session.active().unwrap().global_edit.is_some();
let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "select.globalEdit", "Start Global Edit");
assert_eq!(label(&app), "Start Global Edit");
let texts = tall(&mut app, vec![]);
let at = texts.iter().find(|(t, _)| t == "Start Global Edit").map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{texts:?}"));
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
tall(&mut app, vec![Event::PointerMoved(at), b(true)]);
tall(&mut app, vec![b(false)]);
assert!(on(&app), "the Quick Action starts Global Edit");
assert_eq!(app.session.active().unwrap().global_edit.as_ref().unwrap().members.len(), 2);
assert!(tall(&mut app, vec![]).iter().any(|(t, _)| t == "Stop Global Edit"));
assert_eq!(label(&app), "Stop Global Edit");
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let raw =
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events: vec![escape], ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx()));
out.textures_delta.clear();
assert!(!on(&app), "Escape stops Global Edit");
assert_eq!(label(&app), "Start Global Edit");
assert_eq!(app.session.active().unwrap().selection.objects.iter().map(|n| json!(n.0)).collect::<Vec<_>>(), [a]);
let t = app.run("text.create", json!({"x": 50, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [t]})).unwrap();
assert!(!crate::menus::enabled(&app, "select.globalEdit"));
assert!(!tall(&mut app, vec![]).iter().any(|(t, _)| t == "Start Global Edit"), "type has no Global Edit");
}
/// The options' Result(s) count is found once per document revision, options and selected
/// object, not every frame; another selected object or other options find it again.
#[test]
fn the_global_edit_result_count_is_cached() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("shape.rectangle", json!({"x": 200, "y": 20, "width": 80, "height": 80})).unwrap();
let e = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 40, "height": 40})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a]})).unwrap();
let ctx = egui::Context::default();
assert_eq!(global_edit_count(&app, &ctx), 2);
// The next frame reads the cache: a count planted under the same key is what it shows.
let (key, _) = pstate::<GlobalEditCount>(&ctx, "props-global-edit-count").unwrap();
set_pstate::<GlobalEditCount>(&ctx, "props-global-edit-count", Some((key, 7)));
assert_eq!(global_edit_count(&app, &ctx), 7);
app.run("select.set", json!({"ids": [e]})).unwrap();
assert_eq!(global_edit_count(&app, &ctx), 1, "another selected object");
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(global_edit_count(&app, &ctx), 2);
app.run("select.globalEditOptions", json!({"size": true})).unwrap();
assert_eq!(global_edit_count(&app, &ctx), 1, "other options");
}
// Regression test for #1910: the Properties panel's flip buttons are named as the reference
// app's Properties panel names them, not Flip Horizontal and Flip Vertical (the Transform
// panel menu's names).
+2
View File
@@ -390,6 +390,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
let _ = app.run("object.pattern.done", json!({}));
} else if app.session.active().is_some_and(|d| d.isolation.is_some()) {
let _ = app.run("object.exitIsolation", json!({}));
} else if app.session.active().is_some_and(|d| d.global_edit.is_some()) {
let _ = app.run("select.globalEdit", json!({}));
} else if app.ui.flyout.is_some() {
app.ui.flyout = None;
} else if escape_pair.is_some_and(|second| escape_resets_rotation(app, ctx, second)) {
+11 -1
View File
File diff suppressed because one or more lines are too long
+21
View File
@@ -1288,6 +1288,27 @@ everything in it, and `select.all`, `select.allOnArtboard` and `select.inverse`
among the objects beside it in its group or layer, passing over hidden and locked objects and sublayers. When there
is no such object, the selection stays as it is.
## Global Edit
Select › Start Global Edit is `select.globalEdit {appearance?, size?, artboards?, includeCanvas?}`. With one path,
compound path or symbol instance selected, it finds the objects of the same shape (moved or scaled copies; not rotated
ones), among those `select.all` would take and the objects in their groups, and returns
`{active: true, count, ids}`, the selected object first. `appearance` also asks for the same fill and stroke, `size`
for the same width and height; `artboards` is `"all"` or a range such as `"1, 3-5"`, and `includeCanvas: false`
leaves out the objects on no artboard. Missing params come from `select.globalEditOptions` (the Global Edit Options,
kept for the session; it returns them with `count`, the objects a start would take now). The selection stays the
one object. Until the next `select.globalEdit` (Stop Global Edit, `{active: false}`), what an edit of a selected match
changed is given to the other matches, in the same undo step, and nothing else. A changed outline or anchors go to
each match in proportion to its size. A changed opacity or blending, and each changed setting of a fill or stroke
(paint, weight, opacity, blending, effects…), are copied as they are to the match's fill or stroke of the same kind
and rank; its other fills and strokes, and their unchanged settings, stay its own (a stroke weight change leaves the
fills). A stroke weight set on the selected match is given as it is, and resizing the outline in proportion never
scales a match's stroke weight. Hidden or locked matches are left as they are. A fill or stroke added or removed is
added or removed on the matches whose fills and strokes lay in the same order; the others, and any other change to the
list, take the whole list. Whenever the document is put back (Undo, Redo, a cancelled drag or scrub), the matches it no
longer has leave the Global Edit, which ends when fewer than two are left. An edit that would take any match past the
canvas fails with the coordinates-out-of-range error and changes nothing. Nothing of it is saved.
## Saved selections
Select → Save Selection… keeps the selected objects under a name, in the document: `select.save {name?}` (default
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long