From f0ec1c85068c7a83d87ed5d160777be03798108c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 02:38:16 -0300 Subject: [PATCH] Fix #3716: a click near an anchor selects without moving the object A Selection tool press within reach of an anchor or the centre grabs the object by that point, but the drag threshold was measured from that point instead of from the press. A press 3-6 px from it was already past the threshold, so the first pointer wobble started a move and jumped the object by the offset. The threshold now counts from where the pointer went down; a real drag still moves the object by the grabbed point. --- crates/tools/src/select.rs | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index c68cbdfba..07a6c0822 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -53,6 +53,9 @@ enum State { /// Pressed on an object; becomes Moving after the drag threshold. Moving { start: Point, + /// Where the pointer went down: the drag threshold counts from there, not from `start`, + /// which may be an anchor or centre the press grabbed a few pixels away (#3716). + press: Point, /// The point that snaps, moving with the pointer: `start`, or where a press on a path's /// stroke or beside its edge took hold of the path ([`hold_point`]). hold: Point, @@ -734,7 +737,7 @@ impl Tool for SelectionTool { && cx.select_behind && let Some(behind) = object_behind(cx, p) { - self.state = State::Moving { start: p, hold: p, began: false, deselect: None, key: None }; + self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } match hit_test(cx.doc, p, cx.selection_hit_options()) { @@ -758,7 +761,7 @@ impl Tool for SelectionTool { // Grabbed by no anchor or centre, a press on a path's stroke or beside its // edge holds the path there, and that point of it snaps (#3701). let hold = if grabbed.is_some() { grab } else { hold_point(cx, &h, p) }; - self.state = State::Moving { start: grab, hold, began: false, deselect, key }; + self.state = State::Moving { start: grab, press: p, hold, began: false, deselect, key }; out } None => { @@ -767,10 +770,10 @@ impl Tool for SelectionTool { } } } - (PointerKind::Drag, State::Moving { start, hold, began, .. }) => { + (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => { let mut out = vec![]; if !began { - if p.distance(start) < Self::drag_threshold(cx) { + if p.distance(press) < Self::drag_threshold(cx) { return out; } out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); @@ -783,7 +786,7 @@ impl Tool for SelectionTool { } // Shift has already constrained `d`: any change is a snap. self.snapped = (d - unsnapped).hypot() > 1e-9; - self.state = State::Moving { start, hold, began: true, deselect: None, key: None }; + self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out @@ -1104,6 +1107,31 @@ mod tests { assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y); } + /// #3716: a press a few pixels from an anchor grabs the object by that anchor, but the drag + /// threshold counts from where the pointer went down. A click that wobbles by a pixel selects + /// the object without moving it, instead of jumping the anchor onto the pointer. + #[test] + fn a_click_near_an_anchor_selects_without_moving() { + let (d, id) = doc_with_rect(); + let (none, mut picked) = (Selection::default(), Selection::default()); + picked.add(id); + let p = paint(); + let mut t = SelectionTool::default(); + // 5.7 px from the corner (200, 200): within reach of it, beyond the drag threshold. + let a = t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0]))), "{a:?}"); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 197.0, 196.0)); + assert!(a.is_empty(), "a 1 px wobble moves the object: {a:?}"); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Up, 197.0, 196.0)); + assert!(!a.iter().any(|a| matches!(a, Action::Commit)), "{a:?}"); + // A drag past the threshold from the press still moves it by the corner (#812). + let mut t = SelectionTool::default(); + t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 230.0, 196.0)); + let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; + assert_eq!((v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), (Some(30.0), Some(-4.0)), "the corner follows the pointer: {a:?}"); + } + /// A corner-radius drag is a transform, so the sharp selection box hides while the radius changes. #[test] fn a_corner_drag_counts_as_transforming_once_it_moves() {