mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 06:31:41 +00:00
Global Edit: Start Global Edit is offered only for a group it can start on
Select > Start Global Edit was enabled for any plain group, so a group holding type, an image or nothing was offered in the Select menu and the Properties panel's Quick Actions, the Control bar showed its Global Edit Options, and starting then refused it. The enabled check now applies the same support check as starting to every selected object, a group's contents included. The answer is kept per document revision and selected object, so the Control bar, which asks every frame, walks a group once per change. Docs: docs/mcp.md, docs/target-app-parity.md (a new line; the existing Global Edit line no longer says the menu item checks only for a group).
This commit is contained in:
@@ -432,16 +432,24 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> {
|
||||
let [id] = st.selection.objects.as_slice() else {
|
||||
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
|
||||
};
|
||||
// A group's contents are checked when Global Edit starts: measuring them here would walk
|
||||
// every object in it each time the menu or the Properties panel asks. Other objects are
|
||||
// measured ([`supported`]), not reduced to their form: the Control bar asks every frame.
|
||||
match st.doc.node(*id) {
|
||||
Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()),
|
||||
Some(n) if supported(n) => Ok(()),
|
||||
_ => Err(UNSUPPORTED.into()),
|
||||
}
|
||||
// The object, a group's contents included, is measured ([`supported`]), not reduced to its
|
||||
// form, and the answer is kept until the document's revision moves: the Control bar asks
|
||||
// every frame, and a group's walk takes time with the anchors in it.
|
||||
let ok = match st.global_edit_support.get() {
|
||||
Some((rev, key, ok)) if rev == st.revision && key == *id => ok,
|
||||
_ => {
|
||||
let ok = st.doc.node(*id).is_some_and(supported);
|
||||
st.global_edit_support.set(Some((st.revision, *id, ok)));
|
||||
ok
|
||||
}
|
||||
};
|
||||
if ok { Ok(()) } else { Err(UNSUPPORTED.into()) }
|
||||
}
|
||||
|
||||
/// [`can_toggle`]'s answer for the one selected object: the revision and the object it was found
|
||||
/// for, and whether Global Edit supports it.
|
||||
pub type SupportCache = std::cell::Cell<Option<(u64, NodeId, bool)>>;
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.global_edit.is_some() {
|
||||
let st = s.doc_mut()?;
|
||||
@@ -989,8 +997,7 @@ mod tests {
|
||||
continue;
|
||||
}
|
||||
asked += 1;
|
||||
let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n);
|
||||
assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}");
|
||||
assert_eq!(can_toggle(&s).is_ok(), by_form(&n), "seed {seed}");
|
||||
match s.execute(C, &json!({})) {
|
||||
Ok(_) => {
|
||||
assert!(by_form(&n), "seed {seed}");
|
||||
|
||||
@@ -37,6 +37,9 @@ pub struct DocState {
|
||||
/// 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,
|
||||
/// Whether Start Global Edit supports the one selected object
|
||||
/// ([`cmd::globaledit::SupportCache`]), as of a revision. Not saved, not undoable.
|
||||
pub(crate) global_edit_support: cmd::globaledit::SupportCache,
|
||||
/// What leaving symbol editing mode goes back to ([`cmd::symboledit`]).
|
||||
pub(crate) symbol_edit_return: cmd::symboledit::Return,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -311,6 +314,7 @@ impl DocState {
|
||||
isolation: None,
|
||||
global_edit: None,
|
||||
similar_outlines: Default::default(),
|
||||
global_edit_support: Default::default(),
|
||||
symbol_edit_return: Default::default(),
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
|
||||
@@ -330,18 +330,103 @@ fn global_edit_needs_one_supported_object() {
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
// A group holding type is refused, though another such group exists.
|
||||
// A group holding type is not offered, though another such group exists.
|
||||
let group = |s: &mut Session, x: f64| {
|
||||
let p = rect(s, x, 100.0, 10.0, 10.0);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 150, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
group_of(s, &[p, t])
|
||||
};
|
||||
let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0));
|
||||
assert_not_offered(&mut s, g, "a group holding type");
|
||||
}
|
||||
|
||||
fn enabled(s: &Session, id: &str) -> bool {
|
||||
command_specs().iter().find(|c| c.id == id).is_some_and(|c| (c.enabled)(s).is_ok())
|
||||
}
|
||||
|
||||
/// With `id` selected, Start Global Edit is disabled, and running it changes nothing.
|
||||
fn assert_not_offered(s: &mut Session, id: NodeId, what: &str) {
|
||||
sel(s, &[id]);
|
||||
assert!(!enabled(s, "select.globalEdit"), "{what}");
|
||||
let r = s.execute("select.globalEdit", &json!({}));
|
||||
assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("symbol instances and groups of them")), "{what}: {r:?}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none(), "{what}");
|
||||
}
|
||||
|
||||
/// An embedded 4 × 4 image at `x`, in `parent` (the default layer when `None`) → its id. The
|
||||
/// selection stays as it is.
|
||||
fn image(s: &mut Session, x: f64, parent: Option<NodeId>) -> NodeId {
|
||||
s.edit("Place", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::translate((x, 0.0)) * Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(parent.or(d.default_layer()), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_enabled_check_follows_edits_to_the_selected_group() {
|
||||
let mut s = session();
|
||||
let (g, _) = card(&mut s, 0.0, 1.0);
|
||||
card(&mut s, 300.0, 1.0);
|
||||
sel(&mut s, &[g]);
|
||||
// The menu only checks that it is a group; starting refuses its contents.
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("symbol instances and groups of them"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
// The answer is kept between frames, and an edit of the selected group changes it.
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
image(&mut s, 0.0, Some(g));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
assert!(!enabled(&s, "select.globalEdit"), "an image put into the selected group");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
assert!(enabled(&s, "select.globalEdit"), "after Undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_global_edit_is_offered_only_for_a_group_of_supported_objects() {
|
||||
let mut s = session();
|
||||
// Groups holding an image, groups holding a group that holds type, and empty groups; two of
|
||||
// each, so that each would have a group like it.
|
||||
for x in [0.0, 300.0] {
|
||||
let p = rect(&mut s, x, 100.0, 10.0, 10.0);
|
||||
let i = image(&mut s, x, None);
|
||||
let g = group_of(&mut s, &[p, i]);
|
||||
assert_not_offered(&mut s, g, "a group holding an image");
|
||||
let (p, q) = (rect(&mut s, x, 200.0, 10.0, 10.0), rect(&mut s, x + 20.0, 200.0, 10.0, 10.0));
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let inner = group_of(&mut s, &[q, t]);
|
||||
let g = group_of(&mut s, &[p, inner]);
|
||||
assert_not_offered(&mut s, g, "a group holding a group that holds type");
|
||||
let empty = s
|
||||
.edit("Group", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(d.default_layer(), 0, Node::group(id, vec![]))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
assert_not_offered(&mut s, empty, "an empty group");
|
||||
}
|
||||
// A group of supported objects is offered and starts; while on, Stop is offered whatever is
|
||||
// selected.
|
||||
let p = rect(&mut s, 600.0, 400.0, 10.0, 10.0);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 600, "y": 450, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let typed = group_of(&mut s, &[p, t]);
|
||||
let (g, _) = card(&mut s, 0.0, 1.0);
|
||||
let (copy, _) = card(&mut s, 300.0, 1.0);
|
||||
sel(&mut s, &[g]);
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
assert_eq!(start(&mut s, g, json!({})), [g, copy]);
|
||||
sel(&mut s, &[typed]);
|
||||
assert!(enabled(&s, "select.globalEdit"), "Stop");
|
||||
assert_eq!(s.execute("select.globalEdit", &json!({})).unwrap()["active"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+4
-1
@@ -2664,7 +2664,10 @@ compound path or symbol instance selected, it finds the objects of the same shap
|
||||
ones), among those `select.all` would take and the objects in their groups, and returns `{active: true, count, ids}`,
|
||||
the selected object first (at most 10,000). With a group of such objects selected (nested groups and clipping paths
|
||||
included; not type or images), it finds the groups like it: as many objects, in the same order, each of the same
|
||||
shape, lying the same way relative to each other (the group moved or scaled as a whole). `appearance` also asks for
|
||||
shape, lying the same way relative to each other (the group moved or scaled as a whole). While Global Edit is off,
|
||||
the command is disabled for any other selection, including a group that holds type, an image or nothing (it is off in
|
||||
the Select menu, the Properties panel's Quick Actions do not show it, and the Control bar does not show its Global Edit
|
||||
Options). `appearance` also asks for
|
||||
the same fill and stroke (for groups, of each object in them), `size` for the same width and height; `artboards` is
|
||||
`"all"` or a range such as `"1, 3-5"`, and `includeCanvas: false` leaves out the objects on no artboard. Missing
|
||||
params come from `select.globalEditOptions` (the Global Edit Options, kept for the session; it returns them with
|
||||
|
||||
@@ -627,9 +627,10 @@ since 2026-10-01.
|
||||
- **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`.
|
||||
- **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`.
|
||||
- **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). The menu item checks only that the selection is a group; starting checks its contents. Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.
|
||||
- **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`.
|
||||
- **Start Global Edit is offered only for a group it can start on:** with a group selected, Select › Start Global Edit is disabled, and the Properties panel's Quick Actions and the Control bar do not offer it or its options, when the group holds type, an image or nothing, at any depth; the check walks the group's objects the same way, stopping at the first one Global Edit does not take. This is VectorCraft's own rule. Regression test: `start_global_edit_is_offered_only_for_a_group_of_supported_objects` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`.
|
||||
- **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`.
|
||||
- **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`.
|
||||
|
||||
Reference in New Issue
Block a user