mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+21
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user