diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 0ba4ebc0d..8ba8a0c9f 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Select Shaper Face", [], None, - "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original; while a group's originals are isolated, it selects one of that group's originals → {id}", + "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", has_doc, select ), @@ -678,7 +678,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(id) })?; - s.doc_mut()?.isolation = None; + end_isolation(s)?; Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes})) } @@ -730,13 +730,25 @@ fn select(s: &mut Session, p: &Value) -> Result { } else { group }; - let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten(); + // While an opacity mask or a pattern is edited, the source pick leaves its editing layer isolated. + let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten().filter(|_| editing_layer(&st.doc).is_none()); s.select(|_, sel| sel.set([target]))?; if let Some(id) = isolation { s.doc_mut()?.isolation = Some(id); } Ok(json!({"id": target.0})) } +/// The layer an opacity mask or a pattern is edited in, which stays isolated while that mode is on. +fn editing_layer(d: &Document) -> Option { + d.mask_edit.map(|m| m.layer).or_else(|| d.pattern_edit.as_ref().map(|e| e.layer)) +} +/// Leave isolation mode, Construction mode included. While an opacity mask or a pattern is edited, +/// its editing layer stays isolated. +fn end_isolation(s: &mut Session) -> Result<()> { + let st = s.doc_mut()?; + st.isolation = editing_layer(&st.doc); + Ok(()) +} fn target(s: &Session, p: &Value) -> Result { let id = id_param(p, "id") .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) @@ -757,7 +769,7 @@ fn release(s: &mut Session, p: &Value) -> Result { sel.set(ids.iter().copied()); Ok(ids) })?; - s.doc_mut()?.isolation = None; + end_isolation(s)?; Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) } fn expand(s: &mut Session, p: &Value) -> Result { @@ -774,6 +786,6 @@ fn expand(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(()) })?; - s.doc_mut()?.isolation = None; + end_isolation(s)?; Ok(json!({"id": id.0})) } diff --git a/crates/engine/src/tests/shaper.rs b/crates/engine/src/tests/shaper.rs index c0b4ad888..fbd20ff4a 100644 --- a/crates/engine/src/tests/shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -356,6 +356,50 @@ fn shaper_isolate_refuses_a_locked_or_hidden_source_group() { } } +/// While an opacity mask or a pattern is edited, its editing layer stays isolated: a Shaper source +/// pick selects the original shape without isolating it, and a scribble, Release and Expand keep the +/// layer isolated. Leaving the mode then ends isolation. +#[test] +fn shaper_keeps_the_mask_and_pattern_editing_layers_isolated() { + for mode in ["mask", "pattern"] { + let mut s = session(); + let object = rectangle(&mut s, 300.0, "#000000"); + let layer = if mode == "mask" { + let m = rectangle(&mut s, 300.0, "#ffffff"); + s.execute("select.set", &json!({"ids": [object.0, m.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap()["layer"].as_u64().map(NodeId) + } else { + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + s.doc().unwrap().doc.pattern_edit.as_ref().map(|e| e.layer) + }; + let isolation = |s: &Session| s.doc().unwrap().isolation; + assert!(layer.is_some() && isolation(&s) == layer, "{mode}"); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let make = |s: &mut Session| { + let pts = [[25, 20], [75, 35], [30, 50], [125, 65]]; + NodeId(s.execute("shaper.scribble", &json!({"points": pts, "ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap()) + }; + let g = make(&mut s); + assert_eq!(s.doc().unwrap().doc.parent_of(g), layer, "{mode}"); + assert_eq!(isolation(&s), layer, "{mode}: scribble"); + let picked = s.execute("shaper.select", &json!({"point": [25, 50], "source": true})).unwrap(); + assert_eq!(picked["id"], json!(a.0), "{mode}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{mode}"); + assert_eq!(isolation(&s), layer, "{mode}: source pick"); + s.execute("shaper.release", &json!({"id": g.0})).unwrap(); + assert_eq!(isolation(&s), layer, "{mode}: release"); + let g = make(&mut s); + s.execute("shaper.expand", &json!({"id": g.0})).unwrap(); + assert_eq!(isolation(&s), layer, "{mode}: expand"); + let leave = if mode == "mask" { "transparency.stopEditingOpacityMask" } else { "object.pattern.done" }; + s.execute(leave, &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.doc.pattern_edit.is_none() && st.isolation.is_none(), "{mode}"); + } +} + #[test] fn shaper_lines_keep_free_angles_with_or_without_shift() { for shift in [false, true] { diff --git a/crates/tools/src/shaperwidget.rs b/crates/tools/src/shaperwidget.rs index 0eb1bcb19..f44a622a1 100644 --- a/crates/tools/src/shaperwidget.rs +++ b/crates/tools/src/shaperwidget.rs @@ -1,6 +1,7 @@ //! The Shaper Group's mode widget: a small arrow beside the right side of the bounds of a Shaper //! Group's source shapes. It shows while the group or one of its faces is selected (Face Selection -//! mode) and while the group's source shapes are isolated (Construction mode). A click on it +//! mode) and while the group's source shapes are isolated (Construction mode), but not while an +//! opacity mask or a pattern is edited. A click on it //! switches between the two modes: it isolates the source shapes (`object.isolate`), or it leaves //! isolation with the group selected (`object.exitIsolation`). The Shaper and Selection tools hit //! it and the canvas draws it from the same geometry. @@ -33,9 +34,10 @@ impl ShaperWidget { /// The widget at `zoom` (screen px per document point), beside the bounds of the group's source /// shapes in both modes. Construction mode: `isolation` is a Shaper Group's source shapes. Face /// Selection mode: the selection is one Shaper Group, or one face or stroke of it. It returns - /// None otherwise, and when the group, or a layer or group that holds it, is hidden or locked. + /// None otherwise, when the group, or a layer or group that holds it, is hidden or locked, and + /// while an opacity mask or a pattern is edited. pub fn of(doc: &Document, selection: &Selection, isolation: Option, zoom: f64) -> Option { - if !zoom.is_finite() || zoom <= 0.0 { + if !zoom.is_finite() || zoom <= 0.0 || doc.mask_edit.is_some() || doc.pattern_edit.is_some() { return None; } let shaper = |id: NodeId| doc.node(id).is_some_and(|n| n.extras().shaper.is_some()); @@ -153,4 +155,21 @@ mod tests { assert!(widget(&d, &[g]).is_none()); assert!(widget(&d, &[face]).is_none()); } + + #[test] + fn no_widget_shows_while_an_opacity_mask_or_a_pattern_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; + let mut s = Selection::default(); + s.add(g); + assert!(ShaperWidget::of(&d, &s, None, 1.0).is_some()); + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + assert!(ShaperWidget::of(&d, &s, Some(layer), 1.0).is_none(), "mask editing"); + assert!(ShaperWidget::of(&d, &Selection::default(), Some(sources), 1.0).is_none(), "mask editing, sources isolated"); + d.mask_edit = None; + d.pattern_edit = Some(vectorcraft_doc::pattern::PatternEdit { pattern: "P".into(), layer, original: None }); + assert!(ShaperWidget::of(&d, &s, Some(layer), 1.0).is_none(), "pattern editing"); + assert!(ShaperWidget::of(&d, &Selection::default(), Some(sources), 1.0).is_none(), "pattern editing, sources isolated"); + } } diff --git a/docs/shaper.md b/docs/shaper.md index 5a232bc4b..86379c822 100644 --- a/docs/shaper.md +++ b/docs/shaper.md @@ -17,6 +17,8 @@ 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. + Finer gesture heuristics remain to be implemented. Behavior reference: [public Shaper documentation](https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html). Rotation steps and independent fill/stroke cases follow the contributor’s supplied requirements. No reference images or product assets are included. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eec34b44a..31d8df317 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -631,6 +631,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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). 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. 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. With two or more objects selected and no key object, Align to Key Object makes the topmost of them the key, whatever order they were selected in, and the Align panel, the Control bar and the Properties panel align to it. Align To keeps its Selection or Artboard choice and shows it again once the key is cleared.