mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
362c16e050
commit
fe54a974c5
@@ -60,11 +60,16 @@ fn tool_gesture_creates_one_undo_step() {
|
|||||||
s.set_tool("selection");
|
s.set_tool("selection");
|
||||||
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 30.0), v).unwrap();
|
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, 70.0, 30.0), v).unwrap();
|
||||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 80.0, 40.0), v).unwrap();
|
s.pointer(&PointerEvent::new(PointerKind::Drag, 90.0, 40.0), v).unwrap();
|
||||||
s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 40.0), v).unwrap();
|
s.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 40.0), v).unwrap();
|
||||||
let st = s.doc().unwrap();
|
let st = s.doc().unwrap();
|
||||||
assert_eq!(st.history.undo.len(), 2);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ pub struct FrameTool {
|
|||||||
start: Option<Point>,
|
start: Option<Point>,
|
||||||
cur: Point,
|
cur: Point,
|
||||||
active: bool,
|
active: bool,
|
||||||
|
guides: Vec<crate::Overlay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FrameTool {
|
impl FrameTool {
|
||||||
pub fn new(id: &str) -> Self {
|
pub fn new(id: &str) -> Self {
|
||||||
let id = crate::tool_info(id).map(|t| t.id).unwrap_or("rectangleFrame");
|
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) {
|
fn kind(&self) -> (&'static str, &'static str) {
|
||||||
match self.id {
|
match self.id {
|
||||||
@@ -54,7 +55,11 @@ impl Tool for FrameTool {
|
|||||||
let (shape, content) = self.kind();
|
let (shape, content) = self.kind();
|
||||||
match ev.kind {
|
match ev.kind {
|
||||||
PointerKind::Down => {
|
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.cur = ev.pos;
|
||||||
self.active = false;
|
self.active = false;
|
||||||
vec![]
|
vec![]
|
||||||
@@ -67,6 +72,14 @@ impl Tool for FrameTool {
|
|||||||
}
|
}
|
||||||
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
|
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
|
||||||
let off = cx.layout.offset(sr);
|
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![];
|
let mut out = vec![];
|
||||||
if !self.active {
|
if !self.active {
|
||||||
self.active = true;
|
self.active = true;
|
||||||
@@ -93,6 +106,7 @@ impl Tool for FrameTool {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
PointerKind::Up => {
|
PointerKind::Up => {
|
||||||
|
self.guides.clear();
|
||||||
let was = self.active;
|
let was = self.active;
|
||||||
self.active = false;
|
self.active = false;
|
||||||
let start = self.start.take();
|
let start = self.start.take();
|
||||||
@@ -118,7 +132,9 @@ impl Tool for FrameTool {
|
|||||||
if let (true, Some(a)) = (self.active, self.start) {
|
if let (true, Some(a)) = (self.active, self.start) {
|
||||||
let r = Rect::from_points(a, self.cur);
|
let r = Rect::from_points(a, self.cur);
|
||||||
let _ = cx;
|
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![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod layout;
|
|||||||
mod nav;
|
mod nav;
|
||||||
mod pen;
|
mod pen;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
|
pub mod snap;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
use designcraft_compose::Cache;
|
use designcraft_compose::Cache;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
|||||||
enum Drag {
|
enum Drag {
|
||||||
None,
|
None,
|
||||||
Pending { start: Point, hit: bool },
|
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 },
|
Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef },
|
||||||
Marquee { start: Point, cur: Point },
|
Marquee { start: Point, cur: Point },
|
||||||
Anchor { id: u64, si: usize, ai: usize, handle: Option<&'static str>, start: Point },
|
Anchor { id: u64, si: usize, ai: usize, handle: Option<&'static str>, start: Point },
|
||||||
@@ -47,11 +47,12 @@ pub struct SelectionTool {
|
|||||||
direct: bool,
|
direct: bool,
|
||||||
drag: Drag,
|
drag: Drag,
|
||||||
hover_handle: Option<usize>,
|
hover_handle: Option<usize>,
|
||||||
|
guides: Vec<Overlay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelectionTool {
|
impl SelectionTool {
|
||||||
pub fn new(direct: bool) -> Self {
|
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() {
|
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));
|
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 label = if ev.mods.alt { "Duplicate" } else { "Move" };
|
||||||
let mut v = vec![Action::Begin(label.into())];
|
let mut v = vec![Action::Begin(label.into())];
|
||||||
v.extend(self.pointer(cx, ev));
|
v.extend(self.pointer(cx, ev));
|
||||||
@@ -211,7 +212,7 @@ impl Tool for SelectionTool {
|
|||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Drag::Move { start, origin_spread } => {
|
Drag::Move { start, origin_spread, bounds0 } => {
|
||||||
let mut d: Vec2 = p - start;
|
let mut d: Vec2 = p - start;
|
||||||
if ev.mods.shift {
|
if ev.mods.shift {
|
||||||
if d.x.abs() > d.y.abs() {
|
if d.x.abs() > d.y.abs() {
|
||||||
@@ -220,6 +221,15 @@ impl Tool for SelectionTool {
|
|||||||
d.x = 0.0;
|
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.
|
// Dragging to another spread moves the items there.
|
||||||
let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread);
|
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});
|
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![],
|
Drag::None => vec![],
|
||||||
},
|
},
|
||||||
PointerKind::Up => {
|
PointerKind::Up => {
|
||||||
|
self.guides.clear();
|
||||||
let d = std::mem::replace(&mut self.drag, Drag::None);
|
let d = std::mem::replace(&mut self.drag, Drag::None);
|
||||||
match d {
|
match d {
|
||||||
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } => vec![Action::Commit],
|
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } => vec![Action::Commit],
|
||||||
@@ -308,6 +319,7 @@ impl Tool for SelectionTool {
|
|||||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||||
match &self.drag {
|
match &self.drag {
|
||||||
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||||
|
Drag::Move { .. } => self.guides.clone(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user