diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 4a42f03d4f..98f53b102e 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -20,6 +20,9 @@ //! and they lie the same way relative to each other: the group as a whole is moved or scaled. //! With Appearance on, each of those objects must have the same fill and stroke too; with Size, //! the groups the same width and height. +//! - Selecting one of the repeated objects with the Selection tool highlights the similar objects +//! at once, before Global Edit starts. Here they are the objects Start Global Edit would take +//! with the current Global Edit Options ([`similar`]). //! - While Global Edit is on, an edit made to one of the found objects is made to all of them, //! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt //! match to 10 pt. Stop Global Edit (or Escape) ends it. @@ -780,18 +783,55 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas -/// highlights them while Global Edit is on. +/// highlights them while Global Edit is on. While it is off, with one supported object selected, +/// they are the outlines of the objects Start Global Edit would take with the Global Edit Options +/// ([`similar`]). pub fn highlights(s: &Session) -> Outlines { let Some(st) = s.active() else { return vec![] }; - let Some(ge) = &st.global_edit else { return vec![] }; + let Some(ge) = &st.global_edit else { return similar(s) }; if let Some((rev, out)) = &*ge.outlines.borrow() && *rev == st.revision { return out.clone(); } + let out = outlines(st, &ge.members); + *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); + out +} + +/// [`similar`]'s outlines as of a document revision and the options they were found with. +pub type SimilarCache = std::cell::RefCell>; + +/// Before Global Edit starts: with one supported object selected, the outlines of the other +/// objects Start Global Edit would take now with the session's Global Edit Options, so the canvas +/// shows them as soon as the object is selected. None while an interaction (a drag) is in +/// progress, or when fewer than two objects would be taken. Kept per document revision and +/// options, as finding them walks the whole document. +pub fn similar(s: &Session) -> Outlines { + let Some(st) = s.active() else { return vec![] }; + if st.global_edit.is_some() || st.interaction.is_some() || st.selection.objects.len() != 1 { + return vec![]; + } + let o = &s.global_edit_options; + if let Some((rev, opts, out)) = &*st.similar_outlines.borrow() + && *rev == st.revision + && opts == o + { + return out.clone(); + } + let out = match matches(s, o) { + Ok(m) if m.len() >= 2 => outlines(st, &m), + _ => vec![], + }; + *st.similar_outlines.borrow_mut() = Some((st.revision, o.clone(), out.clone())); + out +} + +/// The outlines of `members` that are not selected, hold no selected object and sit inside no +/// selected object, in their layer's color. +fn outlines(st: &crate::DocState, members: &[(NodeId, Affine)]) -> Outlines { let symbols = st.doc.symbol_boxes(); - let out: Vec<_> = ge - .members + members .iter() .filter_map(|(id, _)| { let n = st.doc.node(*id).filter(|n| !holds(n, &st.selection.objects))?; @@ -804,9 +844,7 @@ pub fn highlights(s: &Session) -> Outlines { }; Some((path, st.doc.layer_color(*id))) }) - .collect(); - *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); - out + .collect() } #[cfg(test)] diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 08b8a2f294..0ef7555e02 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -34,6 +34,9 @@ pub struct DocState { /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. pub global_edit: Option, + /// The objects Start Global Edit would take, highlighted before it starts + /// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable. + pub(crate) similar_outlines: cmd::globaledit::SimilarCache, /// What leaving symbol editing mode goes back to ([`cmd::symboledit`]). pub(crate) symbol_edit_return: cmd::symboledit::Return, pub interaction: Option, @@ -302,6 +305,7 @@ impl DocState { variables_highlight: None, isolation: None, global_edit: None, + similar_outlines: Default::default(), symbol_edit_return: Default::default(), interaction: None, undo_group: None, diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index 60bf81a060..ac5a494dc7 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -92,7 +92,50 @@ fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2); let r = s.execute("select.globalEdit", &json!({})).unwrap(); assert_eq!(r["active"], false, "the second run is Stop Global Edit"); - assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty()); + assert!(s.doc().unwrap().global_edit.is_none()); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "with the square still selected, the objects it would take are highlighted again"); +} + +#[test] +fn selecting_one_object_highlights_the_similar_objects_before_global_edit_starts() { + use crate::cmd::globaledit::highlights; + let mut s = session(); + let [a, b, _, wide, _] = squares(&mut s); + assert!(highlights(&s).is_empty(), "nothing selected"); + sel(&mut s, &[a]); + assert_eq!(members(&s), None, "Global Edit has not started"); + assert_eq!(highlights(&s).len(), 2, "the copy and the smaller square"); + s.execute("select.globalEditOptions", &json!({"size": true})).unwrap(); + assert_eq!(highlights(&s).len(), 1, "the options narrow them: the copy only"); + s.execute("select.globalEditOptions", &json!({"size": false})).unwrap(); + assert_eq!(highlights(&s).len(), 2); + sel(&mut s, &[b]); + s.execute("edit.clear", &json!({})).unwrap(); + sel(&mut s, &[a]); + assert_eq!(highlights(&s).len(), 1, "a deleted copy is no longer highlighted"); + sel(&mut s, &[wide]); + assert!(highlights(&s).is_empty(), "the rectangle has no similar object"); + sel(&mut s, &[a, wide]); + assert!(highlights(&s).is_empty(), "two objects selected"); +} + +#[test] +fn the_canvas_highlights_similar_objects_before_the_start_with_the_selection_tool_only() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + let outline = node(&s, b).stroke_path().unwrap(); + let shown = |s: &mut Session| { + s.overlays(ViewInfo::default()) + .iter() + .any(|o| matches!(o, vectorcraft_tools::Overlay::Path { path, width, .. } if *path == outline && *width == 1.0)) + }; + sel(&mut s, &[a]); + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(shown(&mut s), "the copy is highlighted with the Selection tool"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + assert!(!shown(&mut s), "not with another tool before Global Edit starts"); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(shown(&mut s), "while Global Edit is on, with any tool"); } #[test] @@ -506,6 +549,7 @@ fn leaving_out_all_but_one_ends_global_edit_and_the_first_object_stays() { let r = s.execute("select.globalEditExclude", &json!({"id": b.0})).unwrap(); assert_eq!(r, json!({"active": false, "count": 0, "ids": []})); assert_eq!(members(&s), None, "one object left: Global Edit ends"); + sel(&mut s, &[wide]); assert!(crate::cmd::globaledit::highlights(&s).is_empty()); } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9bc450f937..4f8188dbe7 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1142,8 +1142,11 @@ impl Session { let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position }); v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place)); } - // Global Edit: the objects edited with the selected one are highlighted. - for (path, color) in crate::cmd::globaledit::highlights(self) { + // Global Edit: the objects edited with the selected one are highlighted; before it starts, + // with the Selection tool, the objects it would take. + let ge_on = self.active().is_some_and(|d| d.global_edit.is_some()); + let shown = if ge_on || self.tool.id() == "selection" { crate::cmd::globaledit::highlights(self) } else { vec![] }; + for (path, color) in shown { v.push(Overlay::Path { path, color, width: 1.0, dashed: false }); } if let Some((cmd, p)) = deferred_preview { diff --git a/docs/gaps.md b/docs/gaps.md index a582c8c20a..cc40abf56b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -610,6 +610,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi them in proportion, as one undo step ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)); on a group (#4939), the groups like it (the same objects at the same places, moved or scaled) take an edit made to an object inside it at the object in the same place ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); + with one such object or group selected with the Selection tool, the objects Start Global Edit would take are + highlighted on the canvas before it starts (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); dynamic symbols (#4938): Symbol Options › Symbol Type (Dynamic Symbol / Static Symbol; `type` on `symbol.new` / `symbol.options` / `symbol.list`), an instance's own fill, stroke, opacity and blend for an object of a dynamic symbol's art (`symbol.instance.setAppearance`, by child-index path), drawn and exported @@ -654,8 +656,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; - Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the - start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). + Global Edit of groups holding type or images, and rotated copies as matches; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 5–15 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index 85a341b1e6..b487fa0eff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2415,7 +2415,9 @@ each of the others, whose hidden or locked objects are left as they are; when th other group takes its objects at that level, keeping its own ids in order. Whenever the document is put back (Undo, Redo, a cancelled drag or scrub), the matches it no longer has leave the Global Edit, which ends when fewer than two are left. An edit that would take any match past the canvas fails with the coordinates-out-of-range error and changes -nothing. Nothing of it is saved. +nothing. Nothing of it is saved. Before Global Edit starts, while one object that `select.globalEdit` accepts is +selected with the Selection tool, the canvas highlights the other objects a start would take with the current Global +Edit Options (none while a drag is in progress, or when there are none). `select.globalEditExclude {id}` leaves the match `id` out of the Global Edit, as a Shift-click on a highlighted match (or on an object inside it) with the Selection tool does: it is no longer highlighted, and later edits leave it as it