diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 5e32e4a19..93c68c12b 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -3,8 +3,9 @@ //! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the //! segment independently of its endpoint anchors, 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; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a -//! direction handle to +//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it; an Alt-click +//! on a path selected whole adds its group, and each further Alt-click adds the next group up, as +//! Group Selection's clicks do), 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 @@ -178,6 +179,9 @@ pub struct DirectSelectionTool { click: Option<(NodeId, usize, usize)>, /// A click on a whole path picks the segment on release; a drag still moves the whole path. click_segment: Option<(NodeId, usize, usize)>, + /// An Alt-click on a path selected whole adds this enclosing group on release, as a Group + /// Selection click does; an Alt-drag still copies the selection. + click_group: Option, } impl DirectSelectionTool { @@ -199,6 +203,7 @@ impl DirectSelectionTool { guide: GuideEdit::default(), click: None, click_segment: None, + click_group: None, } } @@ -444,6 +449,23 @@ struct SegmentHit { curved: bool, } +/// The group a Group Selection click on a selected object adds, given the object's `ancestry` +/// (its layer and sublayers first, the object last). It walks up from the object and returns the +/// first ancestor below the layer and its sublayers that is not yet selected. The walk stops at the +/// isolated object, since the next group up is outside isolation mode, and at any layer or +/// sublayer, including one inside a group. +fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option { + // Skip the layer and its sublayers: they are not objects. + let layers = ancestry.iter().take_while(|id| cx.doc.node(**id).is_some_and(Node::is_layer)).count().max(1); + ancestry + .iter() + .skip(layers) + .rev() + .copied() + .take_while(|id| in_scope(cx.doc, cx.isolation, *id) && !cx.doc.node(*id).is_some_and(Node::is_layer)) + .find(|id| !cx.selection.contains(*id)) +} + /// The segment of path `id` nearest to `p`, where the hit test found its stroke. fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { let pd = cx.doc.node(id)?.path_data()?; @@ -488,7 +510,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.click, self.click_segment) = (None, None); + (self.click, self.click_segment, self.click_group) = (None, None, None); vec![Action::Cancel] } fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { @@ -512,6 +534,7 @@ impl Tool for DirectSelectionTool { (PointerKind::Down, _) => { self.click = None; self.click_segment = None; + self.click_group = None; if let Some(c) = CornerDrag::hit(cx, ev, true) { self.state = State::Corner(c); return vec![]; @@ -882,11 +905,7 @@ impl DirectSelectionTool { h.ancestry.push(part); h.leaf = part; } - // Walk up from the leaf: select the first ancestor not yet selected (below the layer). - // Stop at the isolated object: the next group up is outside isolation mode. - let chain: Vec = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).collect(); - let target = - if cx.selection.contains(h.leaf) { chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf) } else { h.leaf }; + let target = if cx.selection.contains(h.leaf) { next_group(cx, &h.ancestry).unwrap_or(h.leaf) } else { h.leaf }; let mut deselect = None; let mut out = vec![]; if ev.mods.shift { @@ -933,10 +952,14 @@ impl DirectSelectionTool { if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) { return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); } - // Alt picks the whole path (and Alt-drag copies it), as on a segment. + // Alt picks the whole path (and Alt-drag copies it), as on a segment. On a path + // already selected whole, an Alt-click adds the next enclosing group on release. if ev.mods.alt { self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }; let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none(); + if whole && !ev.mods.shift { + self.click_group = cx.doc.ancestry(id).and_then(|a| next_group(cx, &a)); + } 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). @@ -993,8 +1016,13 @@ impl DirectSelectionTool { return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))]; } } - // Clicking the fill selects the whole leaf path (all anchors). + // Clicking the fill selects the whole leaf path (all anchors). With Alt, a click + // on a path already selected whole adds the next enclosing group on release, + // as Group Selection's click does; each further Alt-click climbs one level. self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; + if ev.mods.alt && whole && !ev.mods.shift { + self.click_group = next_group(cx, &h.ancestry); + } if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; } @@ -1099,6 +1127,7 @@ impl DirectSelectionTool { self.state = State::Idle; self.guides.clear(); (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); + let group = self.click_group.take(); match (began, deselect, self.click.take(), self.click_segment.take()) { (true, ..) => vec![Action::Commit], (false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))], @@ -1108,7 +1137,7 @@ impl DirectSelectionTool { (false, None, None, Some((id, si, seg))) => { vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))] } - (false, None, None, None) => vec![], + (false, None, None, None) => group.map(|g| Action::Exec("select.add".into(), json!({"ids": [g.0]}))).into_iter().collect(), } } fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed): (Point, bool, bool)) -> Vec { diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 08e8c669d..673779722 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -577,6 +577,124 @@ fn a_double_click_outside_isolation_leaves_it() { assert!(DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::DoubleClick, 700.0, 700.0)).is_empty()); } +/// With Direct Selection, an Alt-click on a path selected whole adds its group on release, and +/// each further Alt-click adds the next group up, as Group Selection's clicks do, on an anchor, +/// a segment and the fill alike. The climb stops below the layer and at the isolated group. +#[test] +fn alt_clicks_on_a_path_selected_whole_add_its_groups() { + let (d, inside, _, inner, outer) = nested_group_doc(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]; + let click = |cx: &ToolContext, x: f64, y: f64| { + let mut t = DirectSelectionTool::new(false); + let down = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt)); + assert!(down.is_empty(), "({x}, {y}): {down:?}"); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt)) + }; + for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] { + let mut s = Selection::default(); + s.set([inside]); + assert_eq!(click(&cx(&d, &s, &p), x, y), add(inner), "({x}, {y}): the path's group"); + s.set([inside, inner]); + assert_eq!(click(&cx(&d, &s, &p), x, y), add(outer), "({x}, {y}): the group around it"); + s.set([inside, inner, outer]); + assert!(click(&cx(&d, &s, &p), x, y).is_empty(), "({x}, {y}): the layer is not added"); + s.set([inside, inner]); + let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) }; + assert!(click(&isolated, x, y).is_empty(), "({x}, {y}): not the group outside isolation"); + } +} + +/// In a sublayer, a Group Selection click and a Direct Selection Alt-click climb to the group +/// and stop there: the sublayer is not added. +#[test] +fn group_clicks_in_a_sublayer_stop_below_the_sublayer() { + use vectorcraft_doc::{Appearance, Document, LayerColor, Node}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art())) + .unwrap(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let click = |s: &Selection, group: bool, mods: Mods| { + let c = cx(&d, s, &p); + let mut t = DirectSelectionTool::new(group); + let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods)); + let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods)); + [down, up].concat() + }; + let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]; + let mut s = Selection::default(); + s.set([a]); + assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group"); + assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group"); + s.set([a, g]); + assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer"); + assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer"); +} + +/// When a group holds a sublayer, the climb stops at the sublayer: it adds neither the sublayer +/// nor the group above it. +#[test] +fn group_clicks_stop_at_a_sublayer_inside_a_group() { + use vectorcraft_doc::{Appearance, Document, LayerColor, Node}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::group(outer, vec![])).unwrap(); + d.insert(Some(outer), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art())) + .unwrap(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let click = |s: &Selection, group: bool, mods: Mods| { + let c = cx(&d, s, &p); + let mut t = DirectSelectionTool::new(group); + let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods)); + let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods)); + [down, up].concat() + }; + let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]; + let mut s = Selection::default(); + s.set([a]); + assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group"); + assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group"); + s.set([a, g]); + assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer"); + assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer"); +} + +/// An Alt-drag from a path selected whole inside a group copies the path and adds no group, and +/// Alt+Shift-click on a segment or the fill still toggles the path, adding no group either. +#[test] +fn alt_drags_and_alt_shift_clicks_on_a_path_in_a_group_add_no_group() { + let (d, inside, ..) = nested_group_doc(); + let p = paint(); + let mut s = Selection::default(); + s.set([inside]); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] { + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y).with_mods(alt)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, y).with_mods(alt)), vec![Action::Commit]); + } + let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; + for (x, y) in [(150.0, 100.0), (150.0, 150.0)] { + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt_shift)); + assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": inside.0}))], "({x}, {y})"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt_shift)).is_empty(), "({x}, {y})"); + } +} + /// #927: a marquee across type selects the type object whole, beside the anchors it holds. #[test] fn marquee_selects_type_it_touches() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7ad85bb8c..182086b2a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -681,6 +681,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. `path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged. - **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path. + With Direct Selection, an Alt-click on a path selected whole adds its group, and each further Alt-click adds the next group up, as Group Selection's clicks do; the climb stops below the layer and its sublayers and at the isolated object. An Alt-drag still copies the selection, and Alt+Shift-click on a segment or the fill still adds or removes the path. Public references: [Select objects](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copies). With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. With the Anchor Point tool, Shift held at the press on an anchor (or a selected blend's spine point) moves it, handles and all, instead of pulling out handles; Shift kept down keeps the move at 45° steps, and a Shift-click that doesn't move leaves the anchor as it was (`path.moveAnchor`). A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`). - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512). View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.