mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
Merge pull request #1233 from dotCooCoo/feat/no-op-undo-menu-transforms
Object: Lock, Transform Each, Perspective and other commands that change nothing record no undo step
This commit is contained in:
@@ -266,6 +266,17 @@ fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f6
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` hold the same width points, each place and width within a rounding error
|
||||
/// (1e-9).
|
||||
fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
|
||||
let near = |p: &(f64, f64, f64), q: &(f64, f64, f64)| (p.0 - q.0).abs() <= 1e-9 && (p.1 - q.1).abs() <= 1e-9 && (p.2 - q.2).abs() <= 1e-9;
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => a.points.len() == b.points.len() && a.points.iter().zip(&b.points).all(|(p, q)| near(p, q)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.set";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
@@ -275,12 +286,16 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "left/right must be non-negative widths"));
|
||||
}
|
||||
let adjust = bool_or(p, "adjustAdjoining", false);
|
||||
let pos = s.edit("Width Point", |d, _| {
|
||||
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
|
||||
// history stay as they are.
|
||||
let mut pos = 0;
|
||||
super::overprint::edit_counted(s, "Width Point", |d| {
|
||||
let st = weighted_stroke(d, id)?;
|
||||
let before = st.profile.clone();
|
||||
let half = st.width / 2.0;
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
let pt = (t, left / half, right / half);
|
||||
let pos = match point_index(p, &prof, C)? {
|
||||
pos = match point_index(p, &prof, C)? {
|
||||
Some(i) => {
|
||||
let old = prof.points[i];
|
||||
if adjust {
|
||||
@@ -301,7 +316,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
st.profile = Some(prof);
|
||||
Ok(pos)
|
||||
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
|
||||
})?;
|
||||
Ok(json!({ "index": pos }))
|
||||
}
|
||||
@@ -310,14 +325,18 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.copy";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
|
||||
let pos = s.edit("Copy Width Point", |d, _| {
|
||||
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
|
||||
// its widths already), the document and its undo history stay as they are.
|
||||
let mut pos = 0;
|
||||
super::overprint::edit_counted(s, "Copy Width Point", |d| {
|
||||
let st = weighted_stroke(d, id)?;
|
||||
let before = st.profile.clone();
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
let i = point_index(p, &prof, C)?.ok_or_else(|| bad(C, "missing index"))?;
|
||||
let (from, l, r) = prof.points[i];
|
||||
let pos = place(&mut prof.points, (t, l, r), from < t);
|
||||
pos = place(&mut prof.points, (t, l, r), from < t);
|
||||
st.profile = Some(prof);
|
||||
Ok(pos)
|
||||
Ok(usize::from(st.profile != before))
|
||||
})?;
|
||||
Ok(json!({ "index": pos }))
|
||||
}
|
||||
@@ -373,6 +392,11 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
// When every stroke has the profile already, or there is no stroke, the document and its undo
|
||||
// history stay as they are.
|
||||
if ids.iter().all(|id| doc.node(*id).is_some_and(|n| n.appearance.stroke().is_none_or(|st| st.profile == profile))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Width Profile", |d, _| {
|
||||
for id in &ids {
|
||||
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
@@ -602,18 +626,23 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
stroke.extend(pts.get(done..).unwrap_or_default());
|
||||
let results = stroke.results();
|
||||
let (skipped, warning) = stroke.skipped();
|
||||
let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
|
||||
let mut changed = vec![];
|
||||
for (id, new) in results {
|
||||
let Some(n) = d.node_mut(id) else { continue };
|
||||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||||
*path = new;
|
||||
*live = None;
|
||||
changed.push(id.0);
|
||||
// When the stroke reshapes no path, the document and its undo history stay as they are.
|
||||
let changed = if results.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
|
||||
let mut changed = vec![];
|
||||
for (id, new) in results {
|
||||
let Some(n) = d.node_mut(id) else { continue };
|
||||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||||
*path = new;
|
||||
*live = None;
|
||||
changed.push(id.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(changed)
|
||||
})?
|
||||
};
|
||||
if s.in_interaction() {
|
||||
s.liquify_stroke = Some(stroke);
|
||||
}
|
||||
@@ -659,10 +688,21 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
|
||||
if rest {
|
||||
let pins: Vec<PuppetPin> = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect();
|
||||
s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?;
|
||||
// When the art and its pins come out as they were, the document and its undo history stay
|
||||
// as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Puppet Warp", |d| {
|
||||
warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e))?;
|
||||
Ok(usize::from(d.puppet != before.puppet || ids.iter().any(|id| d.node(*id) != before.node(*id))))
|
||||
})?;
|
||||
return Ok(ids_json);
|
||||
}
|
||||
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
|
||||
// When every pin stays where it is and turns the art by 0° or is free, the document and its undo
|
||||
// history stay as they are.
|
||||
if pins == moved && angles.iter().all(|a| a.is_none_or(|a| a == 0.0)) {
|
||||
return Ok(ids_json);
|
||||
}
|
||||
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect();
|
||||
let deformed = arap::deform(&mesh, &pins);
|
||||
let f = |q: Point| mesh.map(&deformed, q);
|
||||
@@ -707,6 +747,14 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
|
||||
g.store(d);
|
||||
}
|
||||
|
||||
/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g`
|
||||
/// leaves `d` as it is.
|
||||
fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
|
||||
let mut g = g.clone();
|
||||
g.attached.clear();
|
||||
serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v))
|
||||
}
|
||||
|
||||
/// Change view state of the grid without an undo step.
|
||||
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
|
||||
let st = s.doc_mut()?;
|
||||
@@ -724,6 +772,11 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
|
||||
let reproject = bool_or(p, "reproject", false);
|
||||
// When the document holds this grid already, no object moves, and the document and its undo
|
||||
// history stay as they are.
|
||||
if grid_kept(&s.doc()?.doc, &g) {
|
||||
return Ok(g.definition_json());
|
||||
}
|
||||
s.edit("Define Perspective Grid", |d, _| {
|
||||
if reproject {
|
||||
old.adopt_stored_attachments(d);
|
||||
@@ -800,6 +853,11 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
let plane = plane_param(&g, p, "perspective.attach")?;
|
||||
// With no object to attach, when the document holds the grid already, the document and its undo
|
||||
// history stay as they are.
|
||||
if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) {
|
||||
return Ok(json!({ "ids": [], "plane": plane.id() }));
|
||||
}
|
||||
s.edit("Attach to Active Plane", |d, _| {
|
||||
for id in &ids {
|
||||
attach_in(d, &mut g, *id, plane, false, None)?;
|
||||
@@ -813,6 +871,19 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
// When releasing leaves every object as it is (none is in perspective) and the document holds
|
||||
// the grid already, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
let unchanged = |id: &NodeId| {
|
||||
d.node(*id).is_none_or(|n| {
|
||||
let mut released = n.clone();
|
||||
persp::release(&mut released);
|
||||
released == *n
|
||||
})
|
||||
};
|
||||
if ids.iter().all(unchanged) && grid_kept(d, &g) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release with Perspective", |d, _| {
|
||||
for id in &ids {
|
||||
g.attached.remove(&id.0.to_string());
|
||||
@@ -875,6 +946,19 @@ fn persp_edit(
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
|
||||
/// of them is in perspective already, `still` holds for each one's plane and depth, and the document
|
||||
/// holds the grid already; None otherwise.
|
||||
fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result<Option<Attached>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let g = grid_of(d);
|
||||
if ids.is_empty() || !grid_kept(d, &g) {
|
||||
return Ok(None);
|
||||
}
|
||||
let at: Option<Vec<Attached>> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect();
|
||||
Ok(at.and_then(|at| at.first().copied()))
|
||||
}
|
||||
|
||||
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
|
||||
/// the drag that made it is committed.
|
||||
fn record_again(s: &mut Session, again: Value) -> Result<()> {
|
||||
@@ -921,11 +1005,25 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
|
||||
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
|
||||
// Transform Again repeats the first object's move.
|
||||
// When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid
|
||||
// included) and there is no copy, the document and its undo history stay as they are.
|
||||
let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| {
|
||||
if perp {
|
||||
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
|
||||
} else {
|
||||
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
|
||||
}
|
||||
};
|
||||
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
|
||||
let (targets, first) = match first_still {
|
||||
Some(at) => (ids, Some(at)),
|
||||
None => persp_edit(s, label, &ids, fallback, copy, &step)?,
|
||||
};
|
||||
// Transform Again repeats the first object's move, and the identity when no object moved.
|
||||
if let Some((pl, depth)) = first {
|
||||
let g = grid_of(&s.doc()?.doc);
|
||||
let again = match perp {
|
||||
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
|
||||
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
|
||||
false => {
|
||||
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
|
||||
@@ -955,7 +1053,14 @@ fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
|
||||
};
|
||||
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
|
||||
// An identity without a copy moves no object that is in perspective already: then the document
|
||||
// and its undo history stay as they are, and Transform Again repeats the identity.
|
||||
let identity = !copy && m == Affine::IDENTITY && dz == 0.0;
|
||||
let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() {
|
||||
ids
|
||||
} else {
|
||||
persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0
|
||||
};
|
||||
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
|
||||
Ok(ids_json(&targets))
|
||||
}
|
||||
@@ -992,16 +1097,25 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"move" => "Move Plane and Objects",
|
||||
_ => "Move Plane",
|
||||
};
|
||||
// The objects on the plane where it was (not those moved off it along its normal).
|
||||
let on_plane = |d: &Document| -> Vec<NodeId> {
|
||||
persp::attached_roots(d)
|
||||
.into_iter()
|
||||
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
|
||||
.map(|(id, ..)| id)
|
||||
.collect()
|
||||
};
|
||||
// A plane moved to where it is, with its objects or without them, moves nothing: when the
|
||||
// document holds the grid already, the document and its undo history stay as they are.
|
||||
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) {
|
||||
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
|
||||
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
old.adopt_stored_attachments(d);
|
||||
let mut ids = vec![];
|
||||
if objects != "none" {
|
||||
// The objects on the plane where it was (not those moved off it along its normal).
|
||||
let on: Vec<NodeId> = persp::attached_roots(d)
|
||||
.into_iter()
|
||||
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
|
||||
.map(|(id, ..)| id)
|
||||
.collect();
|
||||
let on = on_plane(d);
|
||||
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
|
||||
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
|
||||
for id in &ids {
|
||||
@@ -1025,6 +1139,11 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
g.set_offset(plane, depth);
|
||||
g.plane = plane;
|
||||
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
|
||||
// When the plane is at the object and active already, and the document holds that grid, the
|
||||
// document and its undo history stay as they are.
|
||||
if grid_kept(&st.doc, &g) {
|
||||
return Ok(json!({ "plane": plane.id(), "offset": depth }));
|
||||
}
|
||||
s.edit("Move Plane to Match Object", |d, _| {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
|
||||
@@ -226,6 +226,13 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// When there is no object above, or every one is locked (Lock) or hidden (Hide) already, the
|
||||
// document and its undo history stay as they are, and selected objects that are hidden or
|
||||
// locked are deselected.
|
||||
if hits.iter().all(|id| d.node(*id).is_none_or(|n| if lock { n.locked } else { !n.visible })) {
|
||||
s.select(|d, sel| sel.deselect_uneditable(d))?;
|
||||
return Ok(json!({ "count": hits.len() }));
|
||||
}
|
||||
let label = if lock { "Lock" } else { "Hide" };
|
||||
let n = hits.len();
|
||||
s.edit(label, |d, sel| {
|
||||
@@ -249,6 +256,11 @@ fn other_layers(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let keep: Vec<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.ancestry(*id)).flatten().collect();
|
||||
let others: Vec<NodeId> = st.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||||
let n = others.len();
|
||||
// When there is no other layer, or every one is locked (Lock) or hidden (Hide) already, the
|
||||
// document and its undo history stay as they are.
|
||||
if others.iter().all(|id| st.doc.node(*id).is_none_or(|l| if lock { l.locked } else { !l.visible })) {
|
||||
return Ok(json!({ "count": n }));
|
||||
}
|
||||
s.edit(if lock { "Lock Others" } else { "Hide Others" }, |d, _| {
|
||||
for id in &others {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -300,6 +312,12 @@ fn transform_each(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
|
||||
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||||
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
|
||||
// With no object to transform, or without a copy when both scales are 100% and there is no
|
||||
// move, rotation or reflection (with or without Random), the document and its undo history
|
||||
// stay as they are.
|
||||
if roots.is_empty() || (!copy && sh == 100.0 && sv == 100.0 && mh == 0.0 && mv == 0.0 && rot == 0.0 && !rx && !ry) {
|
||||
return Ok(ids_json(&roots));
|
||||
}
|
||||
let ids = s.edit("Transform Each", |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
|
||||
for id in &targets {
|
||||
@@ -853,12 +871,18 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let move_art = bool_or(p, "moveArtwork", true);
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
let scale_strokes = false;
|
||||
// How far each artboard moves: the grid's top-left is at the first artboard's.
|
||||
let rects: Vec<Rect> = s.doc()?.doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
|
||||
let origin = rects.first().map_or(Point::ZERO, |r| r.origin());
|
||||
let origins = super::newdoc::grid_origins_rc(&sizes, origin, (rows, cols), spacing, layout == L::GridByColumn, rtl);
|
||||
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
|
||||
// When no artboard moves more than 1e-9 pt on either axis, or there is none, the document and
|
||||
// its undo history stay as they are.
|
||||
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
|
||||
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
|
||||
}
|
||||
s.edit("Rearrange Artboards", |d, _| {
|
||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
let Some(first) = rects.first().copied() else { return Ok(()) };
|
||||
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
|
||||
let origins = super::newdoc::grid_origins_rc(&sizes, first.origin(), (rows, cols), spacing, layout == L::GridByColumn, rtl);
|
||||
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
|
||||
if move_art {
|
||||
let tops: Vec<(NodeId, Point)> = d
|
||||
.layers
|
||||
|
||||
@@ -37,6 +37,20 @@ fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// Leave a step to redo (a rectangle drawn and undone, which keeps the selection) and mark the
|
||||
/// document saved.
|
||||
fn with_a_step_to_redo(s: &mut Session) {
|
||||
rect(s, 700.0, 500.0, 10.0, 10.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
@@ -112,6 +126,58 @@ fn width_tool_gesture_is_one_undo_step() {
|
||||
assert_eq!(profile(&s, id).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Width Point with the place and widths a point has already (given its index, or at its place, with
|
||||
/// Adjust Adjoining Width Points, or as Width Point Edit gives back the widths it shows), Copy Width
|
||||
/// Point onto the side of a discontinuous point that has its widths already, and Width Profile with
|
||||
/// the profile the strokes have already (or null for a uniform stroke and for art with no stroke)
|
||||
/// record no undo step: the step to redo stays and the document stays unmodified.
|
||||
#[test]
|
||||
fn width_commands_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let id = line(&mut s);
|
||||
let uniform = line(&mut s);
|
||||
let r = rect(&mut s, 100.0, 400.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
// Points 1 and 2 make a discontinuous point at 0.5 whose sides are both 2 pt and 3 pt wide
|
||||
// (point 1 copied onto its own place), and point 3 is at 0.75.
|
||||
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 2, "right": 3})).unwrap();
|
||||
s.execute("stroke.widthPoint.copy", &json!({"id": id.0, "index": 1, "t": 0.5})).unwrap();
|
||||
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.75, "left": 4, "right": 4})).unwrap();
|
||||
let points = profile(&s, id).unwrap();
|
||||
assert_eq!(points, vec![(0.0, 1.0, 1.0), (0.5, 0.4, 0.6), (0.5, 0.4, 0.6), (0.75, 0.8, 0.8), (1.0, 1.0, 1.0)]);
|
||||
// Width Point Edit shows a width as its factor times half the weight (5 pt) and gives that back.
|
||||
// For this factor the round trip changes the last bit.
|
||||
let dragged = line(&mut s);
|
||||
let f = 1.8822996672167678;
|
||||
assert_ne!(f * 5.0 / 5.0, f);
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [dragged.0], "points": [[0, 1, 1], [0.5, f, f], [1, 1, 1]]})).unwrap();
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||
let calls = [
|
||||
("stroke.widthPoint.set", json!({"id": id.0, "index": 1, "t": 0.5, "left": 2, "right": 3}), json!({"index": 1})),
|
||||
("stroke.widthPoint.set", json!({"id": id.0, "t": 0.75, "left": 4, "right": 4}), json!({"index": 3})),
|
||||
// Adjust Adjoining multiplies point 2's 0.4 by 0.8 and divides the product by 0.8, which
|
||||
// gives 0.4000000000000001.
|
||||
("stroke.widthPoint.set", json!({"id": id.0, "index": 3, "t": 0.75, "left": 4, "right": 4, "adjustAdjoining": true}), json!({"index": 3})),
|
||||
("stroke.widthPoint.set", json!({"id": dragged.0, "index": 1, "t": 0.5, "left": f * 5.0, "right": f * 5.0}), json!({"index": 1})),
|
||||
("stroke.widthPoint.copy", json!({"id": id.0, "index": 2, "t": 0.5}), json!({"index": 2})),
|
||||
("stroke.widthProfile.set", json!({"ids": [id.0], "points": points}), json!(null)),
|
||||
("stroke.widthProfile.set", json!({"ids": [uniform.0, group.0], "points": null}), json!(null)),
|
||||
];
|
||||
for (c, p, reply) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}");
|
||||
assert_eq!(history(&s), before, "{c} {p}");
|
||||
assert_eq!(profile(&s, id).unwrap(), points, "{c} {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
assert_eq!(profile(&s, dragged).unwrap()[1], (0.5, f, f));
|
||||
// Other widths are one undo step, and they clear Redo.
|
||||
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "index": 1, "t": 0.5, "left": 3, "right": 3})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
// ---------- liquify ----------
|
||||
|
||||
fn warp_params() -> serde_json::Value {
|
||||
@@ -171,6 +237,23 @@ fn liquify_tool_replays_from_the_journal() {
|
||||
assert_eq!(path(&t, a), after);
|
||||
}
|
||||
|
||||
/// A Liquify stroke that reshapes no path (the brush reaches none) records no undo step, run as a
|
||||
/// command or dragged with the tool, and the command replies with no ids.
|
||||
#[test]
|
||||
fn liquify_that_reshapes_no_path_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let r = s.execute("object.liquify", &json!({"tool": "warp", "points": [[600, 450], [640, 450]], "diameter": 50})).unwrap();
|
||||
assert_eq!(r["ids"], json!([]));
|
||||
assert_eq!(history(&s), before, "object.liquify");
|
||||
gesture(&mut s, "warp", &[(600.0, 450.0), (620.0, 450.0), (640.0, 450.0)], Mods::default());
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.liquify");
|
||||
assert_eq!(history(&s), before, "a Warp tool drag");
|
||||
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0));
|
||||
}
|
||||
|
||||
fn session_with_rect() -> Session {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 200.0, 200.0);
|
||||
@@ -225,6 +308,40 @@ fn puppet_tool_drag_warps_selection() {
|
||||
assert!(path(&s, a).bounds().unwrap().y0 < 95.0);
|
||||
}
|
||||
|
||||
/// Puppet Warp with the pins the art holds already (as object.puppetWarp.pins lists them), and
|
||||
/// Puppet Warp with every pin left where it is and turning the art by 0° or free, record no undo
|
||||
/// step and leave the art as it is: a live shape stays live.
|
||||
#[test]
|
||||
fn puppet_warp_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
|
||||
let b = rect(&mut s, 100.0, 300.0, 300.0, 100.0);
|
||||
// The automatic pins, the right end's moved up 30 pt, warp the art in one undo step.
|
||||
let mut p = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap();
|
||||
let moved = p["moved"].as_array().unwrap();
|
||||
let right = (0..moved.len()).max_by(|&i, &j| moved[i][0].as_f64().unwrap().total_cmp(&moved[j][0].as_f64().unwrap())).unwrap();
|
||||
let y = p["moved"][right][1].as_f64().unwrap();
|
||||
p["moved"][right][1] = json!(y - 30.0);
|
||||
let undo = undo_len(&s);
|
||||
s.execute("object.puppetWarp", &p).unwrap();
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let pins = json!([[120, 350], [250, 350]]);
|
||||
assert_eq!(s.execute("object.puppetWarp", &json!({"id": b.0, "pins": pins, "moved": pins})).unwrap(), json!({"ids": [b.0]}));
|
||||
let live = matches!(s.doc().unwrap().doc.node(b).unwrap().kind, NodeKind::Path { live: Some(_), .. });
|
||||
assert_eq!((history(&s), live), (before, true), "a live shape with its pins left where they are");
|
||||
let held = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(held["auto"], json!(false));
|
||||
let warped = path(&s, a);
|
||||
let still = json!({"id": a.0, "pins": [[120, 150], [250, 150]], "moved": [[120, 150], [250, 150]], "angles": [null, 0]});
|
||||
for p in [held, still] {
|
||||
assert_eq!(s.execute("object.puppetWarp", &p).unwrap(), json!({"ids": [a.0]}), "{p}");
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(path(&s, a), warped, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- perspective ----------
|
||||
|
||||
fn grid(s: &Session) -> PerspectiveGrid {
|
||||
@@ -382,3 +499,103 @@ fn perspective_move_attaches_loose_objects_to_the_given_plane() {
|
||||
assert_eq!(grid(&s).attached_plane(a), Some(Plane::Ground));
|
||||
assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err());
|
||||
}
|
||||
|
||||
/// With the grid defined, Define Perspective Grid with the values the grid has (Reproject or not),
|
||||
/// Attach to Active Plane with no object, Release with Perspective on art not in perspective, Move
|
||||
/// Plane to where the plane is (its objects moved with it or not), and Move Plane to Match Object for
|
||||
/// an object the active plane is at already record no undo step and reply as before.
|
||||
#[test]
|
||||
fn perspective_grid_commands_that_change_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||||
s.execute("perspective.grid.set", &json!({"cell": 40})).unwrap();
|
||||
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
|
||||
let a = rect(&mut s, 300.0, 320.0, 40.0, 40.0);
|
||||
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "left"})).unwrap();
|
||||
let loose = rect(&mut s, 600.0, 100.0, 20.0, 20.0);
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let definition = grid(&s).definition_json();
|
||||
let left = |ids: Vec<u64>| json!({"plane": "left", "offset": 0.0, "ids": ids});
|
||||
let calls = [
|
||||
("perspective.grid.set", json!({"cell": 40}), definition.clone()),
|
||||
("perspective.grid.set", json!({"cell": 40, "reproject": true}), definition),
|
||||
("perspective.attach", json!({"ids": []}), json!({"ids": [], "plane": "left"})),
|
||||
("perspective.release", json!({"ids": [loose.0]}), json!(null)),
|
||||
("perspective.plane.move", json!({"by": 0}), left(vec![])),
|
||||
("perspective.plane.move", json!({"offset": 0, "objects": "move"}), left(vec![a.0])),
|
||||
("perspective.plane.matchObject", json!({"id": a.0}), json!({"plane": "left", "offset": 0.0})),
|
||||
];
|
||||
for (c, p, reply) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}");
|
||||
assert_eq!(history(&s), before, "{c} {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
// Moving the plane by 0 with copies of its objects is one undo step.
|
||||
s.execute("perspective.plane.move", &json!({"by": 0, "objects": "copy"})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
/// For an object in perspective, Move in Perspective by 0 (along its plane or its normal),
|
||||
/// Transform in Perspective by the identity, Transform Again after each, and a Perspective Selection
|
||||
/// drag that ends where it started record no undo step. So do a Move by 0 and a 1 pt Nudge that
|
||||
/// Snap to Grid takes back to the gridlines the object's edges are on, and Transform Again after
|
||||
/// each. With Copy, the identity copies the object in one undo step.
|
||||
#[test]
|
||||
fn perspective_moves_that_move_nothing_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||||
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
|
||||
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
|
||||
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let shape = path(&s, a);
|
||||
let at = json!([480, 410]);
|
||||
let calls = [
|
||||
("perspective.move", json!({"from": at, "to": at})),
|
||||
("object.transformAgain", json!({})),
|
||||
("perspective.move", json!({"from": at, "to": at, "perpendicular": true})),
|
||||
("object.transformAgain", json!({})),
|
||||
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
|
||||
("object.transformAgain", json!({})),
|
||||
];
|
||||
for (c, p) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
|
||||
assert_eq!((history(&s), path(&s, a)), (before, shape.clone()), "{c} {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
// Grabbing the object makes its plane the active one (perspective.plane.set), which marks the
|
||||
// document modified without an undo step.
|
||||
gesture(&mut s, "perspectiveSelection", &[(480.0, 410.0), (500.0, 420.0), (480.0, 410.0), (480.0, 410.0)], Mods::default());
|
||||
assert_eq!(s.journal.last().unwrap().0, "perspective.move");
|
||||
let (undo, redo, _) = history(&s);
|
||||
assert_eq!(((undo, redo), path(&s, a)), ((before.0, before.1), shape.clone()), "a drag back to its start");
|
||||
// Snap to Grid takes a move that ends 0.3 pt past a gridline crossing onto the crossing. Then it
|
||||
// takes a move by 0 and a 1 pt nudge back there, and Transform Again after each moves nothing.
|
||||
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
|
||||
let g = grid(&s);
|
||||
let (_, _, b) = g.plane_bounds(&s.doc().unwrap().doc, &[a]).unwrap();
|
||||
let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v;
|
||||
let (c, dv) = (b.center(), vectorcraft_geom::Vec2::new(past(b.x0), past(b.y0)));
|
||||
let (from, to) = (g.to_page(Plane::Right, c).unwrap(), g.to_page(Plane::Right, c + dv).unwrap());
|
||||
s.execute("perspective.move", &json!({"from": [from.x, from.y], "to": [to.x, to.y]})).unwrap();
|
||||
with_a_step_to_redo(&mut s);
|
||||
let before = history(&s);
|
||||
let on_grid = path(&s, a);
|
||||
let calls = [
|
||||
("perspective.move", json!({"from": [to.x, to.y], "to": [to.x, to.y]})),
|
||||
("object.transformAgain", json!({})),
|
||||
("perspective.nudge", json!({"dx": 1, "dy": 0})),
|
||||
("object.transformAgain", json!({})),
|
||||
];
|
||||
for (c, p) in calls {
|
||||
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
|
||||
assert_eq!((history(&s), path(&s, a)), (before, on_grid.clone()), "{c} {p}");
|
||||
}
|
||||
// The identity with Copy copies the object in one undo step.
|
||||
let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "copy": true})).unwrap();
|
||||
assert_ne!(r, json!({"ids": [a.0]}));
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
@@ -153,6 +153,73 @@ fn lock_and_hide_other_layers() {
|
||||
assert!(!s.doc().unwrap().doc.node(l2).unwrap().visible);
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||
sel(s, ids);
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// Lock › All Artwork Above and Hide › All Artwork Above with no object above the selection, or with
|
||||
/// every one locked (Lock) or hidden (Hide) already, and Lock › Other Layers and Hide › Other Layers
|
||||
/// with no other layer, or with every one locked or hidden already, record no undo step: the step to
|
||||
/// redo stays and the document stays unmodified. All Artwork Above still deselects a selected object
|
||||
/// that is hidden.
|
||||
#[test]
|
||||
fn lock_and_hide_with_nothing_to_lock_or_hide_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let over = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
|
||||
// Nothing is above `over`, and the document has one layer.
|
||||
select_with_a_step_to_redo(&mut s, &[over]);
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||
for c in ["object.lock.above", "object.hide.above", "object.lock.otherLayers", "object.hide.otherLayers"] {
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(selected(&s), [over], "{c}");
|
||||
}
|
||||
// Lock › All Artwork Above locks `over` above `a` in one undo step, and then it has nothing left
|
||||
// to lock. Hide › All Artwork Above then hides `over`, and then it has nothing left to hide.
|
||||
for c in ["object.lock.above", "object.hide.above"] {
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let undo = history(&s).0;
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c}");
|
||||
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c} again");
|
||||
assert_eq!(history(&s), before, "{c} again");
|
||||
}
|
||||
// A hidden object selected beside `a` is deselected, still without an undo step.
|
||||
let hidden = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||||
s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[hidden, a]);
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute("object.hide.above", &json!({})).unwrap(), json!({"count": 1}));
|
||||
assert_eq!((history(&s), selected(&s)), (before, vec![a]));
|
||||
// Other Layers locks, then hides, a second layer in one undo step each, and then it has nothing
|
||||
// left to lock or hide.
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
for c in ["object.lock.otherLayers", "object.hide.otherLayers"] {
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let undo = history(&s).0;
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c}");
|
||||
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 1}), "{c} again");
|
||||
assert_eq!(history(&s), before, "{c} again");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Transform Each ----------
|
||||
|
||||
#[test]
|
||||
@@ -195,6 +262,37 @@ fn transform_each_random_is_deterministic_with_seed() {
|
||||
assert!(x.x0.abs() <= 50.0 && x.width() == 100.0);
|
||||
}
|
||||
|
||||
/// Transform Each with its default values, or with any values that move nothing (both scales 100%,
|
||||
/// no move, rotation or reflection, Random or not), records no undo step and replies with the
|
||||
/// objects. With only a layer selected there is no object to transform: with Copy or without, it
|
||||
/// records no undo step and keeps the selection. With Copy and objects selected, the default values
|
||||
/// copy them in one undo step.
|
||||
#[test]
|
||||
fn transform_each_that_changes_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
let still = json!({"scaleH": 100, "scaleV": 100, "moveH": 0, "moveV": 0, "rotate": 0, "reflectX": false, "reflectY": false, "random": true});
|
||||
for p in [json!({}), still, json!({"reference": 0, "strokes": true, "corners": true, "patterns": true})] {
|
||||
assert_eq!(s.execute("object.transformEach", &p).unwrap(), json!({"ids": [a.0, b.0]}), "{p}");
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.transformEach");
|
||||
}
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
sel(&mut s, &[layer]);
|
||||
for p in [json!({"moveH": 10}), json!({"moveH": 10, "copy": true})] {
|
||||
assert_eq!(s.execute("object.transformEach", &p).unwrap(), json!({"ids": []}), "{p}");
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(selected(&s), [layer], "{p}");
|
||||
}
|
||||
sel(&mut s, &[a, b]);
|
||||
let r = s.execute("object.transformEach", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_is_a_noop() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -283,3 +283,32 @@ fn rearrange_aliases_and_rejects_bad_values() {
|
||||
}
|
||||
assert_eq!(boards(&s), before, "nothing moved");
|
||||
}
|
||||
|
||||
/// Rearrange All Artboards with the artboards already where it puts them, or with no artboard,
|
||||
/// records no undo step: the step to redo stays, the document stays unmodified, and the reply is the
|
||||
/// same.
|
||||
#[test]
|
||||
fn rearrange_that_moves_no_artboard_records_no_undo_step() {
|
||||
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
|
||||
let p = json!({"columns": 2, "spacing": 5});
|
||||
let reply = s.execute("artboard.rearrange", &p).unwrap();
|
||||
let placed = boards(&s);
|
||||
// A rectangle drawn and undone leaves a step to redo, and the document is marked saved.
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let history = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
};
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false));
|
||||
assert_eq!(s.execute("artboard.rearrange", &p).unwrap(), reply);
|
||||
assert_eq!((history(&s), boards(&s)), (before, placed));
|
||||
// A file can hold no artboard.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute("artboard.rearrange", &p).unwrap(), json!({"artboards": 0, "rows": 1, "columns": 1}));
|
||||
assert_eq!(history(&s), before);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user