From 253f1a7a102b8071fcbbd68ff7fe1d8348bbf507 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy Date: Tue, 6 Oct 2026 00:26:27 +1100 Subject: [PATCH] Snap: Alt-drag skips preview copies and Shift follows the turned view --- crates/engine/src/tests.rs | 21 +++++++++++++++++ crates/tools/src/select.rs | 27 +++++++++++++-------- crates/tools/src/snap.rs | 48 +++++++++++++++++++++++++++++++++++--- 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4d3e983..ca73c71 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 2cc97fd..54aa795 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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 }, + Move { start: Point, origin_spread: SpreadRef, bounds0: Option, exclude: Vec }, 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, diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 990a511..74b173a 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -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 { 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); + } }