Merge pull request #1088 from dotCooCoo/feat/key-object-needs-two

Align: a key object needs two or more selected objects
This commit is contained in:
BFlat 2026-10-11 11:34:04 +07:00 committed by GitHub
commit 050e355e6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 151 additions and 6 deletions

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,9 @@ pub struct Selection {
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
/// Key object for Align.
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
/// it when every selected object is the key or lies inside it, as when one object is left
/// selected or when the key is a group selected with only its own members.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<NodeId>,
/// The object, group or layer targeted through its target circle in the Layers panel, which
@ -118,7 +120,15 @@ impl Selection {
pub fn prune(&mut self, doc: &Document) {
self.objects.retain(|id| doc.node(*id).is_some());
self.anchors.retain(|id, _| doc.node(*id).is_some());
if self.key.is_some_and(|k| doc.node(k).is_none()) {
// The key object stays while another selected object lies outside it.
let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| {
let mut inside = BTreeSet::new();
n.walk(&mut |c| {
inside.insert(c.id);
});
self.objects.iter().any(|o| !inside.contains(o))
});
if !keep {
self.key = None;
}
if self.target.is_some_and(|t| doc.node(t).is_none()) {
@ -253,4 +263,47 @@ mod tests {
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
s.set([NodeId(1), NodeId(2)]);
s.key = Some(NodeId(2));
s.remove(NodeId(1));
assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key");
s.remove(NodeId(2));
assert_eq!(s.key, None, "the key object itself left");
// Pruning drops objects the document no longer has and clears the key once no other
// selected object lies outside it.
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for _ in 0..4 {
let id = d.alloc_id();
ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap());
}
s.set(ids.iter().copied());
s.key = Some(ids[3]);
d.remove(ids[0]).unwrap();
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays");
s.toggle(ids[1]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays");
s.toggle(ids[2]);
s.prune(&d);
assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left");
// A group key selected with only its own member is cleared too; another selected object
// outside the group keeps it.
let member = d.alloc_id();
d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap();
s.set([member, ids[3]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, None, "a group and its own member");
s.set([member, ids[3], ids[2]]);
s.key = Some(ids[3]);
s.prune(&d);
assert_eq!(s.key, Some(ids[3]), "another object outside the group");
}
}

View File

@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Key Object",
[],
None,
"{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it",
"{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)",
has_doc,
key
),

View File

@ -311,6 +311,57 @@ fn layers_and_lock_hide() {
assert!(s.doc().unwrap().doc.node(b).is_none());
}
/// A key object is one object of a selection of several: a Shift-click (`select.toggle`) or a
/// hidden or deleted row that leaves one object selected clears the key, and `select.key` sets
/// none on a single selected object. A Shift-drag marquee that leaves two or more objects selected
/// keeps the key in either order of its ids. `select.key` also sets none on a group selected with
/// only its own member, and sets it once another object is selected.
#[test]
fn a_lone_selected_object_is_not_the_key_object() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0);
let c = rect(&mut s, 200.0, 60.0, 10.0, 10.0);
let key = |s: &Session| s.doc().unwrap().selection.key;
let objects = |s: &Session| s.doc().unwrap().selection.objects.clone();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!(key(&s), Some(b));
s.execute("select.toggle", &json!({"id": a.0})).unwrap();
assert_eq!(objects(&s), vec![b]);
assert_eq!(key(&s), None, "the Shift-click left one object");
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!(key(&s), None, "one object selected");
for ids in [[a.0, c.0], [c.0, a.0]] {
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
s.execute("select.toggle", &json!({"ids": ids})).unwrap();
assert_eq!((objects(&s), key(&s)), (vec![b, c], Some(b)), "the marquee toggled {ids:?}");
}
// The Group Selection tool's second click on a member adds its group.
s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
s.execute("select.add", &json!({"ids": [g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), None, "a group and its own member");
s.execute("select.add", &json!({"ids": [a.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g), "another object outside the group");
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g));
s.execute("layer.setProps", &json!({"id": a.0, "visible": false})).unwrap();
assert_eq!((objects(&s), key(&s)), (vec![g], None), "hiding the other object's row left one object");
s.execute("layer.setProps", &json!({"id": a.0, "visible": true})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
s.execute("select.key", &json!({"id": g.0})).unwrap();
assert_eq!(key(&s), Some(g));
s.execute("layer.delete", &json!({"id": a.0})).unwrap();
assert_eq!(objects(&s), vec![g]);
assert_eq!(key(&s), None, "deleting the other object's row left one object");
}
#[test]
fn clipping_and_compound() {
let mut s = session();

View File

@ -29,7 +29,7 @@ impl AlignTo {
/// `object.align` params: a single object aligns to the artboard (Illustrator does the same).
pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value {
let mut p = base;
let to = if n_selected == 1 && to == AlignTo::Selection { AlignTo::Artboard } else { to };
let to = if n_selected == 1 { AlignTo::Artboard } else { to };
p["to"] = json!(to.param());
p
}
@ -184,7 +184,47 @@ mod tests {
assert_eq!(p["to"], "artboard");
let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3);
assert_eq!(p["to"], "selection");
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key");
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "artboard");
}
/// A Shift-click that leaves the key object selected on its own lets the key go. With Align to
/// Key Object chosen, the align buttons of the Control bar and the Properties panel, and then
/// the Align panel's, align that object to the artboard.
#[test]
fn a_lone_object_aligns_to_the_artboard() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
let ctx = egui::Context::default();
set_pstate(&ctx, "align-to", AlignTo::Key);
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("select.key", json!({"id": b})).unwrap();
app.run("select.toggle", json!({"id": a})).unwrap();
let id = vectorcraft_doc::NodeId(b.as_u64().unwrap());
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
// `align_buttons` (the Control bar and the Properties panel): one frame finds the first
// button, and the next clicks Horizontal Align Right, the third.
let at = std::cell::Cell::new(egui::Pos2::ZERO);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
ui.horizontal(|ui| {
at.set(ui.cursor().min);
align_buttons(app, ui, 24.0);
});
});
out.textures_delta.clear();
};
frame(&mut app, vec![]);
let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0);
let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]);
frame(&mut app, vec![press(false)]);
assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge");
// The Align panel's Horizontal Align Left.
let p = align_params(json!({"horizontal": "left", "bounds": "geometric"}), align_to(&app, &ctx), selection_len(&app));
app.run("object.align", p).unwrap();
assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge");
}
/// #541: while the selection has a key object Align aligns to it, whatever Align To says;

View File

@ -396,6 +396,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Tools:**
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies 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.
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself.
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).