Merge pull request #1558

Allow resizing on all selected shapes (#1539)
This commit is contained in:
Brandon Thomas
2026-11-02 04:51:19 -05:00
5 changed files with 268 additions and 11 deletions
+198
View File
@@ -5841,6 +5841,204 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value {
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
}
/// Two selected boxes with different sizes and aligned right edges, on a blank slide.
fn resize_selection_scene() -> (Session, [u64; 2]) {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.prefs.smart_guides = false;
s.prefs.snap_to_grid = false;
let ids = [[100, 100, 100, 80], [120, 300, 80, 100]]
.map(|rect| s.execute("shape.insert", &json!({"preset": "rect", "rect": rect, "text": "Resize test"})).unwrap()["id"].as_u64().unwrap());
s.execute("edit.select", &json!({"ids": ids})).unwrap();
(s, ids)
}
/// Simulate file-loaded shapes whose stored boxes have not yet been fitted to their text.
fn pending_resize_autofit(s: &mut Session, ids: &[u64]) {
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).unwrap();
for id in ids {
let sh = deckcraft_model::find_shape_mut(shapes, ShapeId(*id as u32)).unwrap();
sh.text.as_mut().unwrap().body.autofit = Some(deckcraft_model::text::AutoFit::Shape);
}
Ok(())
})
.unwrap();
}
/// Resizing multiple selected shapes scales them proportionally around their anchors in one undo step.
#[test]
fn resizing_selected_shapes_is_one_undo_step() {
let (mut s, [a, b]) = resize_selection_scene();
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
assert_eq!(down["hit"]["kind"], "resize");
assert_eq!(down["hit"]["id"], a);
for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 120.0, 137.5])] {
let actual = box_of(&mut s, id);
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
assert_eq!(actual[key], value, "{id}: {key}");
}
}
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before, "one undo restores both shapes");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again");
}
/// Resizing selected shapes refits their text boxes inside the same undo transaction.
#[test]
fn resizing_selected_shapes_refits_text_in_the_same_undo_step() {
let (mut s, ids) = resize_selection_scene();
pending_resize_autofit(&mut s, &ids);
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
// An east-handle drag changes width only; a height change must come from text fitting.
drag_gesture(&mut s, (200.0, 140.0), (250.0, 140.0));
for (id, old_height) in ids.into_iter().zip([80.0, 100.0]) {
assert_ne!(box_of(&mut s, id)["h"], old_height, "both shapes' text is refitted");
}
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit");
}
/// Clicking a resize handle without dragging neither modifies shapes nor triggers text autofit.
#[test]
fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() {
let (mut s, ids) = resize_selection_scene();
pending_resize_autofit(&mut s, &ids);
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
let revision = s.doc().unwrap().revision;
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(PointerEvent { kind, x: 200.0, y: 180.0, tol: 3.0, ..Default::default() }).unwrap();
}
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click creates no undo entry");
assert_eq!(s.doc().unwrap().revision, revision);
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "a click does not dirty the document");
// Ensure this fixture would catch an accidental refit on release.
for id in ids {
cmd::text::fit_text_box(&mut s, ShapeId(id as u32)).unwrap();
}
assert_ne!(s.doc().unwrap().doc, before);
}
/// Locked shapes in a multi-selection are excluded from both resizing and post-resize text fitting.
#[test]
fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() {
let (mut s, [a, b]) = resize_selection_scene();
pending_resize_autofit(&mut s, &[b]);
s.execute("shape.lock", &json!({"ids": [b], "locked": true})).unwrap();
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
let locked = s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().clone();
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
assert_eq!(box_of(&mut s, a)["w"], 150.0);
assert_eq!(box_of(&mut s, a)["h"], 110.0);
assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap(), &locked, "the locked shape is neither resized nor refitted");
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
}
/// Resizing selected shapes excludes other selected resize targets from smart-guide snapping.
#[test]
fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() {
for reverse in [false, true] {
let (mut s, [a, b]) = resize_selection_scene();
s.prefs.smart_guides = true;
let ids = if reverse { [b, a] } else { [a, b] };
s.execute("edit.select", &json!({"ids": ids})).unwrap();
// Within snap tolerance of the other selected shape's original right edge.
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back");
assert_eq!(box_of(&mut s, b)["w"], 82.4, "selection order must not affect snapping");
}
}
/// Resizing selected shapes snaps the active shape against unselected stationary shapes and scales others proportionally.
#[test]
fn resizing_selected_shapes_still_snaps_to_stationary_shapes() {
let (mut s, [a, b]) = resize_selection_scene();
let fixed = s.execute("shape.insert", &json!({"preset": "rect", "rect": [207, 430, 100, 80]})).unwrap()["id"].as_u64().unwrap();
let fixed_before = box_of(&mut s, fixed);
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
s.prefs.smart_guides = true;
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
assert_eq!(box_of(&mut s, a)["w"], 107.0);
assert!((box_of(&mut s, b)["w"].as_f64().unwrap() - 85.6).abs() < 1e-9);
assert_eq!(box_of(&mut s, fixed), fixed_before);
}
/// Secondary pictures resize uniformly around their anchor, preserving independent flips
/// and keeping the complete multi-selection resize in one undo step.
#[test]
fn resizing_selected_shapes_resizes_secondary_pictures() {
// Pointer destination, Alt, expected rectangle, expected picture, expected flips.
let cases = [
((180.0, 200.0), false, [100.0, 100.0, 80.0, 100.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
((200.0, 180.0), false, [100.0, 100.0, 100.0, 80.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
((220.0, 200.0), false, [100.0, 100.0, 120.0, 100.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
((200.0, 220.0), false, [100.0, 100.0, 100.0, 120.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
((180.0, 200.0), true, [120.0, 100.0, 60.0, 100.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
((200.0, 180.0), true, [100.0, 120.0, 100.0, 60.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
((80.0, 200.0), false, [80.0, 100.0, 20.0, 100.0], [280.0, 300.0, 20.0, 20.0], [true, false]),
((200.0, 80.0), false, [100.0, 80.0, 100.0, 20.0], [300.0, 280.0, 20.0, 20.0], [false, true]),
];
for (to, alt, expected_rect, expected_picture, flips) in cases {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.prefs.smart_guides = false;
s.prefs.snap_to_grid = false;
let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
let picture = ramp_picture(&mut s, 1, [300, 300, 100, 100]).as_u64().unwrap();
s.execute("edit.select", &json!({"ids": [rect, picture]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
let mods = Mods { alt, ..Default::default() };
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods, tol: 3.0, pressure: None };
let down = s.pointer(ev(PointerKind::Down, (200.0, 200.0))).unwrap();
assert_eq!(down["hit"]["kind"], "resize");
assert_eq!(down["hit"]["id"], rect);
assert_eq!(down["hit"]["handle"], 4);
s.pointer(ev(PointerKind::Drag, ((200.0 + to.0) / 2.0, (200.0 + to.1) / 2.0))).unwrap();
s.pointer(ev(PointerKind::Drag, to)).unwrap();
s.pointer(ev(PointerKind::Up, to)).unwrap();
for (id, expected) in [(rect, expected_rect), (picture, expected_picture)] {
let actual = box_of(&mut s, id);
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
let got = actual[key].as_f64().unwrap();
assert!((got - value).abs() < 1e-9, "{to:?}, alt={alt}, shape={id}, {key}: expected {value}, got {got}");
}
let xfrm = s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().xfrm.unwrap();
assert_eq!([xfrm.flip_h, xfrm.flip_v], flips, "{to:?}, alt={alt}, shape={id}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
}
}
/// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
#[test]
+3 -1
View File
@@ -150,8 +150,10 @@ enum Drag {
},
Resize {
id: ShapeId,
handle: u8,
orig: Xfrm,
ids: Vec<ShapeId>,
handle: u8,
origs: Vec<(ShapeId, Xfrm)>,
start: Point,
},
LineEnd {
+43 -4
View File
@@ -13,6 +13,7 @@ struct MoveDrag {
}
impl Session {
/// Update the active drag interaction using the pointer position and modifiers.
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag {
@@ -26,10 +27,48 @@ impl Session {
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
}
Drag::Resize { id, handle, orig, start } => {
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
let nx = self.snap_resize(nx, &orig, handle, id, tol);
self.set_xfrm(id, nx)?;
Drag::Resize { id, orig, ids, handle, origs, start } => {
let keep_aspect = mods.shift || self.is_picture(id);
let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
let nx = self.snap_resize(nx, handle, &ids, tol);
let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
let div = if mods.alt { 2.0 } else { 1.0 };
let hx = match handle {
0 | 6 | 7 => -1.0,
2..=4 => 1.0,
_ => 0.0,
};
let hy = match handle {
0..=2 => -1.0,
4..=6 => 1.0,
_ => 0.0,
};
for (tid, o) in &origs {
let target_nx = if *tid == id {
nx
} else {
let picture = self.is_picture(*tid);
let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) {
// Use the axis with the larger relative change so an unchanged
// dimension cannot cancel shrinking. Preserve each axis's flip.
let scale_x = 1.0 + sx;
let scale_y = 1.0 + sy;
let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() };
(scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0)
} else {
(sx, sy)
};
let lp = Affine::rotate(o.rot.to_radians())
* Point::new(
hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 },
hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 },
);
resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt)
};
self.set_xfrm(*tid, target_nx)?;
}
}
Drag::LineEnd { id, end, orig } => {
let a = orig.affine();
+2 -2
View File
@@ -228,7 +228,7 @@ impl Session {
}
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm {
if x.rot != 0.0 {
self.tool.guides.clear();
return x;
@@ -241,7 +241,7 @@ impl Session {
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let mut nx = x;
if moves_left {
nx.x += off.x;
+22 -4
View File
@@ -220,6 +220,7 @@ impl Session {
}
}
/// Handle a pointer press and start the applicable tool interaction.
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
// Next click of a freeform: close on the start point, else add a vertex.
@@ -303,8 +304,12 @@ impl Session {
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
Some(Hit::Resize { id, handle }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
let origs: Vec<(ShapeId, Xfrm)> =
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
let ids: Vec<ShapeId> = origs.iter().map(|(id, _)| *id).collect();
self.begin("Size");
self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p });
}
Some(Hit::LineEnd { id, end }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
@@ -436,6 +441,7 @@ impl Session {
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
}
/// Handle a pointer release and finalize the active tool interaction.
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
let drag = self.tool.drag.take();
self.tool.guides.clear();
@@ -558,10 +564,22 @@ impl Session {
out = self.add_ink(points)?;
}
}
Some(Drag::Resize { id, .. }) => {
Some(Drag::Resize { origs, .. }) => {
let changed: Vec<ShapeId> = origs
.iter()
.filter_map(|(id, orig)| {
let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?;
(current != *orig).then_some(*id)
})
.collect();
if !changed.is_empty() {
for id in changed {
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
}
self.commit();
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
Some(Drag::TextSelect { before, .. }) => {
out = self.paint_if_selection_changed(before, out)?;