From 61a99ed4ed29d8bae206a6e72d58d049192f76dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 01:47:08 -0800 Subject: [PATCH] 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. --- crates/tools/src/direct.rs | 17 +++++++++++-- crates/tools/src/direct/tests.rs | 39 ++++++++++++++++++++++++++++++ crates/tools/src/guides/targets.rs | 7 ++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 2b2f04c83..d27a9f2a4 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -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, + /// 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)>, /// 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()) { diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index c249bd40a..9fbec501e 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -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 = + 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 diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index d553cb0f8..500e391ac 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -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 {