Fix #322: dragged direction handles take Shift and smart guides

Dragging a direction handle with Direct Selection or the Anchor Point tool
sent the pointer position straight to path.setHandle: Shift did nothing and
the handle never snapped, while the Pen already constrains the handles it
pulls out.

guides::snap_handle places a dragged handle: with Shift at a multiple of
45° from its anchor (the Pen's constraint, keeping the dragged length),
otherwise snapped as a drawn point is (smart guides, the grid, pixels).
Both tools use it and show the smart guides while the handle moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EthanSuquet
2026-10-08 03:25:27 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 820f405c61
commit 86de124cc8
5 changed files with 98 additions and 9 deletions
+8 -2
View File
@@ -77,11 +77,13 @@ pub struct DirectSelectionTool {
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
spine: Option<(NodeId, usize)>,
mesh: MeshEdit,
/// Smart guides of the handle being dragged.
guides: Vec<Overlay>,
}
impl DirectSelectionTool {
pub fn new(group: bool) -> Self {
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default() }
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![] }
}
}
@@ -320,9 +322,11 @@ impl Tool for DirectSelectionTool {
out
}
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
self.guides = guides;
vec![Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": q.x, "y": q.y, "independent": ev.mods.alt}),
)]
}
(PointerKind::Drag, State::SpinePoint { id, anchor, from, start, began }) => {
@@ -369,6 +373,7 @@ impl Tool for DirectSelectionTool {
}
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
self.state = State::Idle;
self.guides.clear();
vec![Action::Commit]
}
(PointerKind::Up, State::Marquee { start, add, .. }) => {
@@ -398,6 +403,7 @@ impl Tool for DirectSelectionTool {
match &self.state {
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
State::Corner(c) => c.overlays(cx),
State::Handle { .. } => self.guides.clone(),
_ => {
let mut out = self.spine_overlays(cx);
out.extend(self.mesh.overlays(cx));
+16 -6
View File
@@ -11,7 +11,7 @@ use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor};
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Copy, Debug)]
enum State {
@@ -24,6 +24,8 @@ enum State {
pub struct AnchorTool {
id: &'static str,
state: State,
/// Smart guides of the handle being dragged.
guides: Vec<Overlay>,
}
impl AnchorTool {
@@ -34,7 +36,7 @@ impl AnchorTool {
"scissors" => "scissors",
_ => "addAnchor",
};
Self { id, state: State::Idle }
Self { id, state: State::Idle, guides: vec![] }
}
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
@@ -128,10 +130,14 @@ impl Tool for AnchorTool {
"path.convertAnchor".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
),
State::Handle { id, si, ai, out, .. } => Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": p.x, "y": p.y, "independent": true}),
),
State::Handle { id, si, ai, out, .. } => {
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
self.guides = guides;
Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}),
)
}
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
"path.reshapeSegment".into(),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
@@ -141,6 +147,7 @@ impl Tool for AnchorTool {
out
}
("anchorPoint", PointerKind::Up) => {
self.guides.clear();
let st = std::mem::replace(&mut self.state, State::Idle);
match st {
State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => {
@@ -172,6 +179,9 @@ impl Tool for AnchorTool {
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
if matches!(self.state, State::Handle { .. }) { self.guides.clone() } else { vec![] }
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
match self.id {
"addAnchor" if !m.alt => Cursor::PenAdd,
+13
View File
@@ -171,6 +171,19 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<
Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0))
}
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
/// pointer at `p`: with Shift at a multiple of 45° from its anchor (as the Pen draws them), else
/// snapped as a drawn point is (smart guides, the grid, pixels).
pub fn snap_handle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
if shift {
let anchor = cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai).map(|a| a.p));
if let Some(a) = anchor {
return (a + vectorcraft_geom::constrain_angle(p - a, 45.0), vec![]);
}
}
snap_draw(cx, p, &[])
}
/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the
/// selection or of the object under the pointer, when Snap to Point or Smart Guides is on.
pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {