Merge pull request #5078 from mockydevs/fix/repeat-edit-text-5054

Fix #5054: a double-click on a repeat isolates it to edit its source
This commit is contained in:
BFlat
2026-11-02 14:59:56 +07:00
committed by GitHub
6 changed files with 165 additions and 2 deletions
+1
View File
@@ -135,6 +135,7 @@ mod recolor;
mod recovery;
mod reflecthit;
mod registration;
mod repeat_edit;
mod save;
mod saveoptions;
mod scalestrokes;
+140
View File
@@ -0,0 +1,140 @@
//! Editing a repeat's source art (#5054): a double-click on a repeat with the Selection tool
//! enters isolation mode on it; there a double-click on its type edits the text, and the repeat
//! keeps its settings and draws the new text in every copy.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::*;
fn click(s: &mut Session, kind: PointerKind, x: f64, y: f64) -> Vec<UiRequest> {
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo { smart_guides: false, ..ViewInfo::default() }).unwrap()
}
#[test]
fn a_double_click_on_a_repeat_isolates_it_to_edit_its_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
let text = NodeId(s.execute("text.create", &json!({"x": 50, "y": 60, "text": "Alice", "size": 24})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [text.0]})).unwrap();
let repeat = NodeId(s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap());
let spec = |s: &Session| match &s.doc().unwrap().doc.node(repeat).unwrap().kind {
NodeKind::Repeat(r) => serde_json::to_value(r).unwrap(),
k => panic!("not a repeat: {k:?}"),
};
let before = spec(&s);
s.select_tool("selection", ViewInfo::default()).unwrap();
let b = s.doc().unwrap().doc.node(text).unwrap().geometric_bounds().unwrap();
let (x, y) = (b.center().x, b.center().y);
click(&mut s, PointerKind::DoubleClick, x, y);
assert_eq!(s.doc().unwrap().isolation, Some(repeat), "the double-click isolates the repeat");
// Inside it, a double-click on the type asks for the Type tool, which edits that text.
let asked = click(&mut s, PointerKind::DoubleClick, x, y);
assert!(asked.iter().any(|r| matches!(r, UiRequest::SwitchTool(t) if t == "type")), "{asked:?}");
s.execute("text.setText", &json!({"id": text.0, "text": "Bob"})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(text).unwrap().kind else { panic!("not type") };
assert_eq!(t.plain_text(), "Bob");
assert_eq!(s.doc().unwrap().doc.position(text).and_then(|(p, ..)| p), Some(repeat), "still the repeat's source");
assert_eq!(spec(&s)["kind"], before["kind"], "the repeat keeps its settings (rows, columns, spacing)");
assert!(before["kind"].is_object(), "the settings are read: {before}");
}
/// A text repeat isolated by a double-click on it: returns (session, text, repeat, point on the text).
fn isolated_text_repeat() -> (Session, NodeId, NodeId, (f64, f64)) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
let text = NodeId(s.execute("text.create", &json!({"x": 50, "y": 60, "text": "Alice", "size": 24})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [text.0]})).unwrap();
let repeat = NodeId(s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap());
s.select_tool("selection", ViewInfo::default()).unwrap();
let b = s.doc().unwrap().doc.node(text).unwrap().geometric_bounds().unwrap();
let at = (b.center().x, b.center().y);
click(&mut s, PointerKind::DoubleClick, at.0, at.1);
assert_eq!(s.doc().unwrap().isolation, Some(repeat));
(s, text, repeat, at)
}
fn plain_text(s: &Session, id: NodeId) -> String {
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not type") };
t.plain_text()
}
/// #5054 review: a double-click outside the repeat leaves isolation mode, and so does
/// `object.exitIsolation` (what Escape and Exit Isolation Mode run); the repeat stays a repeat.
#[test]
fn isolation_on_a_repeat_ends_by_double_click_outside_or_exit() {
let (mut s, _, repeat, _) = isolated_text_repeat();
click(&mut s, PointerKind::DoubleClick, 580.0, 380.0);
assert_eq!(s.doc().unwrap().isolation, None, "a double-click on empty canvas leaves isolation mode");
let (mut s, _, repeat2, at) = isolated_text_repeat();
assert_eq!(repeat, repeat2);
s.execute("object.exitIsolation", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().isolation, None, "Escape's command leaves isolation mode");
assert!(matches!(s.doc().unwrap().doc.node(repeat).unwrap().kind, NodeKind::Repeat(_)), "still a repeat");
// And a double-click on it isolates it again.
click(&mut s, PointerKind::DoubleClick, at.0, at.1);
assert_eq!(s.doc().unwrap().isolation, Some(repeat));
}
/// #5054 review: an edit made in a repeat's isolation mode undoes in one step and the repeat
/// keeps drawing its source; isolating it adds no undo step of its own.
#[test]
fn an_edit_inside_an_isolated_repeat_undoes() {
let (mut s, text, repeat, _) = isolated_text_repeat();
let steps = s.doc().unwrap().history.undo.len();
s.execute("text.setText", &json!({"id": text.0, "text": "Bob"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(plain_text(&s, text), "Alice");
assert_eq!(s.doc().unwrap().doc.position(text).and_then(|(p, ..)| p), Some(repeat), "still the repeat's source");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(plain_text(&s, text), "Bob");
}
/// #5054 review: a repeat of a group isolates on a double-click, and a double-click on the group
/// inside it goes one level further, into the group, where its art can be selected alone.
#[test]
fn a_repeat_of_a_group_isolates_then_enters_the_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
let a = s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
let repeat = NodeId(s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap());
s.select_tool("selection", ViewInfo::default()).unwrap();
click(&mut s, PointerKind::DoubleClick, 55.0, 55.0);
assert_eq!(s.doc().unwrap().isolation, Some(repeat), "the first double-click isolates the repeat");
click(&mut s, PointerKind::DoubleClick, 55.0, 55.0);
assert_eq!(s.doc().unwrap().isolation, Some(group), "the second enters the group inside it");
click(&mut s, PointerKind::Down, 55.0, 55.0);
click(&mut s, PointerKind::Up, 55.0, 55.0);
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a)], "inside the group a click selects one rectangle");
}
/// #5054: in a repeat's isolation mode the repeat itself draws as usual (its source and every
/// copy); only the art around it is dimmed, as for an isolated group.
#[test]
fn an_isolated_repeat_is_not_dimmed() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [r], "color": "#000000"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let repeat = s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap();
let other = s.execute("shape.rectangle", &json!({"x": 150, "y": 150, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [other], "color": "#000000"})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let render = |iso: Option<u64>| {
let opts = vectorcraft_render::RenderOptions { isolated: iso.map(NodeId), ..Default::default() };
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
};
let alpha = |img: &vectorcraft_render::Rendered, x: usize, y: usize| img.pixels[(y * 200 + x) * 4 + 3];
let plain = render(None);
let iso = render(Some(repeat));
assert_eq!(alpha(&plain, 20, 20), 255, "the source");
assert_eq!(alpha(&iso, 20, 20), 255, "the isolated repeat's source is not dimmed");
assert!(alpha(&iso, 165, 165) < 200, "the art around it is: {}", alpha(&iso, 165, 165));
}
+8 -1
View File
@@ -273,7 +273,14 @@ impl Renderer {
return;
}
if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) {
return self.draw_live(ctx, f, a);
// The isolated object itself (a repeat isolated to edit its source, #5054) draws its
// art as usual, as an isolated group does in `draw_layer_node`.
let settle = f.opts.isolated == Some(a.id) && f.doc.mask_edit.is_none();
let outer = self.isolation_settled;
self.isolation_settled = outer || settle;
self.draw_live(ctx, f, a);
self.isolation_settled = outer;
return;
}
// Its live-effect geometry depends on the node alone, and for plug-in effects on the
// plug-ins installed (`check_plugins`), so it can be kept across frames.
+10 -1
View File
@@ -1035,7 +1035,8 @@ pub(crate) fn is_threadable(n: &vectorcraft_doc::Node) -> bool {
/// Does a double-click or the Control bar's Isolate Selected Object button isolate an object of
/// this kind? A group, a compound path or a single path, image, mesh or placed file does. Blends,
/// envelopes, repeats and symbol instances do not; they have editing modes of their own.
/// envelopes, repeats and symbol instances do not; they have editing modes of their own (a
/// repeat's is isolation of the repeat itself, to edit its source art).
pub fn isolates(kind: &vectorcraft_doc::NodeKind) -> bool {
use vectorcraft_doc::NodeKind as K;
matches!(kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_))
@@ -1198,6 +1199,14 @@ impl SelectionTool {
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })) {
return if cx.doc.symbol_edit.is_some() { vec![] } else { vec![Action::Exec("symbol.edit".into(), json!({ "id": top.0 }))] };
}
// A repeat: its source art is edited in isolation mode, where the repeat follows each
// edit (#5054), as the reference app's Repeat does.
if cx.double_click_isolate
&& cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Repeat(_)))
&& cx.isolation != Some(top)
{
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
}
// An object that [`isolates`], not the one already isolated.
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) {
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
+5
View File
@@ -1513,10 +1513,15 @@ Edit › Keyboard Shortcuts lists every item of the Effect menu under its menu p
While a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the kind of the corners being edited (Round, Inverted Round, Chamfer, in turn, from the pressed corner's), shown live with the radius and kept, with it, as one undo step when the drag ends; a press with an arrow and no drag changes just the kind (#2898). Alt-clicking a widget still cycles it as before. Test: `tests::livecorners::arrows_while_dragging_a_corner_widget_change_the_corner_kind`.
## Repeat: edit the source art in isolation mode (2026-11-01)
A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool enters isolation mode on it ([isolate objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/isolate-objects.html): a double-click with the Selection tool isolates an object; [repeat patterns overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/repeat-patterns-overview.html): every instance follows edits to the original object); there the source art is selected and edited as usual (a double-click on type in it switches to the Type tool on that text) and every copy follows, with the repeat's settings kept (#5054). Test: `tests::repeat_edit::a_double_click_on_a_repeat_isolates_it_to_edit_its_text`.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Repeat: a double-click isolates it to edit its source art, text included (#5054); evidence added; scores unchanged. |
| 2026-11-01 | minor | Live Corners: Up/Down while dragging a corner widget step the corner kind (#2898); evidence added; scores unchanged. |
| 2026-11-01 | minor | Shortcuts for Effect menu items (#3296); evidence added; scores unchanged. |
| 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); evidence added; scores unchanged. |
+1
View File
@@ -400,6 +400,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | A double-click on a Repeat with the Selection tool enters isolation mode on it, where its source art (type included: a double-click there edits the text) is edited and every copy follows (#5054); scores and estimates unchanged |
| 2026-11-01 | minor | Live Corners: while a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the corners' kind (round, inverted round, chamfer) with the radius, as one undo step (#2898); scores and estimates unchanged |
| 2026-11-01 | minor | Keyboard Shortcuts: every Effect menu item (Distort & Transform › Transform…, Stylize › Drop Shadow…, …) is listed under its menu path and takes a shortcut of its own, which the menu shows and which opens that effect's dialog (or applies an effect without options) (#3296); scores and estimates unchanged |
| 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; scores and estimates unchanged |