mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
Snap: the nearer of a margin and the baseline grid wins; Type tool frames snap
Resizing or drawing at a zoom where the snap zone covers the baseline grid's spacing (66% and below with a 12 pt grid) always snapped to a baseline line, so frames could not reach the top or bottom margin. The baseline grid and the guides (ruler guides, margins, columns) now compete by distance and a tie goes to the guide. Frames drawn with the Type tool snapped nothing; they now snap like the frame tools' (press point and moving edges, with Shift and Alt).
This commit is contained in:
@@ -537,3 +537,64 @@ fn commands_take_non_object_params_without_panicking() {
|
||||
assert!(r.is_ok(), "{p}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units).
|
||||
fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_frame_resize_snaps_to_the_margins() {
|
||||
use designcraft_geom::{Point, Vec2};
|
||||
for zoom in [1.0, 0.5] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
|
||||
let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0);
|
||||
s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap();
|
||||
s.set_tool("selection");
|
||||
let v = ViewInfo::at_zoom(zoom);
|
||||
let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
// Right handle to 1.5 pt short of the right margin.
|
||||
drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v);
|
||||
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1);
|
||||
// Bottom handle to 1.5 pt past the bottom margin.
|
||||
drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v);
|
||||
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1);
|
||||
// Top-left corner handle onto the top and left margins.
|
||||
drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v);
|
||||
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_frames_snap_to_the_margins() {
|
||||
use designcraft_geom::Point;
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
|
||||
s.set_tool("type");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y));
|
||||
// From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin.
|
||||
let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5);
|
||||
let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5);
|
||||
let before = s.doc().unwrap().doc.spreads[0].items.len();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
|
||||
let items = &s.doc().unwrap().doc.spreads[0].items;
|
||||
assert_eq!(items.len(), before + 1, "a frame was drawn");
|
||||
let r = items.last().unwrap().bounds();
|
||||
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
|
||||
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
|
||||
}
|
||||
|
||||
+42
-34
@@ -50,6 +50,46 @@ impl FrameTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangle a frame-drawing drag from `sa` to `end` (spread space) makes, snapped when
|
||||
/// snapping is on, and the snap guides to draw. Shift constrains the shape first; only the
|
||||
/// driving edge snaps, then the other follows. Alt draws from the centre.
|
||||
pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end: Point, mods: Mods) -> (Rect, Vec<crate::Overlay>) {
|
||||
let r = drag_rect(sa, end, mods);
|
||||
if !cx.snap.any() {
|
||||
return (r, vec![]);
|
||||
}
|
||||
let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, mods.alt);
|
||||
let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs();
|
||||
if mods.shift {
|
||||
if driver_x {
|
||||
y_edges = [false, false, false];
|
||||
} else {
|
||||
x_edges = [false, false, false];
|
||||
}
|
||||
}
|
||||
let hit = crate::snap::snap(
|
||||
cx,
|
||||
SnapRequest {
|
||||
spread: sr,
|
||||
gesture: Gesture::Create,
|
||||
rect: r,
|
||||
x_edges,
|
||||
y_edges,
|
||||
exclude: &[],
|
||||
copying: false,
|
||||
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
|
||||
angle: None,
|
||||
radius: 0.0,
|
||||
pointer: end,
|
||||
},
|
||||
);
|
||||
let mut r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta);
|
||||
if mods.shift {
|
||||
r = restore_shift_frame(sa, end, r, driver_x, mods.alt);
|
||||
}
|
||||
(r, hit.guides)
|
||||
}
|
||||
|
||||
pub fn drag_rect(a: Point, b: Point, m: Mods) -> Rect {
|
||||
let (mut dx, mut dy) = (b.x - a.x, b.y - a.y);
|
||||
if m.shift {
|
||||
@@ -365,41 +405,9 @@ impl Tool for FrameTool {
|
||||
}
|
||||
out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]})));
|
||||
} else {
|
||||
// Shift constrains the shape first. Only the driving edge snaps, then the other follows.
|
||||
let end = cx.layout.to_spread(sr, ev.pos);
|
||||
let mut r = drag_rect(sa, end, ev.mods);
|
||||
if cx.snap.any() {
|
||||
let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, ev.mods.alt);
|
||||
let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs();
|
||||
if ev.mods.shift {
|
||||
if driver_x {
|
||||
y_edges = [false, false, false];
|
||||
} else {
|
||||
x_edges = [false, false, false];
|
||||
}
|
||||
}
|
||||
let hit = crate::snap::snap(
|
||||
cx,
|
||||
SnapRequest {
|
||||
spread: sr,
|
||||
gesture: Gesture::Create,
|
||||
rect: r,
|
||||
x_edges,
|
||||
y_edges,
|
||||
exclude: &[],
|
||||
copying: false,
|
||||
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
|
||||
angle: None,
|
||||
radius: 0.0,
|
||||
pointer: end,
|
||||
},
|
||||
);
|
||||
r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta);
|
||||
if ev.mods.shift {
|
||||
r = restore_shift_frame(sa, end, r, driver_x, ev.mods.alt);
|
||||
}
|
||||
self.guides = hit.guides;
|
||||
}
|
||||
let (r, guides) = snapped_drag_rect(cx, sr, sa, end, ev.mods);
|
||||
self.guides = guides;
|
||||
self.last = Some((spread_json(sr), r));
|
||||
out.push(self.preview(spread_json(sr), r));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Snapping and smart guides.
|
||||
//!
|
||||
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone.
|
||||
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone; the
|
||||
//! baseline grid and the guides (ruler guides, margins, columns) count as one pass where the
|
||||
//! nearer target wins.
|
||||
//! Ruler guides, margins, and columns draw nothing. Alignment draws [`Overlay::Guide`] in
|
||||
//! canvas coordinates. Equal spacing draws [`Overlay::Gap`] the same way, through one
|
||||
//! spread-to-canvas transform. A rotation match draws [`Overlay::Measure`] at the pointer
|
||||
@@ -374,13 +376,22 @@ fn format_angle(deg: f64) -> String {
|
||||
|
||||
fn first_on_axis(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
|
||||
pass_grid(cx, req, axis, tol)
|
||||
.or_else(|| pass_baseline(cx, req, axis, tol))
|
||||
.or_else(|| pass_guides(cx, req, axis, tol))
|
||||
.or_else(|| nearer(pass_baseline(cx, req, axis, tol), pass_guides(cx, req, axis, tol)))
|
||||
.or_else(|| pass_align(cx, req, axis, tol))
|
||||
.or_else(|| pass_spacing(cx, req, axis, tol))
|
||||
.or_else(|| pass_dimensions(cx, req, axis, tol))
|
||||
}
|
||||
|
||||
/// The baseline grid and the guides (ruler guides, margins, columns) compete by distance, so a
|
||||
/// margin is reachable between the lines of a fine baseline grid at any zoom. A tie goes to the
|
||||
/// guide.
|
||||
fn nearer(baseline: Option<AxisHit>, guide: Option<AxisHit>) -> Option<AxisHit> {
|
||||
match (baseline, guide) {
|
||||
(Some(b), Some(g)) => Some(if b.delta.abs() < g.delta.abs() { b } else { g }),
|
||||
(b, g) => g.or(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Document grid. No overlay.
|
||||
///
|
||||
/// The interval is the major spacing divided by the subdivision count. A count below 1 counts
|
||||
@@ -1747,6 +1758,24 @@ mod tests {
|
||||
assert_eq!(hit.delta.y, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearer_of_margin_and_baseline_wins() {
|
||||
// Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744.
|
||||
let doc = Document::new(&NewDocument {
|
||||
margins: designcraft_doc::Margins { top: 54.0, bottom: 54.0, inside: 54.0, outside: 42.0 },
|
||||
..Default::default()
|
||||
});
|
||||
let cache = Cache::new();
|
||||
let layout = CanvasLayout::new(&doc, false);
|
||||
let sel = Selection::default();
|
||||
// At 50% the 4 px zone is 8 pt: both the margin and a baseline line are in reach.
|
||||
let cx = ToolContext { zoom: 0.5, ..ctx_on(&doc, &sel, &cache, &layout) };
|
||||
let bottom = |y1: f64| SnapRequest { y_edges: [false, false, true], gesture: Gesture::Resize, ..y_req(Rect::new(100.0, 600.0, 140.0, y1)) };
|
||||
assert!((snap(&cx, bottom(739.5)).delta.y - -1.5).abs() < 1e-6, "the margin is nearer");
|
||||
assert!((snap(&cx, bottom(742.5)).delta.y - 1.5).abs() < 1e-6, "the baseline line is nearer");
|
||||
assert!((snap(&cx, bottom(741.0)).delta.y - -3.0).abs() < 1e-6, "a tie goes to the margin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_item_matches_its_own_side() {
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use designcraft_geom::Point;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::drag_rect, rect_json, spread_json};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::snapped_drag_rect, rect_json, spread_json};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TypeTool {
|
||||
@@ -17,6 +17,8 @@ pub struct TypeTool {
|
||||
/// A press on a table whose whole rows or columns are selected: (frame, press point in
|
||||
/// spread space) — a drag moves them, a click places the caret.
|
||||
cell_drag: Option<(u64, Point)>,
|
||||
/// Snap guides while drawing a frame.
|
||||
guides: Vec<Overlay>,
|
||||
/// The press selected a word, line, paragraph or story (several clicks): drags keep it.
|
||||
unit_selected: bool,
|
||||
}
|
||||
@@ -90,7 +92,10 @@ impl Tool for TypeTool {
|
||||
return vec![];
|
||||
}
|
||||
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
|
||||
let r = drag_rect(sa, cx.layout.to_spread(sr, ev.pos), ev.mods);
|
||||
// Drawn like the frame tools: the press point and the moving edges snap.
|
||||
let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa };
|
||||
let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods);
|
||||
self.guides = guides;
|
||||
let mut out = vec![];
|
||||
if !self.drawing {
|
||||
self.drawing = true;
|
||||
@@ -103,6 +108,7 @@ impl Tool for TypeTool {
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
self.guides.clear();
|
||||
let start = self.start.take();
|
||||
if let Some((fid, from)) = self.cell_drag.take() {
|
||||
let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
|
||||
@@ -155,6 +161,10 @@ impl Tool for TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
if self.drawing { self.guides.clone() } else { vec![] }
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
match cx.hit(p) {
|
||||
Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text,
|
||||
|
||||
Reference in New Issue
Block a user