Merge pull request #1104 from bobtherobot/fix/direct-selection-drag-copy

Direct Selection: drag paths selected whole by an anchor; Alt-copy at any time, snapping by that anchor
This commit is contained in:
BFlat
2026-10-11 12:26:09 +07:00
committed by GitHub
8 changed files with 243 additions and 32 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1366,10 +1366,17 @@ impl Session {
let Some(mut it) = st.interaction.take() else { return Ok(()) };
let Some(preview) = it.preview.take() else { return Ok(()) };
let perspective_again = it.perspective_again.take();
let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
// Alt pressed or released during a move drag turns it into a copy or back: name it for
// what it did in the end.
match (it.label.as_str(), copy) {
("Move", true) => it.label = "Copy".into(),
("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(),
_ => {}
}
st.keep_interaction(it);
if preview.0 == "object.transform" {
let m = cmd::matrix_param(&preview.1, "matrix");
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
if let Some(m) = m {
st.last_transform = Some((m, copy));
st.last_perspective = None;
+16
View File
@@ -355,3 +355,19 @@ fn an_undo_group_is_one_undo_step() {
assert!((width(&s) - 120.0).abs() < 1e-9);
assert_eq!(undo_len(&s), undo + 2);
}
/// A move drag is named for what its last preview did: Alt pressed or released during the drag
/// turns it into a copy or back (the Selection and Direct Selection tools).
#[test]
fn a_move_drag_is_named_for_whether_it_copied() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let last = |s: &Session| s.doc().unwrap().history.undo.last().map(|e| e.label.clone());
for (begin, copy, label) in [("Move", true, "Copy"), ("Copy", false, "Move"), ("Move", false, "Move")] {
s.begin_interaction(begin).unwrap();
s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": copy})).unwrap();
s.commit_interaction().unwrap();
assert_eq!(last(&s).as_deref(), Some(label), "begun as {begin}, copy: {copy}");
}
}
+153 -13
View File
@@ -3,7 +3,8 @@
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
//! steps), drag a direction handle to
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
//! direction handle to
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
@@ -28,7 +29,7 @@ use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
use crate::bbox::move_delta;
@@ -50,8 +51,11 @@ enum State {
grab: Point,
began: bool,
},
/// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the
/// path by, which snaps as a dragged anchor does.
MoveObject {
start: Point,
grab: Option<Point>,
began: bool,
},
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
@@ -113,6 +117,9 @@ pub struct DirectSelectionTool {
guides: Vec<Overlay>,
/// What the dragged anchors snap to, gathered when the drag begins.
anchor_snap: Option<PointSnap>,
/// 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>,
/// What the dragged handle snaps to.
handle_snap: HandleSnap,
/// What the objects being moved snap to, gathered when the move begins.
@@ -131,6 +138,7 @@ impl DirectSelectionTool {
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
copy_snap: None,
handle_snap: HandleSnap::default(),
move_snap: None,
hover: None,
@@ -319,7 +327,7 @@ impl Tool for DirectSelectionTool {
} else {
h.leaf
};
self.state = State::MoveObject { start: p, began: false };
self.state = State::MoveObject { start: p, grab: None, began: false };
if cx.selection.contains(target) {
vec![]
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
@@ -368,8 +376,21 @@ impl Tool for DirectSelectionTool {
if cx.doc.node(id).is_some_and(is_area_type) {
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
}
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
self.state = State::MoveAnchors { start: p, grab, began: false };
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
if ev.mods.alt {
self.state = State::MoveObject { start: p, grab: Some(grab), began: false };
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
// A path with all its anchors selected is stored whole (no partial entry).
let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
// the copy on and off during the drag as with the Selection tool.
self.state = if already && cx.selection.anchors.is_empty() {
State::MoveObject { start: p, grab: Some(grab), began: false }
} else {
State::MoveAnchors { start: p, grab, began: false }
};
if ev.mods.shift {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
}
@@ -409,7 +430,7 @@ impl Tool for DirectSelectionTool {
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
}
// Clicking the fill selects the whole leaf path (all anchors).
self.state = State::MoveObject { start: p, began: false };
self.state = State::MoveObject { start: p, grab: None, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
}
@@ -459,19 +480,31 @@ impl Tool for DirectSelectionTool {
));
out
}
(PointerKind::Drag, State::MoveObject { start, began }) => {
(PointerKind::Drag, State::MoveObject { start, grab, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveObject { start, began: true };
self.move_snap = Some(MoveSnap::new(cx));
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
self.state = State::MoveObject { start, grab, began: true };
if grab.is_some() {
// 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())));
} else {
self.move_snap = Some(MoveSnap::new(cx));
}
}
let mut d = move_delta(start, p, ev.mods.shift);
if let Some(snap) = &self.move_snap {
(d, self.guides) = snap.snap(cx, start, d);
self.guides.clear();
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
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);
} else if let Some(snap) = &self.move_snap {
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
}
out.push(Action::Preview(
"object.transform".into(),
@@ -554,7 +587,7 @@ impl Tool for DirectSelectionTool {
) => {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.move_snap) = (None, None);
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
@@ -838,6 +871,45 @@ mod tests {
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
}
/// Pressing an anchor of a path whose anchors are all selected (stored as the whole path)
/// keeps them all selected, so the drag moves them all; Shift still deselects that anchor.
#[test]
fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0));
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0));
let shift = Mods { shift: true, ..Default::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]);
}
/// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies
/// it as the Selection tool's Alt-drag does.
#[test]
fn alt_dragging_an_anchor_copies_the_whole_path() {
let (d, id) = doc_with_rect();
let alt = Mods { alt: true, ..Default::default() };
let mut whole = Selection::default();
whole.set([id]);
let p = paint();
for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] {
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt));
let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
assert_eq!(a, if pick { set } else { vec![] }, "{s:?}");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt));
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
}
}
#[test]
fn marquee_selects_anchors() {
let (d, id) = doc_with_rect();
@@ -1037,6 +1109,74 @@ mod tests {
(v.clone(), labels)
}
/// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does:
/// onto another path's anchor, or onto an anchor of the original the copy leaves behind.
#[test]
fn alt_dragged_copies_snap_by_the_anchor_pressed() {
let (d, _, b) = two_squares();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let mut whole = Selection::default();
whole.set([b]);
let c = cx(&d, &whole, &p);
let mut t = DirectSelectionTool::new(false);
let copy =
|dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true});
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt);
assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()]));
// B's top-left corner onto the original B's top-right one: the copy sits beside it.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt);
assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()]));
// The history says what the drag did.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt));
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt));
assert_eq!(out.first(), Some(&Action::Begin("Copy".into())));
}
/// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released
/// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed
/// snaps all along, onto the original only while it is left behind.
#[test]
fn alt_during_a_drag_by_an_anchor_toggles_the_copy() {
let (d, _, b) = two_squares();
let p = paint();
let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() });
let mut whole = Selection::default();
whole.set([b]);
let c = cx(&d, &whole, &p);
let mut t = DirectSelectionTool::new(false);
let preview = |dx: f64, dy: f64, copy: bool| {
Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}),
)
};
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty());
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)),
vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)]
);
// The tool sees the document as previewed: B moved along, not where the copy leaves it.
let (mut moved, _, b2) = two_squares();
let l = moved.layers[0].id;
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0));
moved.remove(b2).unwrap();
moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
let c = cx(&moved, &whole, &p);
// Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]);
// Alt up: a move again, which leaves nothing behind to snap to.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]);
// With only some anchors selected, the drag still moves just those.
let one = anchors_of(b, &[(0, 0)]);
let c = cx(&d, &one, &p);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt));
assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}");
}
/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors
/// of its own path, and with several selected the one pressed on snaps, the others following.
#[test]
+8 -7
View File
@@ -179,10 +179,11 @@ impl Targets {
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
}
/// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with
/// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
pub fn for_move(cx: &ToolContext) -> Self {
let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx);
/// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection,
/// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides ›
/// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self {
let mut t = Self::collect(cx.doc, exclude, None).styled(cx);
if cx.spacing_guides {
t.boxes = object_boxes(cx.doc, &cx.selection.objects);
}
@@ -1229,7 +1230,7 @@ mod tests {
}
let (s, p) = (Selection::default(), paint());
let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count();
let t = Targets::for_move(&cx(&d, &s, &p));
let t = Targets::for_move(&cx(&d, &s, &p), &s.objects);
// 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each).
let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
assert_eq!(dv, Vec2::new(-1.0, 0.0));
@@ -1241,7 +1242,7 @@ mod tests {
let id = wide.alloc_id();
wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default()))
.unwrap();
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
assert_eq!(dv, Vec2::new(2.0, 0.0));
assert!(lines(&ov) >= 6, "{ov:?}");
// A column: 28 under D, as C and D are 30 apart.
@@ -1253,7 +1254,7 @@ mod tests {
assert_eq!(dv, Vec2::new(0.0, 2.0));
// Off: nowhere to go, and no marks.
let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) };
let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0));
}
+51 -8
View File
@@ -79,36 +79,47 @@ pub struct SelectionTool {
/// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart
/// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the
/// point it was grabbed by on an anchor or a ruler guide. Snap to Pixel puts its top-left on whole
/// pixels.
/// pixels. A copy (Alt, which can go down or up at any time during the drag) leaves the original
/// behind, so it snaps to the original too.
pub(crate) struct MoveSnap {
bounds: Option<Rect>,
targets: Option<Targets>,
points: Option<Targets>,
/// The targets and points for a copy, gathered with the others before the previews move the
/// original.
copy_targets: Option<Targets>,
copy_points: Option<Targets>,
}
impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
let guides = cx.smart_guides && !cx.snap_to_grid;
let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) };
Self {
bounds: selection_bounds(cx),
targets: (cx.smart_guides && !cx.snap_to_grid).then(|| Targets::for_move(cx)),
points: if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, &cx.selection.objects) },
targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)),
points: points(&cx.selection.objects),
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
copy_points: points(&[]),
}
}
/// The move by `d` of the selection grabbed at `start`, snapped, and its guides.
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec<Overlay>) {
/// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its
/// guides.
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec<Overlay>) {
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 += grid_pull(b + d, cx.grid_step());
}
if let Some(t) = &self.points {
if let Some(t) = points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.bounds) {
if let (Some(t), Some(b)) = (targets, self.bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.snap_tol());
d += adj;
guides = ov;
@@ -331,7 +342,7 @@ impl Tool for SelectionTool {
}
let mut d = move_delta(start, p, m.shift);
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d);
(d, self.guides) = snap.snap(cx, start, d, m.alt);
}
self.state = State::Moving { start, began: true, deselect: None, key: None };
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
@@ -622,6 +633,38 @@ mod tests {
assert_eq!(a, vec![Action::Commit]);
}
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
/// gathered before the previews moved it); released, the move doesn't.
#[test]
fn an_alt_copy_snaps_to_the_original_left_behind() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, id) = doc_with_rect();
// An artboard whose edges and centre are out of reach.
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let mut s = Selection::default();
s.add(id);
let p = paint();
let mut t = SelectionTool::default();
let dx = |a: &[Action]| {
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
v["matrix"][4].as_f64().unwrap()
};
t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0));
assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0);
// The previews moved the rectangle: the tool sees it 50 pt along.
let mut moved = d.clone();
let l = moved.layers[0].id;
moved.remove(id).unwrap();
let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0));
moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap();
let c = cx(&moved, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
// The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it.
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0);
// Without Alt nothing stays behind there.
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
}
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
/// whichever of its edges or its centre is nearest a gridline, on each axis.
#[test]
+3 -1
View File
@@ -2508,8 +2508,10 @@ mod tests {
used
};
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only.
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once
// the rectangle isn't selected whole, which would move all its anchors).
app.select_tool("directSelection");
app.session.execute("select.none", &json!({})).unwrap();
let corner = xf.to_screen(Point::new(200.0, 150.0));
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
let b = bounds(&app);
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist: pen, anchors, handles, snapping, nudging, modifiers, panels) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Direct Selection: whole-path drags by an anchor and Alt-drag copies, #1104; earlier: first checklist) · **Target:** Adobe Illustrator 2026 (30.x)
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
@@ -63,6 +63,7 @@ tools, never checked side by side) isn't captured row by row.
|---|---|---|
| Click/Shift-click anchors; marquee toggles with Shift | done | #483 |
| Drag a curved segment to bend it, a straight one to move its anchors | done | |
| Drag by an anchor of a path selected whole moves the whole path; Alt-drag by an anchor or a segment copies it, Alt toggles the copy during the drag, and the copy snaps by the anchor pressed, onto the original too | done | #1104 |
| Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 |
| Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides |
| Show handles when multiple anchors are selected; Highlight anchors on mouse over; anchor and handle size | done | Preferences › Selection & Anchor Display |
@@ -166,4 +167,5 @@ re-measure of the panels added since.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) |
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |