From 4c1916466b0aad6758c2889193adae8a7316116b Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:19:44 +0000 Subject: [PATCH 1/9] Allow resizing on all selected shapes --- crates/engine/src/tools.rs | 4 ++-- crates/engine/src/tools/drag.rs | 12 ++++++++---- crates/engine/src/tools/pointer.rs | 13 ++++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 78c6ed35..19440fb3 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -149,9 +149,9 @@ enum Drag { enter_text: Option<(ShapeId, Point)>, }, Resize { - id: ShapeId, + ids: Vec, handle: u8, - orig: Xfrm, + origs: Vec<(ShapeId, Xfrm)>, start: Point, }, LineEnd { diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 21aac4f3..5660a09a 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -26,10 +26,14 @@ 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 { ids, handle, origs, start } => { + if ids.len() >= 1 { + for (id, o) in &origs { + let nx = resize_box(&o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); + let nx = self.snap_resize(nx, &o, handle, *id, tol); + self.set_xfrm(*id, nx)?; + } + } } Drag::LineEnd { id, end, orig } => { let a = orig.affine(); diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index bee8f320..0a399970 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -302,9 +302,10 @@ impl Session { Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, 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 = 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).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); self.begin("Size"); - self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p }); + self.tool.drag = Some(Drag::Resize { 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(); @@ -559,10 +560,12 @@ impl Session { out = self.add_ink(points)?; } } - Some(Drag::Resize { id, .. }) => { + Some(Drag::Resize { ids, .. }) => { self.commit(); - let _ = cmd::text::fit_text_box(self, id); - self.refit_after_resize(id)?; + for id in ids { + 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)?; From 32ef926c46d3b6312aaecfcc0870e14bb1e49c0e Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:46:06 +0000 Subject: [PATCH 2/9] fix: address CodeRabbit review findings --- crates/engine/src/tools/pointer.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index 0a399970..50d11c39 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -303,7 +303,8 @@ impl Session { Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let ids: Vec = 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).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + 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 = origs.iter().map(|(id, _)| *id).collect(); self.begin("Size"); self.tool.drag = Some(Drag::Resize { ids, handle, origs, start: p }); } @@ -560,12 +561,24 @@ impl Session { out = self.add_ink(points)?; } } - Some(Drag::Resize { ids, .. }) => { - self.commit(); - for id in ids { - let _ = cmd::text::fit_text_box(self, id); - self.refit_after_resize(id)?; + Some(Drag::Resize { origs, .. }) => { + let changed: Vec = 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(); } Some(Drag::TextSelect { before, .. }) => { out = self.paint_if_selection_changed(before, out)?; From 82adf7326e1e6be4770499e2bef3495091d19ee2 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:13:44 +0000 Subject: [PATCH 3/9] fix: address raised issues --- crates/engine/src/tests.rs | 132 ++++++++++++++++++++++++++++ crates/engine/src/tools/drag.rs | 10 +-- crates/engine/src/tools/geometry.rs | 4 +- crates/engine/src/tools/pointer.rs | 11 ++- 4 files changed, 143 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4dfc21f4..b72dcbd7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5479,6 +5479,138 @@ 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(); +} + +#[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, 130.0, 130.0])] { + 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"); +} + +#[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"); +} + +#[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); +} + +#[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); +} + +#[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"], 83.0, "selection order must not affect snapping"); + } +} + +#[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_eq!(box_of(&mut s, b)["w"], 87.0); + assert_eq!(box_of(&mut s, fixed), fixed_before); +} + /// #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] diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 5660a09a..7dca3822 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -27,12 +27,10 @@ impl Session { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { ids, handle, origs, start } => { - if ids.len() >= 1 { - for (id, o) in &origs { - let nx = resize_box(&o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); - let nx = self.snap_resize(nx, &o, handle, *id, tol); - self.set_xfrm(*id, nx)?; - } + for (id, o) in &origs { + let nx = resize_box(o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); + let nx = self.snap_resize(nx, handle, &ids, tol); + self.set_xfrm(*id, nx)?; } } Drag::LineEnd { id, end, orig } => { diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs index 536a4ead..cbbd0127 100644 --- a/crates/engine/src/tools/geometry.rs +++ b/crates/engine/src/tools/geometry.rs @@ -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; diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index 50d11c39..dbaf495f 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -303,7 +303,8 @@ impl Session { Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let ids: Vec = 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 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 = origs.iter().map(|(id, _)| *id).collect(); self.begin("Size"); self.tool.drag = Some(Drag::Resize { ids, handle, origs, start: p }); @@ -562,12 +563,10 @@ impl Session { } } Some(Drag::Resize { origs, .. }) => { - let changed: Vec = origs.iter() + let changed: Vec = 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) - }))?; + let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?; (current != *orig).then_some(*id) }) From e812da58df5b2d1c268b6674c6b10dbee67ce8ce Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:51:33 +0000 Subject: [PATCH 4/9] fix: addressing issues --- crates/engine/src/tests.rs | 12 ++++++++--- crates/engine/src/tools.rs | 2 ++ crates/engine/src/tools/drag.rs | 33 +++++++++++++++++++++++++----- crates/engine/src/tools/pointer.rs | 3 ++- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b72dcbd7..ef18a31f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5504,6 +5504,7 @@ fn pending_resize_autofit(s: &mut Session, ids: &[u64]) { .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(); @@ -5512,7 +5513,7 @@ fn resizing_selected_shapes_is_one_undo_step() { 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, 130.0, 130.0])] { + 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}"); @@ -5526,6 +5527,7 @@ fn resizing_selected_shapes_is_one_undo_step() { 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(); @@ -5545,6 +5547,7 @@ fn resizing_selected_shapes_refits_text_in_the_same_undo_step() { 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(); @@ -5566,6 +5569,7 @@ fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() { 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(); @@ -5584,6 +5588,7 @@ fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() { 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] { @@ -5594,10 +5599,11 @@ fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() { // 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"], 83.0, "selection order must not affect snapping"); + 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(); @@ -5607,7 +5613,7 @@ fn resizing_selected_shapes_still_snaps_to_stationary_shapes() { 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_eq!(box_of(&mut s, b)["w"], 87.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); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 19440fb3..82f53dfc 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -149,6 +149,8 @@ enum Drag { enter_text: Option<(ShapeId, Point)>, }, Resize { + id: ShapeId, + orig: Xfrm, ids: Vec, handle: u8, origs: Vec<(ShapeId, Xfrm)>, diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 7dca3822..367e6b85 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -26,11 +26,34 @@ 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 { ids, handle, origs, start } => { - for (id, o) in &origs { - let nx = resize_box(o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); - let nx = self.snap_resize(nx, handle, &ids, tol); - self.set_xfrm(*id, nx)?; + Drag::Resize { id, orig, ids, handle, origs, start } => { + let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), 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 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), mods.shift || self.is_picture(*tid), mods.alt) + }; + self.set_xfrm(*tid, target_nx)?; } } Drag::LineEnd { id, end, orig } => { diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index dbaf495f..1f4cba0c 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -302,12 +302,13 @@ impl Session { Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, 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 = 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 = origs.iter().map(|(id, _)| *id).collect(); self.begin("Size"); - self.tool.drag = Some(Drag::Resize { ids, handle, origs, 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(); From 09bbfc72176af42ae586795367caec9dd5e8db77 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:25:01 +0000 Subject: [PATCH 5/9] fix: addressing issues raised by coderabbit --- crates/engine/src/tools/drag.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 367e6b85..24f0ae97 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -27,7 +27,11 @@ impl Session { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { id, orig, ids, handle, origs, start } => { - let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); + if orig.w <= 0.0 || orig.h <= 0.0 { + return Ok(Value::Null); + } + 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 }; @@ -51,7 +55,7 @@ impl Session { 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), mods.shift || self.is_picture(*tid), mods.alt) + resize_box(o, handle, Vec2::new(lp.x, lp.y), keep_aspect, mods.alt) }; self.set_xfrm(*tid, target_nx)?; } From 77e3a444b816eaea93dc2b608cf46043e243d902 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:21:06 +0000 Subject: [PATCH 6/9] Update crates/engine/src/tools/drag.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- crates/engine/src/tools/drag.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 24f0ae97..a25e4785 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -55,7 +55,7 @@ impl Session { 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), keep_aspect, mods.alt) + resize_box(o, handle, Vec2::new(lp.x, lp.y), self.is_picture(*tid), mods.alt) }; self.set_xfrm(*tid, target_nx)?; } From 9aef8cc5ab9d9b2d82198ffb632ea9fc0a8a90be Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:03:53 +0000 Subject: [PATCH 7/9] fix: addressing issues raised by coderabbit --- crates/engine/src/tools/drag.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 24f0ae97..d65ae690 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -27,9 +27,6 @@ impl Session { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { id, orig, ids, handle, origs, start } => { - if orig.w <= 0.0 || orig.h <= 0.0 { - return Ok(Value::Null); - } 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); From 84ac51a43953a11b5b1c3327b8a6c32e4d4e7951 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:04:09 +0000 Subject: [PATCH 8/9] fix: addressing more issues --- crates/engine/src/tests.rs | 60 +++++++++++++++++++++++++++++++++ crates/engine/src/tools/drag.rs | 14 +++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef18a31f..39c04610 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5617,6 +5617,66 @@ fn resizing_selected_shapes_still_snaps_to_stationary_shapes() { 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] diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 9b672ffc..11392c13 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -47,12 +47,24 @@ impl Session { 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), self.is_picture(*tid), mods.alt) + resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt) }; self.set_xfrm(*tid, target_nx)?; } From c5ac1dd44bb4bfe19777190fc98bd589d1fb26e2 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:52:55 +0000 Subject: [PATCH 9/9] docs: add missing docstrings --- crates/engine/src/tools/drag.rs | 1 + crates/engine/src/tools/pointer.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 11392c13..dcee9cf7 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -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 { let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index 1f4cba0c..d049dcf3 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -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 { 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. @@ -440,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 { let drag = self.tool.drag.take(); self.tool.guides.clear();