From 38542e02ba7224be775a33375d661880125ffa37 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 09:40:26 -0700 Subject: [PATCH] Shaper: the Selection tool moves a selected original shape while a mask or pattern is edited While an opacity mask or a pattern was edited, a Shaper double-click selected a Shaper Group's original shape without isolating the group's original shapes. A Selection tool press on that shape then selected and moved the whole Shaper Group over the art the group shows, and started a marquee over a part of the shape the group does not show. A press on the selected original shape now holds it for a move, and the pointer shows the move cursor over it. --- crates/tools/src/select.rs | 25 +++++++++++++++++++++++-- crates/tools/src/select/tests.rs | 29 +++++++++++++++++++++++++++++ docs/shaper.md | 2 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index c957066c7..195cfcfbd 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -462,6 +462,20 @@ pub(crate) fn hold_point(cx: &ToolContext, h: &Hit, p: Point) -> Point { cx.doc.node(h.leaf).and_then(|n| n.path_data()).and_then(|pd| hold_on_path(pd, p, reach)).unwrap_or(p) } +/// While an opacity mask or a pattern is edited, a Shaper double-click selects a Shaper Group's +/// original shape without isolating the group's original shapes. A press at `p` on a selected +/// original shape holds it for a move: the hit inside its Shaper Group's original shapes, and that +/// container. None otherwise. +fn selected_source_hit(cx: &ToolContext, p: Point) -> Option<(Hit, NodeId)> { + if cx.doc.mask_edit.is_none() && cx.doc.pattern_edit.is_none() { + return None; + } + let first = *cx.selection.objects.first()?; + let sources = cx.doc.parent_of(first).filter(|g| cx.doc.node(*g).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)))?; + let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { scope: Some(sources), ..cx.selection_hit_options() })?; + cx.selection.contains(h.top_object(Some(sources))).then_some((h, sources)) +} + impl SelectionTool { fn drag_threshold(cx: &ToolContext) -> f64 { cx.tol(3.0) @@ -675,9 +689,13 @@ impl Tool for SelectionTool { self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } - match hit_test(cx.doc, p, cx.selection_hit_options()) { + let (hit, scope) = match selected_source_hit(cx, p) { + Some((h, sources)) => (Some(h), Some(sources)), + None => (hit_test(cx.doc, p, cx.selection_hit_options()), cx.isolation), + }; + match hit { Some(h) => { - let top = h.top_object(cx.isolation); + let top = h.top_object(scope); let mut out = vec![]; let (mut deselect, mut key) = (None, None); if m.shift { @@ -896,6 +914,9 @@ impl Tool for SelectionTool { if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) { return Cursor::ArrowHollow; } + if selected_source_hit(cx, p).is_some() { + return Cursor::Move; + } if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) { if cx.selection.contains(h.top_object(cx.isolation)) || m.alt { return Cursor::Move; diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 0819d56ad..c511f8200 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2,6 +2,35 @@ use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; +/// While an opacity mask is edited, a press on a selected Shaper original shape moves it, over the +/// art the group shows and over a part of the shape the group does not show. A press on an original +/// shape that is not selected selects the Shaper Group or starts a marquee, as before. +#[test] +fn a_press_on_a_selected_shaper_source_moves_it_while_a_mask_is_edited() { + let (mut d, g, sources, _) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let layer = d.layers[0].id; + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + let ids: Vec = d.node(sources).unwrap().children().unwrap().iter().map(|n| n.id).collect(); + let (mut s, p) = (Selection::default(), paint()); + s.set([ids[1]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + for x in [75.0, 125.0] { + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(x, 350.0), Mods::default()), Cursor::Move, "at {x}"); + let down = t.pointer(&c, &ev(PointerKind::Down, x, 350.0)); + assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "at {x}: {down:?}"); + let drag = t.pointer(&c, &ev(PointerKind::Drag, x + 20.0, 350.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "at {x}: {drag:?}"); + } + s.set([ids[0]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 125.0, 350.0)); + assert!(down.is_empty(), "{down:?}"); + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 75.0, 350.0)); + assert!(down.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([g.0]))), "{down:?}"); +} + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { PointerEvent::new(kind, x, y) } diff --git a/docs/shaper.md b/docs/shaper.md index 86379c822..b6d0fbc84 100644 --- a/docs/shaper.md +++ b/docs/shaper.md @@ -17,7 +17,7 @@ Smart Guides and Snap to Point do not snap to the original shapes. The reference Object › Shaper › Release restores the original paths. Expand keeps the visible result as ordinary paths. Each scribble and source edit records one undo step. Sources in a composition must share a parent layer/group; Shaper limits a composition to 64 source paths and 4,096 segments. -While an opacity mask or a pattern is edited, its editing layer stays isolated and the arrow widget does not show. A double-click with Shaper on a Shaper Group in the mask or pattern art selects the original shape under the pointer without isolating the group's original shapes, and a scribble, Release and Expand leave the editing layer isolated. +While an opacity mask or a pattern is edited, its editing layer stays isolated and the arrow widget does not show. A double-click with Shaper on a Shaper Group in the mask or pattern art selects the original shape under the pointer without isolating the group's original shapes, the Selection tool moves that selected shape, and a scribble, Release and Expand leave the editing layer isolated. Finer gesture heuristics remain to be implemented.