mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Selection: a Shift-constrained move that snaps stays on its line
A move that Shift kept level, upright or on a diagonal left that line when it snapped. With View › Snap to Grid on, a level Shift-drag also moved the selection up or down onto a gridline, and Snap to Pixel, Smart Guides and Snap to Point moved it off the line in the same way. Each axis snapped on its own after Shift had constrained the move. With the Selection tool, and with Group Selection and Direct Selection moving whole objects, a Shift-constrained move now slides along its line until it snaps. With Snap to Grid or Snap to Pixel, the move slides along the line by the shorter of the two distances that put it on a gridline or a whole pixel on either axis. The bounds line up with the other objects, or space evenly with them, only on the axes the line runs along, and the grabbed point stops where the line passes an anchor or crosses a ruler guide. A level or upright move snaps along its own axis only. Moves without Shift snap as before. Docs: docs/target-app-parity.md.
This commit is contained in:
@@ -1784,6 +1784,24 @@ fn bounding_box_handles_snap_to_the_grid() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Shift-constrained move with Snap to Grid on stays level: the square's center lands on a
|
||||
/// gridline across, and its top stays where it was, off the grid.
|
||||
#[test]
|
||||
fn a_shift_move_snapped_to_the_grid_stays_on_its_line() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
|
||||
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
|
||||
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
|
||||
let v = ViewInfo { snap_to_grid: true, ..ViewInfo::default() };
|
||||
let shift = vectorcraft_tools::Mods { shift: true, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (kind, x, y) in [(PointerKind::Down, 151.0, 151.0), (PointerKind::Drag, 180.0, 153.0), (PointerKind::Up, 180.0, 153.0)] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(shift), v).unwrap();
|
||||
}
|
||||
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
|
||||
assert!(on_grid(b.center().x) && (b.y0 - 101.0).abs() < 1e-9, "{b:?}, grid step {step}");
|
||||
}
|
||||
|
||||
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
|
||||
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
|
||||
#[test]
|
||||
|
||||
@@ -663,7 +663,7 @@ impl Tool for DirectSelectionTool {
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
} else if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
|
||||
(d, self.guides) = if ev.mods.shift { snap.snap_along(cx, start, d, ev.mods.alt) } else { snap.snap(cx, start, d, ev.mods.alt) };
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
out.push(Action::Preview(
|
||||
@@ -1949,6 +1949,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Group Selection and Direct Selection on a whole path keep a Shift-constrained move on its
|
||||
/// line when it snaps, as the Selection tool does: level by 13.04 with Snap to Grid on, B's
|
||||
/// right side (413.04) lands on the gridline 414, and its top stays at 300, between 297 and
|
||||
/// 306.
|
||||
#[test]
|
||||
fn a_shift_move_of_objects_snaps_along_its_line() {
|
||||
let (d, _, b) = two_squares();
|
||||
let p = paint();
|
||||
let mut s = Selection::default();
|
||||
s.set([b]);
|
||||
let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
for group in [true, false] {
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
let (v, _) = drag(&mut t, &c, (350.0, 350.0), (363.0, 351.0), shift);
|
||||
let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap());
|
||||
assert!((x - 14.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_type_switches_to_type_tool() {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -685,6 +685,77 @@ impl Targets {
|
||||
self.snap_rects(&[r], tol)
|
||||
}
|
||||
|
||||
/// Snap a point that may only move along `dir` (the grabbed point of a move Shift keeps to its
|
||||
/// line): onto the point of that line nearest an anchor or center within `tol` of `p`, labeled
|
||||
/// at the anchor or center, else to where the nearest target line or ruler guide crosses the
|
||||
/// line, at most twice `tol` away ([`Self::slide`]). Returns where it goes and the guides.
|
||||
pub(crate) fn snap_point_along(&self, p: Point, dir: Vec2, tol: f64) -> (Point, Vec<Overlay>) {
|
||||
let l2 = dir.hypot2();
|
||||
if !l2.is_finite() || l2 <= 1e-18 {
|
||||
return (p, vec![]);
|
||||
}
|
||||
if let Some((q, k)) = self.nearest_point(p, tol) {
|
||||
return (p + dir * ((q - p).dot(dir) / l2), self.label(q, k.label()).into_iter().collect());
|
||||
}
|
||||
match self.slide(p, dir, p, tol, false) {
|
||||
Some((q, from)) => (q, self.lined_up(q, from)),
|
||||
None => (p, vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::snap_rects`] for rectangles that may only move along `dir` (a move Shift keeps to
|
||||
/// its line): they slide along it until the edge or center of one of them is in line with a
|
||||
/// target, within `tol` of it on that axis, the target that needs the shortest way along the
|
||||
/// line winning. Lining up with nothing, a single rectangle with Spacing Guides slides to where
|
||||
/// it is spaced evenly along an axis the line runs along. Returns the shift (along `dir`) and
|
||||
/// the guides.
|
||||
pub(crate) fn snap_rects_along(&self, rects: &[Rect], dir: Vec2, tol: f64) -> (Vec2, Vec<Overlay>) {
|
||||
let len = dir.hypot();
|
||||
if !len.is_finite() || len <= 1e-9 {
|
||||
return (Vec2::ZERO, vec![]);
|
||||
}
|
||||
let u = dir / len;
|
||||
// The axes the line runs along: the x axis (`true`) and the y axis, and how far a step of
|
||||
// one along the line goes on that axis.
|
||||
let axes = [(true, u.x), (false, u.y)].into_iter().filter(|(_, c)| c.abs() > 1e-9);
|
||||
// The way along the line, the target line it lines up with, where that line starts, and
|
||||
// its axis.
|
||||
let mut best: Option<(f64, f64, Point, bool)> = None;
|
||||
for (x, c) in axes.clone() {
|
||||
let lines = if x { &self.xs } else { &self.ys };
|
||||
for r in rects {
|
||||
let vs = if x { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] };
|
||||
for v in vs {
|
||||
for (t, from, _) in lines_near(lines, v, tol).iter().filter(|(t, ..)| (t - v).abs() <= tol) {
|
||||
let s = (t - v) / c;
|
||||
if best.is_none_or(|b| s.abs() < b.0.abs()) {
|
||||
best = Some((s, *t, *from, x));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((s, t, from, x)) = best {
|
||||
let ov = rects.iter().map(|r| *r + u * s).reduce(|a, b| a.union(b)).and_then(|m| {
|
||||
if x {
|
||||
self.line(Point::new(t, from.y.min(m.y0)), Point::new(t, from.y.max(m.y1)))
|
||||
} else {
|
||||
self.line(Point::new(from.x.min(m.x0), t), Point::new(from.x.max(m.x1), t))
|
||||
}
|
||||
});
|
||||
return (u * s, ov.into_iter().collect());
|
||||
}
|
||||
let [r] = rects else { return (Vec2::ZERO, vec![]) };
|
||||
if self.boxes.is_empty() {
|
||||
return (Vec2::ZERO, vec![]);
|
||||
}
|
||||
let spaced =
|
||||
axes.filter_map(|(x, c)| self.even_spacing(*r, x, tol).map(|(shift, _)| (shift / c, x))).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
|
||||
let Some((s, x)) = spaced else { return (Vec2::ZERO, vec![]) };
|
||||
let gaps = self.even_spacing(*r + u * s, x, tol).map(|(_, gaps)| gaps).unwrap_or_default();
|
||||
(u * s, gaps.into_iter().flat_map(|g| self.gap_marks(g, x, tol)).collect())
|
||||
}
|
||||
|
||||
/// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any
|
||||
/// of them nearest a target, per axis. A single moved selection with Spacing Guides
|
||||
/// ([`Self::for_move`]) is spaced evenly along an axis that lines up with nothing
|
||||
|
||||
+222
-1
@@ -178,6 +178,69 @@ impl MoveSnap {
|
||||
}
|
||||
(d, guides)
|
||||
}
|
||||
|
||||
/// [`Self::snap`] for a move Shift keeps to its line (the line through `start` along `d`):
|
||||
/// every snap slides the move along that line. With Snap to Grid or Snap to Pixel, the move
|
||||
/// slides along the line by the shorter of the two distances that put it on a gridline or a
|
||||
/// whole pixel on either axis (the one axis of a level or upright line). The bounds line up
|
||||
/// with the other art ([`Targets::snap_rects_along`]) and the grabbed point lands where the line
|
||||
/// passes an anchor or crosses a ruler guide ([`Targets::snap_point_along`]). A move of zero
|
||||
/// length does not snap.
|
||||
pub(crate) fn snap_along(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec<Overlay>) {
|
||||
let len = d.hypot();
|
||||
if !len.is_finite() || len <= 1e-9 {
|
||||
return (d, vec![]);
|
||||
}
|
||||
let u = d / len;
|
||||
// The way along the line that takes away the offset `pull` on one axis: on the axis the
|
||||
// line runs along, the shorter way of the two.
|
||||
let along = |pull: Vec2| {
|
||||
[(pull.x, u.x), (pull.y, u.y)]
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c.abs() > 1e-9)
|
||||
.map(|(p, c)| p / c)
|
||||
.min_by(|a, b| a.abs().total_cmp(&b.abs()))
|
||||
.unwrap_or(0.0)
|
||||
};
|
||||
let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) };
|
||||
let mut guides = vec![];
|
||||
if cx.snap_to_grid
|
||||
&& let Some(b) = self.bounds
|
||||
{
|
||||
d += u * along(grid_pull(b + d, cx.grid_step()));
|
||||
}
|
||||
if let Some(t) = points {
|
||||
let (q, ov) = t.snap_point_along(start + d, u, cx.tol(cx.snap_tolerance));
|
||||
d = q - start;
|
||||
guides = ov;
|
||||
}
|
||||
if let (Some(t), Some(b)) = (targets, self.bounds) {
|
||||
if t.has_point_near(start + d, cx.snap_tol()) {
|
||||
let (q, ov) = t.snap_point_along(start + d, u, cx.snap_tol());
|
||||
d = q - start;
|
||||
guides = ov;
|
||||
} else {
|
||||
let (adj, ov) = t.snap_rects_along(&[b + d], u, cx.snap_tol());
|
||||
d += adj;
|
||||
guides = ov;
|
||||
}
|
||||
}
|
||||
if let Some(t) = &self.rulers {
|
||||
let (q, ov) = t.snap_point_along(start + d, u, cx.tol(cx.snap_tolerance));
|
||||
if q != start + d {
|
||||
d = q - start;
|
||||
guides = ov;
|
||||
}
|
||||
}
|
||||
if cx.snap_to_pixel
|
||||
&& let Some(b) = self.bounds
|
||||
{
|
||||
let at = Point::new(b.x0, b.y0) + d;
|
||||
d += u * along(Vec2::new(at.x.round() - at.x, at.y.round() - at.y));
|
||||
guides.clear();
|
||||
}
|
||||
(d, guides)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shift that lands `r` on the grid (a gridline every `step`), on each axis by whichever of its
|
||||
@@ -503,7 +566,7 @@ impl Tool for SelectionTool {
|
||||
let mut d = move_delta(start, p, m.shift);
|
||||
let unsnapped = d;
|
||||
if let Some(snap) = &self.moving {
|
||||
(d, self.guides) = snap.snap(cx, start, d, m.alt);
|
||||
(d, self.guides) = if m.shift { snap.snap_along(cx, start, d, m.alt) } else { snap.snap(cx, start, d, m.alt) };
|
||||
}
|
||||
// Shift has already constrained `d`: any change is a snap.
|
||||
self.snapped = (d - unsnapped).hypot() > 1e-9;
|
||||
@@ -1111,6 +1174,164 @@ mod tests {
|
||||
assert!((x - 7.0).abs() < 1e-9 && (y + 1.0).abs() < 1e-9, "{x} {y}");
|
||||
}
|
||||
|
||||
/// The selection Shift-dragged from (150, 150) to `to`: the move it previews, and its guide
|
||||
/// lines.
|
||||
fn shift_moved(cx: &ToolContext, to: (f64, f64)) -> (Vec2, Vec<(Point, Point)>) {
|
||||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(cx, &shift(PointerKind::Down, 150.0, 150.0));
|
||||
let a = t.pointer(cx, &shift(PointerKind::Drag, to.0, to.1));
|
||||
let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") };
|
||||
let lines = t.overlays(cx).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect();
|
||||
(Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), lines)
|
||||
}
|
||||
|
||||
/// `d` is `want` within 1e-9 on each axis.
|
||||
fn moved_by(d: Vec2, want: (f64, f64)) -> bool {
|
||||
(d.x - want.0).abs() < 1e-9 && (d.y - want.1).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// A Shift-constrained move snapped to the grid stays on its line: a level or upright one lands
|
||||
/// on the grid along its own axis only, and a diagonal one slides along the diagonal until an
|
||||
/// edge or the center is on a gridline on the axis that needs the shorter way there.
|
||||
#[test]
|
||||
fn a_shift_move_snapped_to_the_grid_stays_on_its_line() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
// Level by 13.04: the center (163.04) lands on the gridline 162, and the top stays at 100,
|
||||
// between the gridlines 99 and 108.
|
||||
let (m, _) = shift_moved(&cx, (163.0, 151.0));
|
||||
assert!(moved_by(m, (12.0, 0.0)), "{m:?}");
|
||||
let (m, _) = shift_moved(&cx, (151.0, 163.0));
|
||||
assert!(moved_by(m, (0.0, 12.0)), "{m:?}");
|
||||
// Up and to the right by 13 on each axis: the center (163, 137) is 1 from the gridline 162
|
||||
// and 2 from 135, so the move goes back along the diagonal by 1 on each axis.
|
||||
let (m, _) = shift_moved(&cx, (163.0, 137.0));
|
||||
assert!(moved_by(m, (12.0, -12.0)), "{m:?}");
|
||||
}
|
||||
|
||||
/// A Shift-constrained move with Snap to Pixel on puts the top-left on a whole pixel on the
|
||||
/// axis its line runs along, and on a diagonal on the axis that needs the shorter way there.
|
||||
#[test]
|
||||
fn a_shift_move_snapped_to_pixels_stays_on_its_line() {
|
||||
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4));
|
||||
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) };
|
||||
// Pressed at (150, 150), the square is grabbed by its center (150.3, 150.4).
|
||||
let (m, _) = shift_moved(&cx, (163.0, 151.0));
|
||||
assert!(moved_by(m, (12.7, 0.0)), "{m:?}");
|
||||
let (m, _) = shift_moved(&cx, (151.0, 163.0));
|
||||
assert!(moved_by(m, (0.0, 12.6)), "{m:?}");
|
||||
// By 13.16 on each axis, the top-left (113.46, 113.56) is 0.44 from a whole pixel on y and
|
||||
// 0.46 on x.
|
||||
let (m, _) = shift_moved(&cx, (163.0, 164.0));
|
||||
assert!(moved_by(m, (13.6, 13.6)), "{m:?}");
|
||||
}
|
||||
|
||||
/// A Shift-constrained move that Smart Guides line up with another object stays on its line:
|
||||
/// a level or upright one lines up along its own axis only, with the guide of that axis, and
|
||||
/// a diagonal one slides along the diagonal to the target that needs the shorter way.
|
||||
#[test]
|
||||
fn a_shift_move_lined_up_by_smart_guides_stays_on_its_line() {
|
||||
let with = |r: Rect| {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let other = d.alloc_id();
|
||||
d.insert(
|
||||
Some(l),
|
||||
1,
|
||||
vectorcraft_doc::Node::path(other, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()),
|
||||
)
|
||||
.unwrap();
|
||||
(d, id)
|
||||
};
|
||||
let p = paint();
|
||||
// Level by 100.02: the right edge lines up with the other square's left (300). That
|
||||
// square's top (203), 3 below the selection's bottom and within reach, does not pull it
|
||||
// down.
|
||||
let (d, id) = with(Rect::new(300.0, 203.0, 350.0, 253.0));
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let (m, lines) = shift_moved(&cx(&d, &s, &p), (250.0, 152.0));
|
||||
assert!(moved_by(m, (100.0, 0.0)), "{m:?}");
|
||||
assert!(!lines.is_empty() && lines.iter().all(|(a, b)| a.x == b.x), "only upright guides: {lines:?}");
|
||||
let (d, id) = with(Rect::new(203.0, 300.0, 253.0, 350.0));
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let (m, lines) = shift_moved(&cx(&d, &s, &p), (152.0, 250.0));
|
||||
assert!(moved_by(m, (0.0, 100.0)), "{m:?}");
|
||||
assert!(!lines.is_empty() && lines.iter().all(|(a, b)| a.y == b.y), "only level guides: {lines:?}");
|
||||
// By 110.5 on each axis: the right edge (310.5) is 0.5 from the other square's left and the
|
||||
// bottom 3 from its top, so the move goes on by 0.5 on each axis.
|
||||
let (d, id) = with(Rect::new(311.0, 313.5, 361.0, 363.5));
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let (m, lines) = shift_moved(&cx(&d, &s, &p), (260.5, 260.5));
|
||||
assert!(moved_by(m, (111.0, 111.0)), "{m:?}");
|
||||
assert!(matches!(lines[..], [(a, b)] if a.x == 311.0 && b.x == 311.0), "{lines:?}");
|
||||
}
|
||||
|
||||
/// A level Shift-constrained move that Spacing Guides space evenly keeps its line: the gap to
|
||||
/// the next square becomes 60, the gap between the other two, and their tops (102), 2 below
|
||||
/// the selection's, do not pull it down.
|
||||
#[test]
|
||||
fn a_shift_move_spaced_by_smart_guides_stays_on_its_line() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
for x in [330.0, 440.0] {
|
||||
let other = d.alloc_id();
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(x, 102.0, x + 50.0, 202.0));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
}
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let (m, lines) = shift_moved(&cx(&d, &s, &p), (221.0, 151.0));
|
||||
assert!(moved_by(m, (70.0, 0.0)), "{m:?}");
|
||||
assert!(!lines.is_empty(), "the equal gaps are marked");
|
||||
}
|
||||
|
||||
/// A Shift-constrained move that snaps by its grabbed point stays on its line: with Smart
|
||||
/// Guides off and Snap to Point on, it stops where the line passes nearest an anchor, or
|
||||
/// where it crosses a ruler guide; with Smart Guides on, Snap to Point still slides it along
|
||||
/// the line onto a ruler guide.
|
||||
#[test]
|
||||
fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let other = d.alloc_id();
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 151.5, 350.0, 201.5));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
d.guides = vec![vectorcraft_doc::Guide::new(true, 420.5)];
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) };
|
||||
// The grabbed point (299, 150) is 1.8 from the anchor (300, 151.5): it stops at (300, 150),
|
||||
// the point of the line nearest the anchor.
|
||||
let (m, _) = shift_moved(&off, (299.0, 151.0));
|
||||
assert!(moved_by(m, (150.0, 0.0)), "{m:?}");
|
||||
// On the diagonal, 1 short of the ruler guide at 420.5: it goes on to it on both axes.
|
||||
let (m, _) = shift_moved(&off, (419.5, 419.5));
|
||||
assert!(moved_by(m, (270.5, 270.5)), "{m:?}");
|
||||
// Smart Guides on, the selection alone with a ruler guide 1.5 below the grabbed point.
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides = vec![vectorcraft_doc::Guide::new(false, 330.5)];
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let (m, _) = shift_moved(&cx(&d, &s, &p), (329.0, 329.0));
|
||||
assert!(moved_by(m, (180.5, 180.5)), "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_on_a_selected_object_moves_it_constrained() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -572,6 +572,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Object › Arrange › Send to Current Layer returns the error "the target layer is locked or hidden" and leaves the selected objects where they are when the current layer is hidden or locked, or is inside a hidden or locked layer.
|
||||
Selected objects are deselected, the key object included, when a row drag moves them into a hidden layer or group, when `node.move` moves them into a hidden or locked layer or group, when Merge Selected moves them into a hidden or locked layer, when Collect in New Layer moves them into a new layer inside a hidden or locked layer, when Flatten Artwork moves them into a locked layer, and when applying a data set hides them.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't).
|
||||
A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only.
|
||||
A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center.
|
||||
- **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls.
|
||||
- **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows.
|
||||
|
||||
Reference in New Issue
Block a user