Merge pull request #1003 from Faabyy/fix/resize-snaps-dragged-edge-961

This commit is contained in:
Brandon Thomas
2026-10-23 17:09:37 -04:00
3 changed files with 107 additions and 12 deletions
+1
View File
@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 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) |
+70
View File
@@ -4072,3 +4072,73 @@ 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)
}
/// The slide box (`x`, `y`, `w`, `h`) of shape `id`, as `shape.inspect` reports it.
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}");
}
/// #961: dragging a handle past the opposite edge flips the box, and the dragged edge becomes the
/// other side of it: that side snaps, not the fixed one.
#[test]
fn resizing_past_the_opposite_edge_snaps_the_dragged_edge() {
// East handle of the box at x 100..200, dragged left past its left edge to 3, 3 pt from the
// other box's left edge at 0.
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [0.0, 300.0, 50.0, 80.0]);
drag_gesture(&mut s, (200.0, 140.0), (3.0, 140.0));
let b = box_of(&mut s, id);
assert_eq!((b["x"].clone(), b["w"].clone()), (json!(0.0), json!(100.0)), "the dragged edge snaps to 0, the fixed edge stays at 100: {b}");
// South handle of the box at y 100..180, dragged up past its top edge to 3.
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [300.0, 0.0, 80.0, 50.0]);
drag_gesture(&mut s, (150.0, 180.0), (150.0, 3.0));
let b = box_of(&mut s, id);
assert_eq!((b["y"].clone(), b["h"].clone()), (json!(0.0), json!(100.0)), "the dragged edge snaps to 0, the fixed edge stays at 100: {b}");
}
/// #961: the handles of a flipped box sit on the other side (the east handle draws on the left),
/// and the edge under the dragged handle snaps.
#[test]
fn resizing_a_flipped_box_snaps_the_dragged_edge() {
// Flipped box at x 100..200: its east handle is at x 100. Dragged to 3, 3 pt from the other box's left edge at 0.
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [0.0, 300.0, 50.0, 80.0]);
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
drag_gesture(&mut s, (100.0, 140.0), (3.0, 140.0));
let b = box_of(&mut s, id);
assert_eq!((b["x"].clone(), b["w"].clone()), (json!(0.0), json!(200.0)), "the dragged edge snaps to 0, the fixed edge stays at 200: {b}");
}
+36 -12
View File
@@ -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<Guide>) {
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<Guide>) {
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,32 +1516,49 @@ 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)
}
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
if x.rot != 0.0 {
self.tool.guides.clear();
return x;
}
let r = x.rect();
let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true);
// Only the dragged edges move, so only they look for a line to snap to. The handles sit in
// the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge
// just flipped, the dragged handle is on the other side.
let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4));
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 mut nx = x;
// Only the dragged edges move.
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);
if moves_left {
nx.x += off.x;
nx.w -= off.x;