Merge pull request #4443 from dotCooCoo/fix/direct-fill-drag-keeps-partial-paths

Direct Selection: a drag from a fill moves only the selected anchors of partly selected paths
This commit is contained in:
BFlat
2026-10-29 15:40:52 +07:00
committed by GitHub
9 changed files with 276 additions and 8 deletions
+11 -2
View File
@@ -222,18 +222,27 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let whole = s.doc()?.selection.whole();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() };
transform_anchors(s, "Move", vectorcraft_geom::Affine::translate(d), sc)?;
// Transform Again repeats a move that moved something; a drag records it when it commits.
let st = s.doc_mut()?;
if st.interaction.is_none() && (d.x != 0.0 || d.y != 0.0) {
let options = json!({ "copy": false, "patterns": sc.patterns });
st.last_transform = Some(crate::LastTransform::Matrix(vectorcraft_geom::Affine::translate(d), options));
st.last_perspective = None;
}
ok()
}
/// Apply `xf` to the direct-selected anchors, their handles with them, and (with `sc`) to the
/// objects selected whole (those with no entry in the selection's `anchors`).
/// objects selected whole (those with no entry in the selection's `anchors`). An object inside
/// another selected object, such as a path in a selected group, moves once, with that object.
pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_geom::Affine, sc: Scaling) -> Result<()> {
let sel = s.doc()?.selection.clone();
let ids = super::edit::outermost(&s.doc()?.doc, sel.objects.clone());
let c = xf.as_coeffs();
let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9;
let delta = vectorcraft_geom::Vec2::new(c[4], c[5]);
s.edit(label, |doc, _| {
for id in &sel.objects {
for id in &ids {
match sel.anchors.get(id) {
Some(set) => {
let path = path_mut(doc, *id)?;
+15
View File
@@ -251,6 +251,11 @@ impl Session {
// A preview that left the document as it was records no Transform Again either (as
// `keep_interaction` records no undo step): the last real transformation stands.
let changed = !Arc::ptr_eq(&it.doc, &st.doc);
// How far a drag of direct-selected anchors moved them (`path.moveAnchors`).
let moved = vectorcraft_geom::Vec2::new(
preview.1.get("dx").and_then(Value::as_f64).unwrap_or(0.0),
preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0),
);
st.keep_interaction(it);
if preview.0 == "object.transform" {
let m = cmd::matrix_param(&preview.1, "matrix");
@@ -265,6 +270,16 @@ impl Session {
st.last_transform = Some(LastTransform::Matrix(m, options));
st.last_perspective = None;
}
} else if preview.0 == "path.moveAnchors" && (moved.x != 0.0 || moved.y != 0.0) {
// A drag of direct-selected anchors (from an anchor, or from a fill with anchors
// selected): Transform Again moves the selected anchors and the objects selected whole
// by the same distance. A drag that ends where it began leaves the last transform.
let mut options = serde_json::json!({ "copy": false });
if let Some(patterns) = preview.1.get("patterns") {
options["patterns"] = patterns.clone();
}
st.last_transform = Some(LastTransform::Matrix(vectorcraft_geom::Affine::translate(moved), options));
st.last_perspective = None;
} else if preview.0 == "object.transformEach" && changed {
// Transform Again repeats it as it stood when OK was pressed.
st.last_transform = Some(LastTransform::Each(preview.1.clone()));
+108
View File
@@ -998,6 +998,114 @@ fn whole_objects_moved_with_selected_anchors_follow_transform_pattern_tiles() {
}
}
/// A Direct Selection or Group Selection drag that starts on the fill of a path selected whole,
/// while another path has an anchor selected, moves the path whole and only that anchor of the
/// other path, in one undo step, as a drag of the anchor does. With Transform Pattern Tiles on,
/// the tiles of the path selected whole move with it. Alt pressed during the drag copies the
/// selected objects instead.
#[test]
fn a_drag_from_a_fill_moves_only_the_selected_anchors_of_partly_selected_paths() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let filled = patterned_square(&mut s);
let other = square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
set_pref(&mut s, "transformPatternTiles", json!(true));
let xs = |s: &Session| -> Vec<f64> {
s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p.x).collect()
};
let before = xs(&s);
for tool in ["directSelection", "groupSelection"] {
s.select_tool(tool, v).unwrap();
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]);
assert_eq!(top_left(&s, filled), (110.0, 100.0), "{tool}: the square moves whole");
let mut moved = before.clone();
moved[0] += 10.0;
assert_eq!(xs(&s), moved, "{tool}: the selected anchor moves alone");
assert_eq!(s.journal.last().unwrap().0, "path.moveAnchors", "{tool}");
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Move"), "{tool}");
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "{tool}: the tiles move with the square");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((top_left(&s, filled), xs(&s)), ((100.0, 100.0), before.clone()), "{tool}: one undo step");
// Alt pressed during the drag copies the selected objects and leaves the originals.
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
let objects = count(&s);
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
let alt = vectorcraft_tools::Mods { alt: true, ..vectorcraft_tools::Mods::default() };
s.pointer(&PointerEvent::new(Down, 125.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(Drag, 130.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(Drag, 135.0, 125.0).with_mods(alt), v).unwrap();
s.pointer(&PointerEvent::new(Up, 135.0, 125.0).with_mods(alt), v).unwrap();
assert_eq!((top_left(&s, filled), xs(&s), count(&s)), ((100.0, 100.0), before.clone(), objects + 2), "{tool}: copied");
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy"), "{tool}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(count(&s), objects, "{tool}");
}
}
/// A path selected whole inside a selected group moves once, with the group, when the selection
/// also holds a direct-selected anchor: a Group Selection drag from the path's fill (which adds
/// the group) and an arrow key move both by 10 pt.
#[test]
fn a_path_in_a_selected_group_moves_once_with_selected_anchors() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let (a, b) = (square(&mut s, 100.0, 100.0), square(&mut s, 160.0, 100.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let other = square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
let first_x = |s: &Session| s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors[0].p.x;
s.select_tool("groupSelection", v).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]);
assert!(s.doc().unwrap().selection.contains(g), "the press added the group");
assert_eq!((top_left(&s, a), top_left(&s, b), first_x(&s)), ((110.0, 100.0), (170.0, 100.0), 310.0), "the drag");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [g.0, a.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "big": true})).unwrap();
assert_eq!((top_left(&s, a), top_left(&s, b), first_x(&s)), ((110.0, 100.0), (170.0, 100.0), 310.0), "the arrow key");
}
/// Transform Again after a Direct Selection drag from a fill with an anchor of another path
/// selected moves the path selected whole and only that anchor again, by the same distance. A
/// drag back to where it began leaves that move to repeat, and `path.moveAnchors` run as a
/// command is repeated too.
#[test]
fn transform_again_repeats_a_drag_from_a_fill_with_anchors_selected() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let filled = square(&mut s, 100.0, 100.0);
let other = square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
let xs = |s: &Session| -> Vec<f64> {
s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p.x).collect()
};
let mut moved = xs(&s);
s.select_tool("directSelection", v).unwrap();
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]);
s.execute("object.transformAgain", &json!({})).unwrap();
moved[0] += 20.0;
assert_eq!((top_left(&s, filled), xs(&s)), ((120.0, 100.0), moved.clone()));
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Transform Again"));
// A drag that ends where it began leaves the move to repeat.
gesture(&mut s, v, &[(Down, 145.0, 125.0), (Drag, 155.0, 125.0), (Drag, 145.0, 125.0), (Up, 145.0, 125.0)]);
s.execute("object.transformAgain", &json!({})).unwrap();
moved[0] += 10.0;
assert_eq!((top_left(&s, filled), xs(&s)), ((130.0, 100.0), moved), "after a drag back to its start");
// The command, as an action plays it back, is repeated the same way.
s.execute("path.moveAnchors", &json!({"dx": 0, "dy": 5})).unwrap();
s.execute("object.transformAgain", &json!({})).unwrap();
assert_eq!(top_left(&s, filled), (130.0, 110.0), "path.moveAnchors");
}
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
/// swatch; on, only the same tint.
#[test]
+38 -2
View File
@@ -15,7 +15,10 @@
//! objects entirely inside it (members of groups too), whole.
//! In isolation mode both pick only the isolated object and what is inside it: anchors, segments,
//! paths and groups outside it stay put.
//! Both move objects selected as a whole as the Selection tool does, snapping alike.
//! Both move objects selected as a whole as the Selection tool does, snapping alike. When the
//! selection also holds direct-selected anchors or segments, a drag from an object moves those
//! anchors and the objects selected whole, as an anchor drag does, and while Alt is held it
//! copies the selected objects.
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
//! A Cmd/Ctrl+Shift double-click with either one releases the ruler guide or guide path under the
//! pointer ([`release_guide_at`]).
@@ -158,6 +161,13 @@ pub struct DirectSelectionTool {
handle_snap: HandleSnap,
/// What the objects being moved snap to, gathered when the move begins.
move_snap: Option<MoveSnap>,
/// What the point holding a move of objects snaps to while the move takes direct-selected
/// anchors along (`path.moveAnchors`): each move of objects sets it when it begins, to the
/// targets of an anchor drag when anchors are selected, else to `None`.
partial_snap: Option<PointSnap>,
/// With Snap to Grid or Snap to Pixel on, what such a move from a fill snaps instead of the
/// point it holds: the box around the selected anchors and the objects selected whole.
partial_grid: Option<MoveSnap>,
/// The drag snapped to a point or a guide at its last step: the arrow turns hollow.
snapped: bool,
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
@@ -182,6 +192,8 @@ impl DirectSelectionTool {
copy_snap: None,
handle_snap: HandleSnap::default(),
move_snap: None,
partial_snap: None,
partial_grid: None,
snapped: false,
hover: None,
guide: GuideEdit::default(),
@@ -1005,7 +1017,16 @@ impl DirectSelectionTool {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
// With direct-selected anchors or segments in the selection, the drag moves
// them and the objects selected whole (`path.moveAnchors`), as an anchor drag
// does, while Alt is up; the other anchors of a partly selected path stay in
// place. The history names the move or the copy at the end.
self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel);
self.partial_grid =
grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds)));
let copy = ev.mods.alt && self.partial_snap.is_none();
out.push(Action::Begin(if copy { "Copy".into() } else { "Move".into() }));
self.state = State::MoveObject { start, grab, hold, began: true, deselect: None };
if grab.is_some() {
// Alt can go down or up at any time: gather both while nothing has moved.
@@ -1018,6 +1039,21 @@ impl DirectSelectionTool {
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
let unsnapped = d;
self.guides.clear();
if !ev.mods.alt
&& let Some(snap) = &self.partial_snap
{
let held = grab.unwrap_or(hold);
if let Some(grid) = &self.partial_grid {
(d, self.guides) = if ev.mods.shift { grid.snap_along(cx, held, d, false) } else { grid.snap(cx, held, d, false) };
} else {
let shift = ev.mods.shift.then(|| Leave::segment(cx, held, true));
let (q, guides) = snap.snap_from(cx, held + (p - start), shift.as_ref());
(d, self.guides) = (q - held, guides);
}
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
return out;
}
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));
+86
View File
@@ -998,6 +998,92 @@ fn alt_during_a_drag_by_an_anchor_toggles_the_copy() {
assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}");
}
/// A drag from the fill of a path selected whole, while another path has some anchors selected,
/// moves the selected anchors and the paths selected whole (`path.moveAnchors`), as a drag of
/// an anchor does, with Direct Selection and Group Selection alike. Alt, pressed or released at
/// any time during the drag, turns the copy of the selected objects on and off, and a selection
/// of whole paths only still moves as objects.
#[test]
fn a_drag_from_a_fill_with_anchors_selected_moves_the_anchors() {
let (d, a, b) = two_squares();
let p = paint();
let mut mixed = anchors_of(b, &[(0, 0)]);
mixed.objects.insert(0, a);
let mut whole = Selection::default();
whole.set([a, b]);
let alt = Mods { alt: true, ..Default::default() };
for group in [false, true] {
let c = cx(&d, &mixed, &p);
let mut t = DirectSelectionTool::new(group);
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)).is_empty(), "group: {group}");
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0));
assert_eq!(
out,
vec![Action::Begin("Move".into()), Action::Preview("path.moveAnchors".into(), json!({"dx": 21.0, "dy": 3.0}))],
"group: {group}"
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 171.0, 153.0)), vec![Action::Commit]);
// Alt held at the start, released and pressed again: a copy, a move, a copy.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(alt));
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0).with_mods(alt));
assert!(matches!(out.last(), Some(Action::Preview(c, v)) if c == "object.transform" && v["copy"] == true), "group: {group} {out:?}");
assert_eq!(out.first(), Some(&Action::Begin("Move".into())), "the history names it by the last preview");
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 172.0, 153.0));
assert_eq!(out, vec![Action::Preview("path.moveAnchors".into(), json!({"dx": 22.0, "dy": 3.0}))], "group: {group}");
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 173.0, 153.0).with_mods(alt));
assert!(matches!(out.as_slice(), [Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "group: {group} {out:?}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 173.0, 153.0).with_mods(alt));
let c = cx(&d, &whole, &p);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0));
assert!(matches!(out.last(), Some(Action::Preview(c, _)) if c == "object.transform"), "group: {group} {out:?}");
}
}
/// A drag from the fill of A, selected whole, while B's top-left anchor is selected snaps the
/// point it holds as a dragged anchor does: near an anchor of B that stays put, it lands on it.
/// With Snap to Grid on, the box around A and B's selected anchor, (100, 100)–(300, 300), lands an
/// edge on the 9 pt grid instead: moved by (21, 3), its right side and bottom (321, 303) are
/// nearest the gridlines 324 and 306, and with Shift the level move by 13 lands the right side on
/// 315.
#[test]
fn a_drag_from_a_fill_with_anchors_selected_snaps() {
let (d, a, b) = two_squares();
let p = paint();
let mut mixed = anchors_of(b, &[(0, 0)]);
mixed.objects.insert(0, a);
let none = Mods::default();
for group in [false, true] {
let mut t = DirectSelectionTool::new(group);
let c = cx(&d, &mixed, &p);
let (v, labels) = drag(&mut t, &c, (150.0, 150.0), (298.0, 401.0), none);
assert_eq!((v, labels), (json!({"dx": 150.0, "dy": 250.0}), vec!["anchor".to_string()]), "group: {group}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 298.0, 401.0));
let c = ToolContext { snap_to_grid: true, ..cx(&d, &mixed, &p) };
assert_eq!(drag(&mut t, &c, (150.0, 150.0), (171.0, 153.0), none).0, json!({"dx": 24.0, "dy": 6.0}), "group: {group}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 171.0, 153.0));
let (v, _) = drag(&mut t, &c, (150.0, 150.0), (163.0, 151.0), Mods { shift: true, ..none });
let (x, y) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap());
assert!((x - 15.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 163.0, 151.0));
}
}
/// A path inside a selected group moves whole with the group, even with some of its anchors
/// direct-selected, so a drag from a fill does not snap to its other anchors: here to the
/// bottom-right corner (400, 400) of the path at (300, 300)–(400, 400).
#[test]
fn a_drag_from_a_fill_skips_the_anchors_of_a_path_in_a_selected_group() {
let (d, _, outside, _, outer) = nested_group_doc();
let p = paint();
let mut sel = anchors_of(outside, &[(0, 0)]);
sel.objects.insert(0, outer);
let c = cx(&d, &sel, &p);
let mut t = DirectSelectionTool::new(true);
let (v, labels) = drag(&mut t, &c, (150.0, 150.0), (398.0, 401.0), Mods::default());
assert_eq!((v, labels), (json!({"dx": 248.0, "dy": 251.0}), Vec::<String>::new()));
}
/// 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]
+5 -1
View File
@@ -372,7 +372,11 @@ impl Targets {
// With several anchors moving, the one under the pointer is not known here, and the
// others' extensions don't run through it.
let single = sel.objects.len() <= 1 && sel.anchors.values().map(|s| s.len()).sum::<usize>() == 1;
for (id, set) in &sel.anchors {
// A path inside another selected object, such as a path in a selected group, moves whole
// with that object, so none of its anchors or segments stay put.
let selected: HashSet<NodeId> = sel.objects.iter().copied().collect();
let inside_selected = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().rev().skip(1).any(|p| selected.contains(p)));
for (id, set) in sel.anchors.iter().filter(|(id, _)| !inside_selected(**id)) {
let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, sp) in path.subpaths.iter().enumerate() {
let moving = |ai: usize| set.contains(&(si, ai));
+6
View File
@@ -197,6 +197,12 @@ impl MoveSnap {
}
}
/// Snap to Grid and Snap to Pixel alone, for a move whose box is `bounds`: the grid lands an
/// edge of the box on a gridline, and pixels put its top-left on whole pixels.
pub(crate) fn grid_only(bounds: Option<Rect>) -> Self {
Self { bounds, targets: None, points: None, copy_targets: None, copy_points: None, rulers: None }
}
/// 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>) {
+6 -3
View File
@@ -2683,8 +2683,10 @@ changes size, and type keeps its character strokes' weight. With Scale Corners o
size. The journal entry of a scaling command records the `strokes` and `corners` it used, so actions replay alike.
`object.transformAgain` applies the last transform's matrix with the `copy`, `strokes`, `corners`, `patterns` and
`typeAreas` values that transform used, and a true or false value given in its own params takes precedence. After a
drag of art without a pattern, `patterns` follows the preference `transformPatternTiles`. Its journal entry records
the `strokes`, `corners` and `patterns` it used, as a transform's does.
drag of art without a pattern, `patterns` follows the preference `transformPatternTiles`. After a Direct Selection or
Group Selection drag that moved direct-selected anchors (`path.moveAnchors`), it moves the selected anchors and the
objects selected whole by the same distance. Its journal entry records the `strokes`, `corners` and `patterns` it
used, as a transform's does.
```json
{"name":"run_command","arguments":{"command":"object.scale","params":{"sx":200,"strokes":true}}}
@@ -2906,7 +2908,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
Transform Patterns) and of the same param on the commands that move or duplicate art with an artboard (`artboard.move`
and the Artboard tool, `artboard.duplicate`, `artboard.rearrange`, `artboard.setProps`): pattern fills and strokes
move with the art. `object.nudge` (the arrow keys) and `path.moveAnchors` (a Direct Selection drag of selected
anchors) take the same param, with the same default, for the objects they move whole.
anchors, and a Direct Selection or Group Selection drag from an object while anchors are selected) take the same
param, with the same default, for the objects they move whole.
- `selectSameTintPercent` (off by default): `select.same.fillColor`, `strokeColor` and `fillAndStroke` take every tint
of a global or spot swatch; on, only the same tint.
+1
View File
@@ -670,6 +670,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). The link that keeps Transform Each's two scales equal is not built.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663).
- **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character.
With Direct Selection and Group Selection, a drag that starts on an object while the selection also holds direct-selected anchors or segments moves those anchors and the objects selected whole in one undo step (`path.moveAnchors`), as a drag of a selected anchor does, and the other anchors of a partly selected path stay in place. While Alt is held during the drag, it copies the selected objects instead (`object.transform`). When direct-selected anchors are moved or transformed together with objects selected whole (such a drag, an anchor drag, an arrow key, or Move, Rotate, Scale, Reflect and Shear without a copy), a path inside a selected group is transformed once, with the group.
A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included).
With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies).
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0.