diff --git a/crates/draw-engine/src/engine/pointer_move.rs b/crates/draw-engine/src/engine/pointer_move.rs index 53b89e8..68e40f8 100644 --- a/crates/draw-engine/src/engine/pointer_move.rs +++ b/crates/draw-engine/src/engine/pointer_move.rs @@ -4,7 +4,11 @@ use crate::interaction::constrain_to_angle; use crate::interaction::{linear_from_drag, rect_from_drag, snap_move}; use crate::scene::binding::{anchor, anchor_for_drop, set_anchor, Anchor, End, EndDrop}; use crate::scene::{scene_bounds, DrawElement, DrawElementType}; -use crate::selection::{resize_element_within, rotate_element, HandleKind}; +use crate::selection::{ + elements_in_marquee_among, resize_element_within, rotate_element, HandleKind, +}; + +const MARQUEE_MIN_DRAG: f64 = 2.0; impl DrawEngine { /// `invert_snap` is the host's Ctrl/Cmd: it flips object snapping for this move, on @@ -313,6 +317,22 @@ impl DrawEngine { Some(Interaction::Laser) } Interaction::Marquee { start, base, .. } => { + // Resolve the band continuously, not only in `end_pointer`. This makes the + // selection visible while the button is still held, which is important for + // large boards: the user must be able to see which objects the live band will + // capture before deciding where to release. Keep the original selection as + // the additive base, so Shift-drag remains stable while the caught set changes. + let rect = crate::selection::marquee_rect(start.x, start.y, world.x, world.y); + let dragged = (rect.max_x - rect.min_x).abs() > MARQUEE_MIN_DRAG + || (rect.max_y - rect.min_y).abs() > MARQUEE_MIN_DRAG; + let mut ids = base.clone(); + if dragged { + ids.extend(elements_in_marquee_among( + self.scene.iter_ordered().filter(|el| !self.untouchable(el)), + rect, + )); + } + self.select_caught(ids); // The rubber band is chrome: nothing in the scene changes as it grows, so // nothing else will mark the frame dirty. Without this the rectangle was // never painted at all — the state followed the pointer perfectly and the diff --git a/crates/draw-engine/tests/ci_pointer.rs b/crates/draw-engine/tests/ci_pointer.rs index aefbc8d..0c8c307 100644 --- a/crates/draw-engine/tests/ci_pointer.rs +++ b/crates/draw-engine/tests/ci_pointer.rs @@ -201,6 +201,31 @@ fn pointer_select_tool_click_empty_canvas_clears_selection() { assert!(engine.get_selection().is_empty()); } +#[test] +fn pointer_marquee_selects_objects_before_pointerup() { + let first = filled(box_at(40.0, 40.0, 40.0, 40.0)); + let second = filled(box_at(120.0, 40.0, 40.0, 40.0)); + let outside = filled(box_at(260.0, 40.0, 40.0, 40.0)); + let mut engine = engine_with_scene(vec![first.clone(), second.clone(), outside]); + engine.set_tool(DrawTool::Select); + + engine.begin_pointer(20.0, 20.0, false, false); + engine.move_pointer(180.0, 100.0, false, false); + + // The button is still held here. The live band must already have selected both objects + // inside it; waiting for `end_pointer` is the regression this test prevents. + let mut selected = engine.get_selection(); + selected.sort(); + let mut expected = vec![first.id, second.id]; + expected.sort(); + assert_eq!(selected, expected); + + engine.end_pointer(); + let mut after_release = engine.get_selection(); + after_release.sort(); + assert_eq!(after_release, expected); +} + #[test] fn pointer_eraser_tool_deletes_clicked_element() { // Filled, so a click in the middle lands on it: a transparent shape is hit only on