mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
Merge pull request #5030 from dotCooCoo/fix/issue-3748-r
Fix #3748: in isolation mode, art outside a clip group's mask can be selected and dragged
This commit is contained in:
+22
-10
@@ -437,7 +437,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) {
|
||||
@@ -653,6 +653,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,
|
||||
@@ -670,8 +679,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;
|
||||
}
|
||||
}
|
||||
@@ -1205,7 +1216,9 @@ 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> {
|
||||
marquee_with(doc, r, scope, leaves, false)
|
||||
}
|
||||
@@ -1223,16 +1236,15 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, wh
|
||||
// Isolated art whose layer, or a group around it, was locked or hidden afterwards: the
|
||||
// marquee touches none of it, as clicks don't (#5309).
|
||||
Some(s) if !doc.is_editable(s.id) => vec![],
|
||||
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
|
||||
|
||||
@@ -255,7 +255,9 @@ fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline()
|
||||
/// #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);
|
||||
@@ -270,8 +272,9 @@ fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
|
||||
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.
|
||||
@@ -682,6 +685,82 @@ fn select_behind_takes_a_layer_masks_outline_first() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes
|
||||
/// the art its own mask hides, while the mask of a clip group inside it still limits that
|
||||
/// group's art. With the inner clip group isolated, or its art, the inner mask does not.
|
||||
#[test]
|
||||
fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) =
|
||||
(d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let mask = |id, r| {
|
||||
let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Arc::new(path)
|
||||
};
|
||||
let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art()));
|
||||
let inner_group = Node::new(
|
||||
inner,
|
||||
NodeKind::Group {
|
||||
children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))],
|
||||
clip: true,
|
||||
},
|
||||
);
|
||||
let children =
|
||||
vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)];
|
||||
d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap();
|
||||
// `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is
|
||||
// inside the outer mask and outside the inner one, on the inner group's art.
|
||||
let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0));
|
||||
let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf);
|
||||
assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated");
|
||||
assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art");
|
||||
assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips");
|
||||
let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING });
|
||||
assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art");
|
||||
assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated");
|
||||
assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art");
|
||||
assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated");
|
||||
// These marquees cross the right edge of the outer art (outside the outer mask) and of the
|
||||
// inner art (inside the outer mask, outside the inner one).
|
||||
let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0));
|
||||
assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]);
|
||||
assert!(marquee(&d, inner_edge, Some(outer), false).is_empty());
|
||||
assert!(marquee(&d, inner_edge, Some(outer), true).is_empty());
|
||||
assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]);
|
||||
assert!(marquee(&d, inner_edge, None, false).is_empty());
|
||||
assert!(marquee(&d, outer_edge, None, false).is_empty());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -128,6 +128,31 @@ fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() {
|
||||
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, and a drag from there moves it; outside
|
||||
/// isolation mode a press there does not take it.
|
||||
#[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 mut picked = Selection::default();
|
||||
picked.add(art);
|
||||
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 mut t = DirectSelectionTool::new(group);
|
||||
let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 530.0, 450.0));
|
||||
assert!(names(&a), "group {group}: {a:?}");
|
||||
// The press selected the art: the drag that follows moves it.
|
||||
let isolated = ToolContext { isolation: Some(g), ..cx(&d, &picked, &p) };
|
||||
let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Drag, 510.0, 470.0));
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Begin(n) if n == "Move")), "group {group}: {a:?}");
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Preview(..))), "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();
|
||||
|
||||
@@ -79,6 +79,26 @@ 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:?}");
|
||||
}
|
||||
|
||||
/// #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");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -826,6 +826,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Select by Artboards (Object › Plug-ins › Selection Tools, a VectorCraft addition: #5525, #5548, #5549) selects the objects overlapping, fully inside or fully outside one or more chosen artboards (`addon.select.byArtboards {artboards, relation}`). Overlapping is All on Active Artboard's test, shared as `select::on_artboard`; Fully inside means within one artboard, edges included; Fully outside means on none. It looks at what Select All takes, so hidden, locked and locked-artboard art stays out. Its dialog lists each artboard's number, name and size in the document's units, filters by name, chooses rows with click, Shift-click and Cmd/Ctrl-click, and fits a row's artboard in the window on double-click (`view.goToArtboard`). Tests: `crates/engine/src/cmd/addons/select/tests.rs`, `crates/ui-egui/src/addons/select/tests.rs`, `crates/engine/tests/command_sweep.rs`.
|
||||
Select by Link Status (Object › Plug-ins › Selection Tools and the Links panel menu, a VectorCraft addition: #5526) selects the image objects and placed documents whose link is linked (its file found unchanged), embedded, missing or modified, as `links.list` reports it (`addon.select.byLinkStatus {status, ids?, add?, zoom?}`); Select Linked, Embedded, Missing and Modified are off when no image has that status and add to the selection with Shift. Objects that are hidden or locked, inside a hidden or locked object or layer, on a template layer, on a locked artboard, or outside the isolated group are left out (the `select::is_selectable` test) and returned with the reason, and the status bar says how many, in plural forms of every catalog. Select by Link Status… lists each image and placed document with its name, status and format, searches by name, filters by status, picks rows with checkboxes and All / None, and zooms to the selection with the existing Zoom to Selection (`addon.arrange.zoomSelection`). Missing-link markers on the canvas are #5547. Tests: `crates/engine/src/cmd/addons/select/tests.rs` (`by_link_status`), `crates/ui-egui/src/addons/select/link_status/tests.rs`, `crates/engine/tests/command_sweep.rs`.
|
||||
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.
|
||||
While an opacity mask or a pattern is edited, a Shaper double-click on a Shaper Group in the mask or pattern art selects the original shape under the pointer without entering Construction mode, the Shaper Group's mode widget does not show, and a Shaper scribble, Release and Expand keep the editing layer isolated.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user