M2.8: snapping and smart guides (page, margins, columns, guides, objects)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 10:52:49 -04:00
co-authored by Claude Opus 5.5
parent 362c16e050
commit fe54a974c5
5 changed files with 151 additions and 10 deletions
+8 -3
View File
@@ -60,11 +60,16 @@ fn tool_gesture_creates_one_undo_step() {
s.set_tool("selection");
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 70.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 80.0, 40.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 40.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 40.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 40.0), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 2);
assert_eq!(st.doc.spreads[0].items[0].bounds().x0, 40.0);
assert_eq!(st.doc.spreads[0].items[0].bounds().x0, 50.0);
// A move that ends near the margin snaps to it.
s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 48.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 48.0, 30.0), v).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].items[0].bounds().x0, 36.0);
}
#[test]
+19 -3
View File
@@ -11,12 +11,13 @@ pub struct FrameTool {
start: Option<Point>,
cur: Point,
active: bool,
guides: Vec<crate::Overlay>,
}
impl FrameTool {
pub fn new(id: &str) -> Self {
let id = crate::tool_info(id).map(|t| t.id).unwrap_or("rectangleFrame");
Self { id, start: None, cur: Point::ZERO, active: false }
Self { id, start: None, cur: Point::ZERO, active: false, guides: vec![] }
}
fn kind(&self) -> (&'static str, &'static str) {
match self.id {
@@ -54,7 +55,11 @@ impl Tool for FrameTool {
let (shape, content) = self.kind();
match ev.kind {
PointerKind::Down => {
self.start = Some(ev.pos);
let pos = match cx.layout.spread_at(ev.pos) {
Some((sr, sp)) if cx.snap => crate::snap::snap_point(cx, sr, sp).0 + cx.layout.offset(sr),
_ => ev.pos,
};
self.start = Some(pos);
self.cur = ev.pos;
self.active = false;
vec![]
@@ -67,6 +72,14 @@ impl Tool for FrameTool {
}
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
let off = cx.layout.offset(sr);
let mut ev = *ev;
self.guides.clear();
if cx.snap && !ev.mods.shift {
let (p, g) = crate::snap::snap_point(cx, sr, ev.pos - off);
ev.pos = p + off;
self.guides = g;
}
let ev = &ev;
let mut out = vec![];
if !self.active {
self.active = true;
@@ -93,6 +106,7 @@ impl Tool for FrameTool {
out
}
PointerKind::Up => {
self.guides.clear();
let was = self.active;
self.active = false;
let start = self.start.take();
@@ -118,7 +132,9 @@ impl Tool for FrameTool {
if let (true, Some(a)) = (self.active, self.start) {
let r = Rect::from_points(a, self.cur);
let _ = cx;
return vec![Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }];
let mut v = self.guides.clone();
v.push(Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) });
return v;
}
vec![]
}
+1
View File
@@ -14,6 +14,7 @@ pub mod layout;
mod nav;
mod pen;
pub mod select;
pub mod snap;
mod text;
use designcraft_compose::Cache;
+16 -4
View File
@@ -14,7 +14,7 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
enum Drag {
None,
Pending { start: Point, hit: bool },
Move { start: Point, origin_spread: SpreadRef },
Move { start: Point, origin_spread: SpreadRef, bounds0: Option<Rect> },
Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef },
Marquee { start: Point, cur: Point },
Anchor { id: u64, si: usize, ai: usize, handle: Option<&'static str>, start: Point },
@@ -47,11 +47,12 @@ pub struct SelectionTool {
direct: bool,
drag: Drag,
hover_handle: Option<usize>,
guides: Vec<Overlay>,
}
impl SelectionTool {
pub fn new(direct: bool) -> Self {
Self { direct, drag: Drag::None, hover_handle: None }
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![] }
}
}
@@ -201,7 +202,7 @@ impl Tool for SelectionTool {
}
if hit && !cx.selection.items.is_empty() {
let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
self.drag = Drag::Move { start, origin_spread: sr };
self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_bounds() };
let label = if ev.mods.alt { "Duplicate" } else { "Move" };
let mut v = vec![Action::Begin(label.into())];
v.extend(self.pointer(cx, ev));
@@ -211,7 +212,7 @@ impl Tool for SelectionTool {
vec![]
}
}
Drag::Move { start, origin_spread } => {
Drag::Move { start, origin_spread, bounds0 } => {
let mut d: Vec2 = p - start;
if ev.mods.shift {
if d.x.abs() > d.y.abs() {
@@ -220,6 +221,15 @@ impl Tool for SelectionTool {
d.x = 0.0;
}
}
self.guides.clear();
if cx.snap
&& let Some(b0) = bounds0
{
let off = cx.layout.offset(origin_spread);
let snap = crate::snap::snap_rect(cx, origin_spread, b0 + d - off, &cx.selection.items);
d += snap.delta;
self.guides = snap.guides;
}
// Dragging to another spread moves the items there.
let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread);
let mut params = json!({"dx": d.x, "dy": d.y, "copy": ev.mods.alt});
@@ -250,6 +260,7 @@ impl Tool for SelectionTool {
Drag::None => vec![],
},
PointerKind::Up => {
self.guides.clear();
let d = std::mem::replace(&mut self.drag, Drag::None);
match d {
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } => vec![Action::Commit],
@@ -308,6 +319,7 @@ impl Tool for SelectionTool {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match &self.drag {
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
Drag::Move { .. } => self.guides.clone(),
_ => vec![],
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Snapping and smart guides: page edges and centres, margins, column guides, ruler guides and
//! other objects' edges/centres on the same spread. Returns the snap correction and the guide
//! lines to draw (canvas coordinates).
use designcraft_doc::{ItemId, Orientation, SpreadRef};
use designcraft_geom::{Point, Rect, Vec2};
use crate::{Overlay, ToolContext};
#[derive(Clone, Debug, Default)]
pub struct Snap {
pub delta: Vec2,
pub guides: Vec<Overlay>,
}
#[derive(Clone, Copy)]
struct Target {
v: f64,
/// Extent of the target along the other axis (for drawing the guide).
lo: f64,
hi: f64,
smart: bool,
}
fn targets(cx: &ToolContext, sr: SpreadRef, exclude: &[ItemId]) -> (Vec<Target>, Vec<Target>) {
let mut xs = Vec::new();
let mut ys = Vec::new();
let Some(sp) = cx.doc.spread(sr) else { return (xs, ys) };
let push = |v: &mut Vec<Target>, val: f64, lo: f64, hi: f64, smart: bool| v.push(Target { v: val, lo, hi, smart });
for p in &sp.pages {
let b = p.bounds();
let m = p.margin_rect();
for x in [b.x0, b.center().x, b.x1, m.x0, m.x1] {
push(&mut xs, x, b.y0, b.y1, false);
}
for y in [b.y0, b.center().y, b.y1, m.y0, m.y1] {
push(&mut ys, y, b.x0, b.x1, false);
}
for c in p.column_rects() {
push(&mut xs, c.x0, b.y0, b.y1, false);
push(&mut xs, c.x1, b.y0, b.y1, false);
}
for g in &p.guides {
match g.orientation {
Orientation::Vertical => push(&mut xs, g.position, b.y0, b.y1, false),
Orientation::Horizontal => push(&mut ys, g.position, b.x0, b.x1, false),
}
}
}
for it in &sp.items {
if exclude.contains(&it.id) || it.hidden {
continue;
}
let b = it.bounds();
for x in [b.x0, b.center().x, b.x1] {
push(&mut xs, x, b.y0, b.y1, true);
}
for y in [b.y0, b.center().y, b.y1] {
push(&mut ys, y, b.x0, b.x1, true);
}
}
(xs, ys)
}
fn best(cands: &[f64], targets: &[Target], tol: f64) -> Option<(f64, Target)> {
let mut out: Option<(f64, Target)> = None;
for &c in cands {
for t in targets {
let d = t.v - c;
if d.abs() <= tol && out.is_none_or(|(od, _)| d.abs() < od.abs()) {
out = Some((d, *t));
}
}
}
out
}
/// Snap a moving rect (spread coords). Edges and centre snap independently per axis.
pub fn snap_rect(cx: &ToolContext, sr: SpreadRef, r: Rect, exclude: &[ItemId]) -> Snap {
let tol = cx.tol(5.0);
let (xs, ys) = targets(cx, sr, exclude);
let mut s = Snap::default();
let off = cx.layout.offset(sr);
if let Some((d, t)) = best(&[r.x0, r.center().x, r.x1], &xs, tol) {
s.delta.x = d;
let lo = t.lo.min(r.y0) - 6.0;
let hi = t.hi.max(r.y1) + 6.0;
s.guides.push(guide(Point::new(t.v, lo) + off, Point::new(t.v, hi) + off, t.smart));
}
if let Some((d, t)) = best(&[r.y0, r.center().y, r.y1], &ys, tol) {
s.delta.y = d;
let lo = t.lo.min(r.x0) - 6.0;
let hi = t.hi.max(r.x1) + 6.0;
s.guides.push(guide(Point::new(lo, t.v) + off, Point::new(hi, t.v) + off, t.smart));
}
s
}
/// Snap a single point (drawing tools).
pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec<Overlay>) {
let s = snap_rect(cx, sr, Rect::from_points(p, p), &[]);
(p + s.delta, s.guides)
}
fn guide(a: Point, b: Point, smart: bool) -> Overlay {
if smart { Overlay::Guide { a, b } } else { Overlay::Line { a, b, color: [255, 0, 255], dashed: false } }
}