Snap: Alt-drag skips preview copies and Shift follows the turned view

This commit is contained in:
Cameron Gilroy 2026-10-06 00:26:27 +11:00 committed by Brandon Thomas
parent a2451243ad
commit 253f1a7a10
3 changed files with 83 additions and 13 deletions

View File

@ -106,6 +106,27 @@ fn cmd_move_does_not_snap() {
assert!(b.x0 > 80.0);
}
#[test]
fn shift_move_on_a_turned_spread_stays_on_the_line() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [80.0, 100.0, 140.0, 140.0], "content": "unassigned"})).unwrap();
s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap();
// Vertical guide sits on the spread axis that a screen-horizontal Shift lock must not offer.
s.execute("guide.add", &json!({"orientation": "vertical", "position": 82.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
let c = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), b0.center());
let shift = designcraft_tools::Mods { shift: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 60.0, c.y + 1.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, c.x + 60.0, c.y + 1.0).with_mods(shift), v).unwrap();
let b = s.doc().unwrap().doc.spreads[0].items[0].bounds();
assert!((b.x0 - b0.x0).abs() < 1e-6, "locked screen axis jumped to {b:?}");
assert!((b.y0 - b0.y0).abs() > 1.0, "free axis did not move: {b:?}");
}
#[test]
fn pages_and_layers() {
let mut s = session();

View File

@ -4,7 +4,7 @@
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
use designcraft_doc::SpreadRef;
use designcraft_doc::{ItemId, SpreadRef};
use designcraft_geom::{Point, Rect, Vec2};
use serde_json::{Value, json};
@ -14,7 +14,7 @@ use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, S
enum Drag {
None,
Pending { start: Point, hit: bool },
Move { start: Point, origin_spread: SpreadRef, bounds0: Option<Rect> },
Move { start: Point, origin_spread: SpreadRef, bounds0: Option<Rect>, exclude: Vec<ItemId> },
Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef },
Rotate { center: Point, start_angle: f64 },
Marquee { start: Point, cur: Point },
@ -251,7 +251,8 @@ impl Tool for SelectionTool {
self.shift_release = None;
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, bounds0: cx.selection_bounds() };
let exclude = cx.selection.items.clone();
self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_bounds(), exclude };
let label = if ev.mods.alt { "Duplicate" } else { "Move" };
let mut v = vec![Action::Begin(label.into())];
v.extend(self.pointer(cx, ev));
@ -261,24 +262,30 @@ impl Tool for SelectionTool {
vec![]
}
}
Drag::Move { start, origin_spread, bounds0 } => {
Drag::Move { start, origin_spread, bounds0, exclude } => {
let mut d: Vec2 = p - start;
let mut x_edges = [true, true, true];
let mut y_edges = [true, true, true];
let mut screen_x_locked = false;
let mut screen_y_locked = false;
if ev.mods.shift {
if d.x.abs() > d.y.abs() {
d.y = 0.0;
y_edges = [false, false, false];
screen_y_locked = true;
} else {
d.x = 0.0;
x_edges = [false, false, false];
screen_x_locked = true;
}
}
// 180 degrees keeps the screen axis. 90 and 270 swap it into the other spread axis.
let odd_turn = cx.layout.slot(origin_spread).is_some_and(|slot| slot.rotation % 2 == 1);
let spread_x_locked = if odd_turn { screen_y_locked } else { screen_x_locked };
let spread_y_locked = if odd_turn { screen_x_locked } else { screen_y_locked };
let x_edges = if spread_x_locked { [false; 3] } else { [true; 3] };
let y_edges = if spread_y_locked { [false; 3] } else { [true; 3] };
self.guides.clear();
// The movement in the spread's own coordinates (its view may be turned).
let mut ds = cx.layout.delta_to_spread(origin_spread, d);
let b0s = bounds0.map(|b| cx.layout.xf(origin_spread).inverse().transform_rect_bbox(b));
// Command suspends snapping. A Shift-locked axis is not offered.
// Command suspends snapping. A Shift-locked spread axis is not offered.
if !ev.mods.cmd
&& cx.snap.any()
&& let Some(b0) = b0s
@ -291,7 +298,7 @@ impl Tool for SelectionTool {
rect: b0 + ds,
x_edges,
y_edges,
exclude: &cx.selection.items,
exclude: &exclude,
copying: ev.mods.alt,
lengths: [None, None],
angle: None,

View File

@ -263,8 +263,9 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
///
/// Edges match edges only while `align_edges` is on. Centers match centers only while
/// `align_centers` is on, and only when the request's middle flag is set. Hidden items are
/// skipped. Excluded items are skipped unless `copying` is set, so an Alt-drag can meet the
/// original. Parent items and group children are not targets.
/// skipped. Without copying, excluded items are skipped. With copying, that list is the
/// drag-start set and stays a target. Live selection items outside that set are preview
/// copies and are skipped. Parent items and group children are not targets.
fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.smart_guides || (!cx.snap.align_edges && !cx.snap.align_centers) {
return None;
@ -276,7 +277,7 @@ fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
}
for item in &sp.items {
if item.hidden || (!req.copying && req.exclude.contains(&item.id)) {
if item.hidden || align_skips_item(cx, req, item.id) {
continue;
}
offer_box(&mut best, req.rect, flags, axis, item.visible_bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
@ -284,6 +285,11 @@ fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
best.map(|b| b.hit)
}
/// Drag-start ids stay targets while copying. Preview copies are the live selection minus that set.
fn align_skips_item(cx: &ToolContext, req: &SnapRequest<'_>, id: ItemId) -> bool {
if req.copying { cx.selection.items.contains(&id) && !req.exclude.contains(&id) } else { req.exclude.contains(&id) }
}
fn axis_flags(req: &SnapRequest<'_>, axis: Axis) -> [bool; 3] {
match axis {
Axis::X => req.x_edges,
@ -552,4 +558,40 @@ mod tests {
);
assert!((hit.delta.x - -2.0).abs() < 1e-6);
}
#[test]
fn alt_copy_does_not_stick_to_the_preview() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let make = |doc: &mut Document, rect: Rect| {
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
id
};
let original = make(&mut doc, Rect::new(100.0, 40.0, 160.0, 80.0));
let ghost = make(&mut doc, Rect::new(161.0, 40.0, 221.0, 80.0));
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::items(vec![ghost]);
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.snap_to_guides = false;
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(161.0, 40.0, 221.0, 80.0),
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[original],
copying: true,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x - -1.0).abs() < 1e-6, "delta.x = {}", hit.delta.x);
}
}