Direct Selection: a marquee selects area type's frame anchors

Squashed from #4435 onto main.
This commit is contained in:
dotCooCoo
2026-10-30 00:15:56 +07:00
committed by bflatastic
parent dc9106939e
commit d5cbeb6cc7
6 changed files with 494 additions and 53 deletions
+37 -14
View File
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Type Area",
[],
None,
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)",
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step",
has_doc,
reshape_area
),
@@ -1063,22 +1063,45 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.reshapeArea";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
if refs.is_empty() {
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
// One area type (`id`, `anchors`), or several (`items`: [{id, anchors}…]).
let raw: Vec<&Value> = match p.get("items") {
Some(items) => {
if p.get("id").is_some() || p.get("anchors").is_some() {
return Err(bad(C, "give `id` and `anchors`, or `items`, not both"));
}
let items = items.as_array().ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?;
if items.is_empty() {
return Err(bad(C, "`items` is empty"));
}
items.iter().collect()
}
None => vec![p],
};
let mut items = Vec::with_capacity(raw.len());
for it in raw {
let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(it.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
if refs.is_empty() {
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
}
if items.iter().any(|(other, _)| *other == id) {
return Err(bad(C, format!("area type {} is named twice", id.0)));
}
let node = s.doc()?.doc.node(id);
if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) {
return Err(bad(C, "not area type (text in a frame) outside perspective"));
}
items.push((id, refs));
}
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
let node = s.doc()?.doc.node(id);
if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) {
return Err(bad(C, "not area type (text in a frame) outside perspective"));
}
s.edit("Reshape Type Area", |doc, _| {
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) {
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
for (id, refs) in &items {
let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) };
if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) {
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
}
}
Ok(())
})?;
+82 -1
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::{NodeKind, TextKind, TextObject};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_text::TextLayout;
use vectorcraft_tools::bbox::Handle;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use crate::*;
@@ -194,6 +194,87 @@ fn direct_selection_drags_a_frame_corner_or_edge() {
assert_eq!(text(&s, id).runs[0].style.size, 12.0);
}
/// Direct Selection's marquee selects the frame anchors of area type inside it: a drag of one moves
/// only those, the arrow keys nudge them, and Shift-click takes one out.
#[test]
fn direct_selection_marquee_selects_frame_anchors() {
let mut s = session();
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
let other = area(&mut s, 300.0, 40.0, 120.0, 40.0, STORY);
s.execute("select.none", &json!({})).unwrap();
let v = ViewInfo::default();
let shift = Mods { shift: true, ..Mods::default() };
// A marquee round the right-hand corners, (160, 40) and (160, 80).
drag(&mut s, "directSelection", Point::new(150.0, 20.0), Point::new(180.0, 100.0));
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
let n = undo_len(&s);
drag(&mut s, "directSelection", Point::new(160.0, 80.0), Point::new(180.0, 100.0));
assert_eq!(undo_len(&s), n + 1, "one step");
assert_eq!(last_step(&s), "Reshape Type Area");
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(140.0, 20.0), Point::new(140.0, 60.0), Point::new(0.0, 40.0)]);
// The arrow keys nudge them, each press one step, and Shift-click takes (179, 60) out.
assert!(s.tool_claims_key(ToolKey::Left, v));
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
assert_eq!(undo_len(&s), n + 2);
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(139.0, 20.0), Point::new(139.0, 60.0), Point::new(0.0, 40.0)]);
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 179.0, 60.0).with_mods(shift), v).unwrap();
}
s.tool_key(ToolKey::Down, shift, v).unwrap();
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(139.0, 20.0), Point::new(139.0, 70.0), Point::new(0.0, 40.0)]);
// A marquee across both frames' facing corners moves both frames in one step.
drag(&mut s, "directSelection", Point::new(170.0, 10.0), Point::new(310.0, 65.0));
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
let n = undo_len(&s);
drag(&mut s, "directSelection", Point::new(300.0, 40.0), Point::new(300.0, 30.0));
assert_eq!(undo_len(&s), n + 1, "one step");
assert_eq!(corners(&s, id)[1], Point::new(139.0, 10.0));
assert_eq!(corners(&s, other), vec![Point::new(0.0, -10.0), Point::new(120.0, 0.0), Point::new(120.0, 40.0), Point::new(0.0, 40.0)]);
// Shift-click on the other frame's only selected corner deselects that type; the corner of the
// first frame still nudges.
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 300.0, 30.0).with_mods(shift), v).unwrap();
}
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
assert_eq!(corners(&s, id)[1], Point::new(139.0, 9.0));
// Deselect, then the same two selected again: they are selected whole, so the arrow keys move
// them as objects.
s.execute("select.none", &json!({})).unwrap();
sel(&mut s, &[id, other]);
assert!(!s.tool_claims_key(ToolKey::Left, v));
// A click on empty canvas deselects; the arrow keys are then no longer the tool's.
drag(&mut s, "directSelection", Point::new(600.0, 500.0), Point::new(600.0, 500.0));
assert!(!s.tool_claims_key(ToolKey::Left, v));
}
/// `text.reshapeArea` with `items` moves several frames' anchors in one step, and fails whole.
#[test]
fn reshape_area_moves_the_anchors_of_several_frames() {
let mut s = session();
let a = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
let b = area(&mut s, 300.0, 40.0, 120.0, 40.0, STORY);
let n = undo_len(&s);
let items = json!([{"id": a.0, "anchors": [[0, 2]]}, {"id": b.0, "anchors": [[0, 0], [0, 3]]}]);
s.execute("text.reshapeArea", &json!({"items": items, "dx": -10, "dy": 5})).unwrap();
assert_eq!(undo_len(&s), n + 1, "one step");
assert_eq!(corners(&s, a)[2], Point::new(110.0, 45.0));
assert_eq!(corners(&s, b), vec![Point::new(-10.0, 5.0), Point::new(120.0, 0.0), Point::new(120.0, 40.0), Point::new(-10.0, 45.0)]);
for bad in [
json!({"items": [], "dx": 1, "dy": 1}),
json!({"items": {"id": a.0}, "dx": 1, "dy": 1}),
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}], "id": a.0, "dx": 1, "dy": 1}),
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"id": a.0, "anchors": [[0, 1]]}], "dx": 1, "dy": 1}),
// A bad item fails the whole step: the good one does not move either.
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"id": b.0, "anchors": [[0, 9]]}], "dx": 1, "dy": 1}),
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"anchors": [[0, 1]]}], "dx": 1, "dy": 1}),
] {
assert!(s.execute("text.reshapeArea", &bad).is_err(), "{bad}");
}
assert_eq!(undo_len(&s), n + 1, "nothing more changed");
assert_eq!(corners(&s, a)[2], Point::new(110.0, 45.0));
}
#[test]
fn a_resized_frame_reflows_its_thread() {
let mut s = session();