From 24a512793ca825a92a88d79c1fdcbea26a88a270 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 29 Oct 2026 11:16:33 +0200 Subject: [PATCH 1/3] M4: a drag anywhere in vector edit mode draws a marquee (#1508) A press more than a few pixels from the edited shape left edit mode at once, so a drag over another layer moved that layer. The press now starts a marquee; released without a drag it still leaves edit mode and replays the click to the normal tool. --- crates/engine/src/tools.rs | 2 +- crates/engine/src/vectoredit/pointer.rs | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 86feed4f1..763b60ec9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -212,7 +212,7 @@ pub struct PointerEvent { } /// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed. -fn within_slop(ev: &PointerEvent, start: Point) -> bool { +pub(crate) fn within_slop(ev: &PointerEvent, start: Point) -> bool { (ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX } diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 4721d6bd5..f9cb05931 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -13,7 +13,7 @@ use super::regions::region_hit; use super::transform::{self, BoxHit, PointBox}; use super::{EditPart, VHit, VTool, VectorEdit, edit, editing, hit, hit_any, local_offset, network, ops, points_of, selected_points, set_state}; use crate::snap::Targets; -use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind}; +use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind, within_slop}; use crate::{Result, Session}; /// An in-progress edit-mode gesture (cheap to clone: snap targets are shared). @@ -67,10 +67,13 @@ pub(crate) enum VGesture { moved: bool, smooth: bool, }, - /// Marquee and lasso: `base` is each edited layer's selection to add to. + /// Marquee and lasso: `base` is each edited layer's selection to add to. `leave` is a press + /// away from every edited layer: released without a drag, it leaves edit mode and goes to the + /// normal tool. Marquee { start: Point, base: Vec, + leave: Option, }, Lasso { points: Vec, @@ -661,17 +664,14 @@ impl Session { } } None => { - // Outside every edited layer (and not shift): leave edit mode. + // Outside every edited layer (and not shift): a click leaves edit mode, a drag marquees. let inside = ve.parts().iter().any(|p| doc.world_render_bounds(p.id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()))); - if !inside && !ev.shift { - super::end(self)?; - return Ok(false); - } if !ev.shift { set_state(self, VectorEdit::clear_selection)?; } - self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) }); + let leave = (!inside && !ev.shift).then(|| ev.clone()); + self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift), leave }); } }, VTool::Bend => match h { @@ -695,7 +695,7 @@ impl Session { self.begin_interaction("Bend")?; self.vgesture(VGesture::BendSegment { seg, t }); } - None => self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) }), + None => self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift), leave: None }), }, VTool::Pen => { let at = if h.is_none() && !ev.shift { self.snap_pen(ev, Some(id), &[])?.0 } else { ev.p() }; @@ -908,7 +908,7 @@ impl Session { self.preview(|doc, _| edit(doc, id, |net| ops::make_smooth(net, v, d)))?; self.vgesture(VGesture::BendVertex { v, origin, moved: true, smooth }); } - VGesture::Marquee { start, base } => { + VGesture::Marquee { start, base, .. } => { let r = Rect::from_points(start, ev.p()); self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); self.select_parts(&base, |doc, id| super::vertices_in(doc, id, r), false)?; @@ -1025,6 +1025,11 @@ impl Session { VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } | VGesture::BoxRotate { .. } => { self.commit_interaction()? } + VGesture::Marquee { start, leave: Some(press), .. } if within_slop(ev, start) => { + super::end(self)?; + self.pointer(&press)?; + return self.pointer(ev); + } // The active layer moves to one with points selected when it has none. VGesture::Marquee { .. } | VGesture::Lasso { .. } => set_state(self, |e| { if e.vertices.is_empty() From 3e0e00c3fd23c05649ce588d40252e04ac96d59d Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 29 Oct 2026 11:16:33 +0200 Subject: [PATCH 2/3] test(vector): a marquee starts anywhere on the canvas (#1508) --- crates/engine/src/vector_behaviour_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index fb734085b..1a3b9b427 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -270,6 +270,27 @@ fn selecting_points_and_segments() { assert_eq!(page_pos(&s, id, tl), Point::new(1.0, 0.0), "the drag is one undo step"); } +#[test] +fn a_marquee_starts_anywhere_on_the_canvas() { + // #1508: a drag far from the edited shape, over another layer, marquees its points; a click + // there still leaves edit mode and selects that layer. + let mut s = Session::new(); + let bg = id_of(&ex(&mut s, "shape.rectangle", json!({"x": -500, "y": -500, "width": 1000, "height": 1000}))["id"]).unwrap(); + let id = square(&mut s, true); + let (tl, tr) = (point_at(&s, id, (0.0, 0.0)), point_at(&s, id, (100.0, 0.0))); + let bg_x = |s: &Session| s.doc().unwrap().world_transform(bg).translation().x; + drag(&mut s, (-200.0, -200.0), (110.0, 10.0), NONE); + let mut got = editing(&s).expect("a drag away from the shape stays in edit mode").vertices; + got.sort_unstable(); + let mut want = vec![tl, tr]; + want.sort_unstable(); + assert_eq!(got, want, "the marquee selects the points inside it"); + assert_eq!(bg_x(&s), -500.0, "the layer under the drag stays put"); + click(&mut s, (-200.0, -200.0), NONE); + assert!(editing(&s).is_none(), "a click away from the shape leaves edit mode"); + assert_eq!(s.selection(), vec![bg], "the click selects the layer under it"); +} + #[test] fn deleting_points_vs_delete_and_heal() { // (key, Shift, points, segments, closed afterwards, derivation) From 374ebdb0b91df3989dbc953ad55d4fbcf15d10a3 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 29 Oct 2026 11:24:30 +0200 Subject: [PATCH 3/3] docs: record the vector edit marquee fix (#1508) --- ROADMAP.md | 1 + docs/roadmap.md | 1 + 2 files changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 69d3bf731..3f709e548 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -329,6 +329,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press more than a few pixels from the edited shape used to leave edit mode at once, so a drag over another layer moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e093ac93..29608c3f6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press more than a few pixels from the edited shape used to leave edit mode at once, so a drag over another layer moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |