Smart Guides: Snap to Last Location puts paths Direct Selection moves by an anchor back

A path selected whole that Direct Selection moves held by one of its anchors snapped only through the anchor-drag targets, so Snap to Last Location never put it back. The move now goes back to where it began within Snapping Tolerance, labeled "last location", as a Selection tool move does; an Alt copy is not pulled back.
This commit is contained in:
dotCooCoo
2026-11-08 12:21:36 +07:00
committed by bflatastic
parent b093e53e77
commit 61a99ed4ed
3 changed files with 61 additions and 2 deletions
+15 -2
View File
@@ -60,7 +60,7 @@ use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
use crate::meshedit::{MeshEdit, grid_of};
use crate::pathtype::{self, BracketDrag, near_bracket};
use crate::rulerguide::GuideEdit;
use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at};
use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at, selection_bounds};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Debug)]
@@ -200,6 +200,10 @@ pub struct DirectSelectionTool {
/// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left
/// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original.
copy_snap: Option<PointSnap>,
/// With Smart Guides › Snap to Last Location, for paths selected whole that an anchor holds
/// while they move: the targets that put the move back where it began, and the bounds it began
/// with. Set when the move begins.
last_location: Option<(Targets, Option<Rect>)>,
/// What the dragged handle snaps to.
handle_snap: HandleSnap,
/// What the objects being moved snap to, gathered when the move begins.
@@ -249,6 +253,7 @@ impl DirectSelectionTool {
anchor_snap: None,
line_end: None,
copy_snap: None,
last_location: None,
handle_snap: HandleSnap::default(),
move_snap: None,
partial_snap: None,
@@ -724,6 +729,7 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
self.last_location = None;
(self.click, self.click_segment, self.click_group, self.frame_click) = (None, None, None, None);
if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] }
}
@@ -1440,6 +1446,9 @@ impl DirectSelectionTool {
// Alt can go down or up at any time: gather both while nothing has moved.
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default())));
// As a move with the Selection tool does ([`MoveSnap::new`]).
let back = cx.smart_guides && !cx.snap_to_grid && cx.snap_to_last_location;
self.last_location = back.then(|| (Targets::last_location(cx), selection_bounds(cx)));
} else {
self.move_snap = Some(MoveSnap::new(cx));
}
@@ -1463,7 +1472,10 @@ impl DirectSelectionTool {
return out;
}
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
if let (Some(grab), Some(snap)) = (grab, snap) {
let back = self.last_location.as_ref().filter(|_| !ev.mods.alt).and_then(|(t, from)| t.snap_back(*from, d, cx.snap_tol()));
if let (Some(_), Some(ov)) = (grab, back) {
(d, self.guides) = (Vec2::ZERO, ov);
} else if let (Some(grab), Some(snap)) = (grab, snap) {
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
(d, self.guides) = (q - grab, guides);
@@ -1487,6 +1499,7 @@ impl DirectSelectionTool {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
self.last_location = None;
let group = self.click_group.take();
// A click on one of several selected frame anchors selects it alone.
if let (false, Some((id, anchor))) = (began, self.frame_click.take()) {
+39
View File
@@ -542,6 +542,45 @@ fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() {
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]);
}
/// Smart Guides › Snap to Last Location: a path selected whole that Direct Selection moves held by
/// one of its anchors goes back to where it began within Snapping Tolerance, labeled "last
/// location", a move Shift keeps to its line too; farther out, or with the option off, it moves as
/// dragged.
#[test]
fn snap_to_last_location_puts_a_path_moved_by_its_anchor_back() {
let (mut d, id) = doc_with_rect();
// An artboard whose edges and center are out of reach.
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let mut s = Selection::default();
s.set([id]);
let p = paint();
let on = ToolContext { snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) };
// A press on the top-left anchor, then a drag to `to` with `m` held.
let moved = |c: &ToolContext, to: (f64, f64), m: Mods| {
let mut t = DirectSelectionTool::new(false);
assert!(t.pointer(c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty());
let a = t.pointer(c, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m));
let Some(Action::Preview(command, v)) = a.last() else { panic!("{a:?}") };
assert_eq!(command, "object.transform");
let labels: Vec<String> =
t.overlays(c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect();
(Vec2::new(v["matrix"][4].as_f64().unwrap_or(f64::NAN), v["matrix"][5].as_f64().unwrap_or(f64::NAN)), labels)
};
let none = Mods::default();
let (back, labels) = moved(&on, (106.0, 105.0), none);
assert!(back.hypot() < 1e-9, "{back:?}: 7.8 pt away, within 10");
assert_eq!(labels, ["last location"]);
let shift = Mods { shift: true, ..none };
let (back, _) = moved(&on, (107.0, 100.0), shift);
assert!(back.hypot() < 1e-9, "{back:?}: a Shift move goes back too");
let (out, labels) = moved(&on, (109.0, 108.0), none);
assert!((out - Vec2::new(9.0, 8.0)).hypot() < 1e-9, "{out:?}: 12 pt away, out of reach");
assert!(labels.is_empty(), "{labels:?}");
let off = ToolContext { snap_to_last_location: false, ..on };
let (stays, _) = moved(&off, (106.0, 105.0), none);
assert!((stays - Vec2::new(6.0, 5.0)).hypot() < 1e-9, "{stays:?}: the option off");
}
/// A click (no drag) on an anchor that is selected with others, whether the whole path is or
/// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the
/// selection so a drag moves it all, the release without a drag narrows it. Shift still
+7
View File
@@ -186,6 +186,13 @@ impl Targets {
(self.last_location && d.hypot() <= tol).then(|| from.and_then(|r| self.label(r.center(), "last location")).into_iter().collect())
}
/// Targets that hold nothing but Smart Guides › Snap to Last Location ([`Self::snap_back`]),
/// styled as `cx` asks: for a move of the selection whose other snaps come from other targets,
/// such as paths selected whole that Direct Selection moves held by an anchor.
pub fn last_location(cx: &ToolContext) -> Self {
Self { last_location: cx.snap_to_last_location, ..Self::default() }.styled(cx)
}
/// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides ›
/// Spacing Guides the neighbours whose gaps the moving edge should match ([`Self::snap_scale`]).
pub fn for_scale(cx: &ToolContext) -> Self {