Browse / github.com/storytold/vectorcraft / patch
Fix #3748: in isolation mode, art outside a clip group's mask can be selected and dragged
[Bug Report?] Cannot drag objects outside a Clip Group’s clipping area Objects inside a Clip Group become undraggable when positioned outside the clipping area. **Steps to reproduce:** 1. Create a Clip Group containing an object. 2. Position the object outside the clipping area. 3. Try to select and drag it. **Expected:** The object can still be selected and moved while editing the Clip Group. **Actual:** The object cannot be dragged when outside the clipping area. **Attachments:** - - Actual behavior. <img width="800" height="627" alt="Image" src="https://github.com/user-attachments/assets/50fe08f5-78f9-4b39-b4fd-0afc377607da" /> - Expected behavior. <img w
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #3825, open)
- Base
50d976e1e6b92ab3633d6c8cc174268e5e6ffef9- Size
- +92 −13 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
21c8965acb274289289a9fb3
How it went
No other operator has reported using it yet.
Diff
raw · crates/doc/src/hit.rs crates/tools/src/direct.rs crates/tools/src/select.rs docs/target-app-parity.md
@@ -246,7 +246,7 @@ pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeI /// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside. fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) { let Some(children) = parent.children() else { return };- if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {+ if parent.clips() && !edits_inside(parent, opt.scope) && children.first().is_some_and(|clip| !clip_contains(clip, p)) { return; } for i in hit_order(parent, children, p) {@@ -420,6 +420,15 @@ fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbo clip_outline(clip, p, opt) } +/// Does isolation mode (`scope`) edit what `parent` holds: is `parent` the isolated container, or+/// the parent of the single object isolated? Then the selection tools hit the art that the+/// clipping mask of `parent` hides, and the Unlock submenu lists it (#3748). The reference+/// app's help says that the parts outside a clipping mask can still be edited+/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>).+fn edits_inside(parent: &Node, scope: Option<NodeId>) -> bool {+ scope.is_some_and(|s| s == parent.id || parent.children().is_some_and(|c| c.iter().any(|c| c.id == s)))+}+ fn hit_children( parent: &Node, p: Point,@@ -437,8 +446,10 @@ fn hit_children( chain.push(clip.id); return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }); }- // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.- if !clip_contains(clip, p) {+ // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it, except for+ // the selection tools in isolation mode within it.+ let edited = opt.clip_outlines && editable_only && edits_inside(parent, opt.scope);+ if !edited && !clip_contains(clip, p) { return None; } }@@ -607,21 +618,22 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> { /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured /// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then-/// only within that region's bounds.+/// only within that region's bounds. In isolation mode the clipping mask of the isolated container,+/// or of the parent of the single object isolated, does not limit the marquee, as it does not limit+/// the selection tools' clicks. pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> { let mut out = Vec::new(); // `tops` pairs each object with the part of `r` in which the marquee can touch it. let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {- Some(s) => match (s.children(), marquee_in(s, r)) {- (Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),- (Some(_), None) => vec![],+ Some(s) => match s.children() {+ Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it.- (None, _) => doc+ None => doc .parent_of(s.id) .and_then(|p| doc.node(p))- .and_then(|p| Some((p.children()?, marquee_in(p, r)?)))+ .and_then(|p| p.children()) .into_iter()- .flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))+ .flat_map(|c| c.iter().filter(|c| c.id == s.id).map(|c| (c, r))) .collect(), }, // The objects of the layers, looking through sublayers (hidden, locked and template ones@@ -814,7 +826,9 @@ mod tests { /// #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- /// Group Selection's leaves and in isolation mode.+ /// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not+ /// limit the marquee, as it does not limit clicks: with the group or the art isolated, the+ /// marquee takes the hidden art. #[test] fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { let mut d = Document::new(300.0, 200.0);@@ -829,8 +843,9 @@ mod tests { let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); assert!(marquee(&d, hidden, None, false).is_empty()); assert!(marquee(&d, hidden, None, true).is_empty());- assert!(marquee(&d, hidden, Some(g), false).is_empty());- assert!(marquee(&d, hidden, Some(art_id), false).is_empty());+ assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]);+ assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]);+ assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]); // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); // This marquee crosses the shown half's top edge and goes on into the hidden half.@@ -1096,6 +1111,32 @@ mod tests { assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); } + /// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or+ /// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the+ /// single object isolated; the mask's outline is still hit first. Outside isolation mode, and+ /// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu+ /// lists the hidden art when it is locked.+ #[test]+ fn isolation_reaches_the_art_a_clipping_mask_hides() {+ let hidden = Point::new(170.0, 90.0);+ for layer in [false, true] {+ let (mut d, g, clip, art) = masked_square(layer);+ let isolated = HitOptions { scope: Some(g), ..SELECTING };+ assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated");+ let h = hit_test(&d, hidden, isolated);+ assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}");+ assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}");+ assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first");+ let one = HitOptions { scope: Some(art), ..SELECTING };+ assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated");+ assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only");+ assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling");+ d.node_mut(art).unwrap().locked = true;+ assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}");+ assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated");+ }+ }+ /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test]@@ -1158,6 +1158,23 @@ mod tests { assert!(names(&a, art) && !names(&a, clip), "{a:?}"); } + /// #3748: in isolation mode Direct Selection and Group Selection take the art that the+ /// isolated clip group hides outside its mask; outside isolation mode a press there does not.+ #[test]+ fn isolation_lets_direct_selection_take_the_art_a_clip_group_hides() {+ let (d, g, _, art) = doc_with_masked_square();+ let (s, p) = (Selection::default(), paint());+ let names =+ |actions: &[Action]| actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(art.0) || v["ids"] == json!([art.0])));+ for group in [false, true] {+ let isolated = ToolContext { isolation: Some(g), ..cx(&d, &s, &p) };+ let a = DirectSelectionTool::new(group).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 530.0, 450.0));+ assert!(names(&a), "group {group}: {a:?}");+ let a = DirectSelectionTool::new(group).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 530.0, 450.0));+ assert!(!names(&a), "group {group}, not isolated: {a:?}");+ }+ }+ /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, _) = doc_with_rect();@@ -1082,6 +1082,26 @@ mod tests { assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); } + /// #3748: in isolation mode a press on the art that the isolated clip group hides outside its+ /// mask selects that art, and a drag from there moves it. Outside isolation mode the same+ /// press starts a marquee.+ #[test]+ fn isolation_drags_the_art_a_clip_group_hides() {+ let (d, g, _, art) = doc_with_masked_square();+ let (none, p) = (Selection::default(), paint());+ let mut picked = Selection::default();+ picked.add(art);+ let mut t = SelectionTool::default();+ let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &none, &p) }, &ev(PointerKind::Down, 530.0, 450.0));+ assert_eq!(a, vec![Action::Exec("select.set".into(), json!({ "ids": [art.0] }))]);+ let a = t.pointer(&ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) }, &ev(PointerKind::Drag, 430.0, 450.0));+ let Some(Action::Preview(c, v)) = a.get(1) else { panic!("{a:?}") };+ assert_eq!((c.as_str(), v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), ("object.transform", Some(-100.0), Some(0.0)));+ let mut t = SelectionTool::default();+ assert!(t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 530.0, 450.0)).is_empty());+ assert!(matches!(t.state, State::Marquee { .. }), "not isolated");+ }+ /// #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]@@ -613,6 +613,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Selection or Group Selection tool, pressing E while dragging a marquee makes the marquee select only the objects that lie entirely inside it. Pressing E again switches the marquee back to its default selection. The Lasso picks anchor points only on art a click can pick, and the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchor points and segments only on the selected paths and on art a click can pick: not on a Shaper Group's original shapes outside Construction mode, and in isolation mode only inside the isolated group or object. Smart Guides and Snap to Point do not snap to a Shaper Group's original shapes, and the reference point of the transform tools snaps to one only when it is selected, or under the pointer in Construction mode. A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345).+ When a clip group, a layer with a clipping mask or a single object in one is isolated, the Selection, Direct Selection and Group Selection tools also select the art that the mask hides, by a click or a marquee, and a drag from that art moves it (#3748). In isolation mode the canvas Unlock submenu also lists that art when it is locked. Outside isolation mode a click selects clipped art only inside the mask. A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points. A marquee selects an Intertwine group only when it reaches one of the group's members, as for a plain group, and it selects a repeat only when it reaches one of the copies the repeat draws. Direct and Group Selection's marquees select the anchor points of an Intertwine group's members, not the group, and Group Selection's E marquee selects the members inside it. A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to.