Selection: a press where a mask hides a selected object's art grabs it

An image in a clipping mask, picked in the Layers panel, shows its
whole frame, but a press on the part the mask hides hit nothing: the
Selection, Direct Selection and Group Selection tools started a marquee
there, which dropped the selection, so the image could be dragged only
by the part that shows.

Such a press now grabs the selection by that art, as a press on its
visible part does: a drag moves the selected object (the image, not its
clip group) and a click keeps it selected. With nothing of it selected
there, or with Shift, a press there starts a marquee as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjfPR4a1jPek7r3nyFFNYv
This commit is contained in:
nevarem
2026-10-29 18:46:47 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent a7db76bdfb
commit 4cd9afe67c
6 changed files with 170 additions and 3 deletions
+30
View File
@@ -110,6 +110,36 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
hit_test_skipping(doc, p, opt, &|_| false, false)
}
/// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask
/// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A
/// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that
/// art instead of starting a marquee: an image in a mask, picked in the Layers panel, can be
/// dragged by its hidden part, whose frame shows while it is selected. An object that a click
/// can't hit anyway doesn't count: hidden, locked, a template, outside the isolated container
/// (`opt.scope`) or on a locked artboard.
pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option<NodeId> {
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
selected.iter().rev().copied().find(|&id| {
let Some(chain) = doc.ancestry(id) else { return false };
let nodes: Vec<&Node> = chain.iter().filter_map(|a| doc.node(*a)).collect();
if nodes.len() != chain.len()
|| nodes.iter().any(|n| !n.visible || n.locked || n.is_template())
|| opt.scope.is_some_and(|s| !chain.contains(&s))
|| locks.holds_id(id)
{
return false;
}
// Hidden by a mask: one of the containers around it clips, and not to `p`.
let masked = nodes
.iter()
.rev()
.skip(1)
.any(|n| n.clips() && n.children().and_then(|c| c.first()).is_some_and(|clip| clip.id != id && !clip_contains(clip, p)));
masked && nodes.last().is_some_and(|n| hit_any(n, p, opt, &symbols).is_some())
})
}
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
+42
View File
@@ -77,6 +77,48 @@ fn half_clipped(d: &mut Document) -> (Node, Node) {
(mask, art)
}
/// The art a mask hides counts only for a selected object: there a click hits nothing, and the
/// selection tools grab the selection by that art. Not where the art shows (the click hits it),
/// beyond the art, for the clip group selected as a whole, nor for art a click can't hit (locked,
/// hidden, outside the isolated container). A layer's clipping mask hides art as a clip group's
/// does.
#[test]
fn a_selected_objects_art_hidden_by_a_mask_is_found_under_the_pointer() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, shown, beyond) = (Point::new(150.0, 50.0), Point::new(50.0, 50.0), Point::new(250.0, 50.0));
let opt = HitOptions::default();
assert!(hit_test(&d, hidden, opt).is_none());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
assert_eq!(selected_hidden_at(&d, hidden, opt, &[]), None);
assert_eq!(selected_hidden_at(&d, shown, opt, &[art_id]), None);
assert_eq!(selected_hidden_at(&d, beyond, opt, &[art_id]), None);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None);
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(g), ..opt }, &[art_id]), Some(art_id));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(art_id), ..opt }, &[art_id]), Some(art_id));
let other = d.add_layer(Some("Other"));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(other), ..opt }, &[art_id]), None);
for change in [|n: &mut Node| n.locked = true, |n: &mut Node| n.visible = false] {
let mut d = d.clone();
change(d.node_mut(art_id).unwrap());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), None);
}
// A layer's clipping mask.
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let art_id = art.id;
d.insert(Some(l), 0, mask).unwrap();
d.insert(Some(l), 1, art).unwrap();
d.node_mut(l).unwrap().set_clips(true);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
+18 -1
View File
@@ -37,7 +37,7 @@
use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test};
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test, selected_hidden_at};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
@@ -563,6 +563,9 @@ impl Tool for DirectSelectionTool {
if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) {
return self.press_object(cx, ev, p, tol, h);
}
if let Some(out) = self.press_hidden(cx, ev, p) {
return out;
}
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
vec![]
}
@@ -851,12 +854,26 @@ impl DirectSelectionTool {
mod tests;
impl DirectSelectionTool {
/// A press where a clipping mask hides the art of a selected object (picked in the Layers
/// panel, say) grabs the selection by it, as a press on its visible part does; `None`
/// elsewhere, and with Shift (a marquee then adds to the selection).
fn press_hidden(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Option<Vec<Action>> {
if ev.mods.shift {
return None;
}
selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects)?;
self.state = State::MoveObject { start: p, grab: None, hold: p, began: false, deselect: None };
Some(vec![])
}
fn press_group(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, tol: f64) -> Vec<Action> {
if let Some(out) = self.guide.press(cx, ev) {
self.state = State::Idle;
return out;
}
let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else {
if let Some(out) = self.press_hidden(cx, ev, p) {
return out;
}
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
return vec![];
};
+27
View File
@@ -2,6 +2,33 @@ use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
/// Direct Selection and Group Selection grab a selected clipped object (picked in the Layers
/// panel) where its mask hides it, as on its visible part: a drag moves it. With nothing of it
/// selected there, a press starts a marquee.
#[test]
fn a_press_where_a_mask_hides_a_selected_object_drags_it() {
let (d, _, _, art) = doc_with_masked_square();
let p = paint();
let mut picked = Selection::default();
picked.add(art);
let none = Selection::default();
// (370, 450): on the art, outside the mask (400..500).
for group in [false, true] {
let c = cx(&d, &picked, &p);
let mut t = DirectSelectionTool::new(group);
let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0));
assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c.starts_with("select."))), "{down:?} (group: {group})");
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0));
assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "{a:?} (group: {group})");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 390.0, 470.0));
let c = cx(&d, &none, &p);
let mut t = DirectSelectionTool::new(group);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 370.0, 450.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 470.0));
assert!(!a.iter().any(|a| matches!(a, Action::Begin(_))), "{a:?} (group: {group})");
}
}
/// #414: a ruler guide is picked over the art, but an anchor on it is picked first.
#[test]
fn direct_and_group_selection_pick_ruler_guides() {
+13 -2
View File
@@ -37,7 +37,7 @@ use std::cell::RefCell;
use std::collections::HashMap;
use serde_json::{Value, json};
use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at};
use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at, selected_hidden_at};
use vectorcraft_doc::{NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@@ -740,7 +740,7 @@ impl Tool for SelectionTool {
out
}
None => {
self.state = State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false };
self.press_nothing(cx, p, grab, m);
vec![]
}
}
@@ -1191,6 +1191,17 @@ impl SelectionTool {
}
impl SelectionTool {
/// A press at `p` that hits no object starts a marquee, except where a clipping mask hides
/// the art of a selected object (picked in the Layers panel, say): there, without Shift, it
/// grabs the selection by that art (at `grab`), as a press on its visible part does.
fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, m: Mods) {
self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() {
State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None }
} else {
State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false }
};
}
fn drag_moving(&mut self, cx: &ToolContext, p: Point, m: Mods, (start, press, hold, began): (Point, Point, Point, bool)) -> Vec<Action> {
let mut out = vec![];
if !began {
+40
View File
@@ -79,6 +79,46 @@ fn a_press_on_a_masks_edge_selects_its_clip_group() {
assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}");
}
/// The art of a selected clipped object (picked in the Layers panel), pressed where its mask
/// hides it, is grabbed: a drag moves the selection (the art, not its clip group) and a click
/// keeps it selected. With nothing of it selected, or with Shift, a press there starts a marquee.
#[test]
fn a_press_where_a_mask_hides_a_selected_object_drags_it() {
let (d, _, _, art) = doc_with_masked_square();
let p = paint();
let mut picked = Selection::default();
picked.add(art);
let shift = |kind, x, y| {
let mut e = ev(kind, x, y);
e.mods.shift = true;
e
};
let changes_selection = |a: &[Action]| a.iter().any(|a| matches!(a, Action::Exec(c, _) if c.starts_with("select.")));
// (370, 450): on the art, outside the mask (400..500).
let mut t = SelectionTool::default();
let c = cx(&d, &picked, &p);
assert!(t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0)).is_empty());
let a = t.pointer(&c, &ev(PointerKind::Drag, 390.0, 470.0));
assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "{a:?}");
assert!(a.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "{a:?}");
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 470.0)), vec![Action::Commit]);
// A click there keeps the selection.
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0));
assert!(!changes_selection(&t.pointer(&c, &ev(PointerKind::Up, 370.0, 450.0))));
// Nothing selected: a marquee, and a click deselects.
let none = Selection::default();
let c = cx(&d, &none, &p);
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 370.0, 450.0));
assert!(!t.pointer(&c, &ev(PointerKind::Drag, 390.0, 470.0)).iter().any(|a| matches!(a, Action::Begin(_))));
// Shift: a marquee that adds to the selection.
let c = cx(&d, &picked, &p);
let mut t = SelectionTool::default();
t.pointer(&c, &shift(PointerKind::Down, 370.0, 450.0));
assert!(!t.pointer(&c, &shift(PointerKind::Drag, 390.0, 470.0)).iter().any(|a| matches!(a, Action::Begin(_))));
}
/// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands
/// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it.
#[test]