From 92df608e540bfc61f0dff6b7458508a1270e6d86 Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:54:54 +0000 Subject: [PATCH] Canvas: resizing snaps the edge being dragged (#961) snap_resize ran snap_rect on the whole box, which looks at all three edges on each axis and takes the nearest line. A fixed edge already on a line (distance 0) always won, so the dragged edge never snapped; a fixed edge near a line moved the dragged edge by its own distance. Resizing now looks only at the dragged edges (snap_edges), also for the grid fallback. Co-Authored-By: Claude --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 39 ++++++++++++++++++++++++++++--------- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 933bc7d1..63219682 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd64b08c..604dac94 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4072,3 +4072,43 @@ fn a_shape_in_front_of_edited_text_takes_the_click() { let st = s.doc().unwrap(); assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); } + +/// A box at `rect` and a second box at `other` on a fresh slide; smart guides on, the grid off. +/// The first box is selected. +fn snap_scene(rect: [f64; 4], other: [f64; 4]) -> (Session, u64) { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.execute("shape.insert", &json!({"preset": "rect", "rect": other})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.prefs.smart_guides = true; + (s, id) +} + +fn box_of(s: &mut Session, id: u64) -> serde_json::Value { + s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone() +} + +/// #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] +fn resizing_snaps_the_dragged_edge_when_the_fixed_edge_is_already_aligned() { + // The box spans x 100..200; the other box starts at 100 too and ends at 300. + let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [100.0, 300.0, 200.0, 80.0]); + drag_gesture(&mut s, (200.0, 140.0), (297.0, 140.0)); + let b = box_of(&mut s, id); + assert_eq!(b["x"], 100.0, "the fixed edge stays: {b}"); + assert_eq!(b["w"], 200.0, "the right edge snaps to the other box's right edge at 300: {b}"); +} + +/// #961: only the dragged edge looks for a line. A fixed edge near a line must not shift the +/// edge being dragged by its own distance. +#[test] +fn resizing_is_not_shifted_by_a_fixed_edge_near_a_line() { + // The other box starts at 103, 3 pt from the fixed left edge; the dragged edge ends far from any line. + let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [103.0, 300.0, 50.0, 80.0]); + drag_gesture(&mut s, (200.0, 140.0), (421.0, 140.0)); + let b = box_of(&mut s, id); + assert_eq!(b["x"], 100.0, "the fixed edge stays: {b}"); + assert_eq!(b["w"], 321.0, "the dragged edge goes where the pointer went: {b}"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a7190b73..267eb7e5 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1449,6 +1449,13 @@ impl Session { /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to /// apply and the guides to draw. fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { + self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3]) + } + + /// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on` + /// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that + /// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped. + fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (Vec2, Vec) { let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) }; if !enabled || !self.prefs.smart_guides { return (Vec2::ZERO, vec![]); @@ -1486,7 +1493,7 @@ impl Session { } } let mut best_x: Option<(f64, f64, Rect)> = None; - for edge in [r.x0, r.center().x, r.x1] { + for edge in [r.x0, r.center().x, r.x1].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) { for (x, b) in &xs { let d = x - edge; if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { @@ -1495,7 +1502,7 @@ impl Session { } } let mut best_y: Option<(f64, f64, Rect)> = None; - for edge in [r.y0, r.center().y, r.y1] { + for edge in [r.y0, r.center().y, r.y1].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) { for (y, b) in &ys { let d = y - edge; if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { @@ -1509,15 +1516,29 @@ impl Session { off.x = d; let rr = r + Vec2::new(d, 0.0); guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) }); - } else if self.prefs.snap_to_grid { - off.x = deckcraft_geom::snap(r.x0, self.prefs.grid_spacing) - r.x0; + } else if self.prefs.snap_to_grid && xs_on.contains(&true) { + let at = if xs_on[0] { + r.x0 + } else if xs_on[2] { + r.x1 + } else { + r.center().x + }; + off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; } if let Some((d, y, b)) = best_y { off.y = d; let rr = r + Vec2::new(0.0, d); guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) }); - } else if self.prefs.snap_to_grid { - off.y = deckcraft_geom::snap(r.y0, self.prefs.grid_spacing) - r.y0; + } else if self.prefs.snap_to_grid && ys_on.contains(&true) { + let at = if ys_on[0] { + r.y0 + } else if ys_on[2] { + r.y1 + } else { + r.center().y + }; + off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; } (off, guides) } @@ -1528,13 +1549,13 @@ impl Session { return x; } let r = x.rect(); - let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true); - let mut nx = x; - // Only the dragged edges move. + // Only the dragged edges move, so only they look for a line to snap to. let moves_left = matches!(handle, 0 | 6 | 7); let moves_right = matches!(handle, 2..=4); let moves_top = matches!(handle, 0..=2); let moves_bottom = matches!(handle, 4..=6); + let (off, guides) = self.snap_edges(r, &[id], 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; nx.w -= off.x;