From d48aa59ec757ba9b6ebe346aebfbbaed87505d97 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 11:52:41 -0500 Subject: [PATCH 1/4] M12: drag a "+" handle to drop the next shape anywhere or join another object (#1919) The quick-create handles only took a click. As the help says ("drag the preview to any location on your board"), a handle can now be dragged: the preview follows the pointer and, let go on empty board, the next sticky or shape is made there, centred on the pointer, joined by a connector whose ends take the sides facing each other (board.addConnected gains at: [x, y]); let go on another sticky or shape, a connector joins the two instead (board.connector), that object outlined while over it. figcraft_engine::board::object_at exposes the hit test a stamp uses. The click is as before. Tests: board_connect_tests (two), board_connected_tests::the_next_one_goes_where_it_is_dropped. Docs: boards-parity row, status line and revision history. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/board_connected_tests.rs | 17 +++ crates/engine/src/boards/board.rs | 9 +- crates/engine/src/boards/board/items.rs | 16 +++ crates/ui-egui/src/board_connect_tests.rs | 53 ++++++++ crates/ui-egui/src/canvas/board_connect.rs | 138 +++++++++++++++++++-- docs/boards-parity.md | 5 +- 6 files changed, 225 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/board_connected_tests.rs b/crates/engine/src/board_connected_tests.rs index 2e5f8a344..d6b962449 100644 --- a/crates/engine/src/board_connected_tests.rs +++ b/crates/engine/src/board_connected_tests.rs @@ -104,3 +104,20 @@ fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_wer assert!(r.get("connector").is_none(), "{r}"); assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. }))); } + +/// `at` (#1919; "drag the preview to any location on your board", Build faster with quick create): +/// the next one is centred on that point, wherever it is, and the connector takes the sides facing +/// each other; `at` must be two finite numbers. +#[test] +fn the_next_one_goes_where_it_is_dropped() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id"); + let r = s.execute("board.addConnected", &json!({"id": a, "side": "below", "at": [700, 60]})).unwrap(); + let (b, c) = (id(&r, "id"), id(&r, "connector")); + assert_eq!(at(&s, b), (600.0, 0.0), "centred on the point"); + let k = connector(&s, c); + assert_eq!((k.from, k.to), (End::Node { node: a, side: Side::Auto, at: None }, End::Node { node: b, side: Side::Auto, at: None })); + for wrong in [json!([1]), json!("x"), json!([0, "y"]), json!({"x": 1, "y": 2})] { + assert!(s.execute("board.addConnected", &json!({"id": a, "at": wrong})).is_err(), "{wrong}"); + } +} diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs index bf7f8ccf1..d6c5dc255 100644 --- a/crates/engine/src/boards/board.rs +++ b/crates/engine/src/boards/board.rs @@ -33,6 +33,13 @@ pub use connectors::{END_HANDLE_PX, selected_connector_ends}; use connectors::{connector_cmd, reattach}; pub use derived::route; pub(crate) use derived::{settle_routes, settle_stamps, settle_text, stamp_target}; + +/// The sticky, shape or other object at `p` on `page`: the topmost layer of the page whose box +/// holds it (stamps, connectors, their labels and ink aside), other than `except`. What a stamp +/// sticks to, and what a "+" handle dragged onto it joins (#1919). +pub fn object_at(doc: &Document, page: NodeId, p: Point, except: Option) -> Option { + stamp_target(doc, page, p, except) +} pub use edit::label_of; pub(crate) use edit::{copied_items, copy_map, rejoin}; use edit::{label_cmd, set_color, set_style}; @@ -114,7 +121,7 @@ pub fn specs() -> Vec { spec!( "board.addConnected", "Add connected shape", - "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector", + "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), at? ([x, y] on the page: the next one's centre, anywhere, instead of its place on that side; the connector then takes the sides facing each other), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector", true, add_connected ), diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index 44708d944..981a8399e 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -223,6 +223,12 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result { let color = color.unwrap_or(s.tool.board.sticky); let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side) .ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?; + // Dropped somewhere by hand ("drag the preview to any location on your board", #1919): the + // next one is centred there, and the connector joins the sides facing each other. + let (to_at, from_side, to_side) = match drop_point(cmd, p)? { + Some((x, y)) => ((x - next.0 / 2.0, y - next.1 / 2.0), Side::Auto, Side::Auto), + None => (to_at, from_side, to_side), + }; let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?); if editing.is_some() { textedit::end(s)?; @@ -246,6 +252,16 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id, "text": text, "connector": line})) } +/// `at?` of `board.addConnected`: a point on the page, `[x, y]`, or none. +fn drop_point(cmd: &str, p: &Value) -> Result> { + let Some(v) = get(p, "at") else { return Ok(None) }; + let pair = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + match pair { + Some((x, y)) if x.is_finite() && y.is_finite() => Ok(Some((x, y))), + _ => Err(bad(cmd, "`at` is [x, y] on the page, two finite numbers")), + } +} + /// A shape with text's layers: the frame, its outline and its text. fn make_shape( doc: &mut Document, diff --git a/crates/ui-egui/src/board_connect_tests.rs b/crates/ui-egui/src/board_connect_tests.rs index 5a4712197..f3e4dc612 100644 --- a/crates/ui-egui/src/board_connect_tests.rs +++ b/crates/ui-egui/src/board_connect_tests.rs @@ -140,3 +140,56 @@ fn the_object_a_stamp_will_stick_to_is_highlighted() { u.button(off, false); assert_eq!((target(&u), on(&u)), (None, None), "let go off the shape: on nothing, and no outline"); } + +/// Dragging a "+" carries the preview ("Click to select and create the next shape in that +/// direction, or drag the preview to any location on your board", Build faster with quick create; +/// #1919): let go on empty board and the next shape is made there, centred on the pointer, joined +/// by a connector, with typing started; one undo takes both away. +#[test] +fn dragging_a_handle_drops_the_next_shape_where_it_is_let_go() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let before = u.app.session.doc().unwrap().ids().count(); + let from = handle(&u, "below").expect("a handle below").center(); + let to = from + egui::vec2(260.0, 180.0); + u.drag_to(from, to); + u.button(to, false); + u.frame(vec![]); + let doc = u.app.session.doc().unwrap(); + let next = doc.ids().find(|i| *i != a && matches!(doc.node(*i).unwrap().board, Some(BoardItem::Shape { .. }))).expect("a new shape"); + let line = doc.ids().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector"); + let centre = screen_box(&u, next).center(); + assert!((centre - to).length() < 1.0, "centred where it was let go: {centre:?}, not {to:?}"); + assert!(matches!(&doc.node(line).unwrap().board, Some(BoardItem::Connector(c)) if c.from.node() == Some(a) && c.to.node() == Some(next))); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "typing in the new shape"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "undo takes the shape and its connector away"); +} + +/// A "+" dragged onto another sticky or shape joins the two with a connector instead of making a +/// new shape (#1919). +#[test] +fn dropping_a_handle_on_another_shape_joins_them() { + let (mut u, a) = board_with_shape(); + let b = id(&u.app.session.execute("board.shape", &json!({"shape": "ellipse", "x": 400, "y": 300, "width": 160, "height": 100})).unwrap()); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let shapes = |u: &Ui| { + let doc = u.app.session.doc().unwrap(); + doc.ids().filter(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Shape { .. }))).count() + }; + assert_eq!(shapes(&u), 2); + let from = handle(&u, "right").expect("a handle right").center(); + let to = screen_box(&u, b).center(); + u.drag_to(from, to); + u.button(to, false); + u.frame(vec![]); + assert_eq!(shapes(&u), 2, "no new shape"); + let doc = u.app.session.doc().unwrap(); + let line = doc.ids().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector"); + assert!(matches!(&doc.node(line).unwrap().board, Some(BoardItem::Connector(c)) if c.from.node() == Some(a) && c.to.node() == Some(b))); +} diff --git a/crates/ui-egui/src/canvas/board_connect.rs b/crates/ui-egui/src/canvas/board_connect.rs index 87211055b..aaac45af9 100644 --- a/crates/ui-egui/src/canvas/board_connect.rs +++ b/crates/ui-egui/src/canvas/board_connect.rs @@ -1,11 +1,13 @@ //! Boards: a "+" beside each side of the one selected sticky or shape (#1597). Hovering one shows //! where the next one goes; clicking it makes the next of the same kind there, joined by a -//! connector, and starts typing in it (`board.addConnected`). +//! connector, and starts typing in it (`board.addConnected`). Dragging one carries the preview +//! ("drag the preview to any location on your board", #1919): let go on empty board and the next +//! one is made there, joined; let go on another sticky or shape and a connector joins the two. -use egui::{Area, Color32, CornerRadius, Id, LayerId, Order, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; +use egui::{Area, Color32, CornerRadius, Id, LayerId, Order, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, pos2, vec2}; use figcraft_doc::{BoardItem, NodeId}; use figcraft_engine::ToolId; -use figcraft_engine::board::{STICKY, connected_place}; +use figcraft_engine::board::{STICKY, connected_place, object_at}; use serde_json::json; use crate::FigApp; @@ -18,6 +20,17 @@ pub(crate) const REACH: f32 = 36.0; const RADIUS: f32 = 9.0; const SIDES: [&str; 4] = ["above", "right", "below", "left"]; +/// A drag from a "+": the object and the side it started from, kept while the button is down. +#[derive(Clone)] +struct Drag { + from: NodeId, + side: &'static str, +} + +fn drag_id() -> Id { + Id::unique("board_connect_drag") +} + /// The sticky or shape the handles are for: its id, its box on the page and the next one's size. fn target(app: &FigApp) -> Option<(NodeId, figcraft_geom::Rect, (f64, f64))> { let editable = app.session.active_read_only().is_none(); @@ -51,8 +64,35 @@ fn handle_at(r: Rect, side: &str) -> Pos2 { } } +/// The middle of `side` of the object's screen box `r`, where its connector leaves. +fn side_middle(r: Rect, side: &str) -> Pos2 { + match side { + "above" => r.center_top(), + "right" => r.right_center(), + "below" => r.center_bottom(), + _ => r.left_center(), + } +} + +/// A "+" side as `board.connector` names it. +fn connector_side(side: &str) -> &'static str { + match side { + "above" => "top", + "below" => "bottom", + "left" => "left", + _ => "right", + } +} + pub fn show(app: &mut FigApp, ctx: &egui::Context) { - let Some((id, b, next)) = target(app) else { return }; + let drag: Option = ctx.data(|d| d.get_temp(drag_id())); + let Some((id, b, next)) = target(app) else { + // The selection or the tool changed under a drag: it is over. + if drag.is_some() { + ctx.data_mut(|d| d.remove::(drag_id())); + } + return; + }; let r = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); // Too small on screen to tell the handles from the object. if r.width() < 2.0 * RADIUS || r.height() < 2.0 * RADIUS { @@ -63,16 +103,18 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // Not over the toolbar at the bottom of the canvas. let toolbar = crate::toolbar::pill_rect(app.canvas.rect, crate::toolbar::bar_width(app)).expand(RADIUS); let mut picked = None; + let mut started = None; + let mut let_go = false; for side in SIDES { let c = handle_at(r, side); if !app.canvas.rect.shrink(RADIUS).contains(c) || toolbar.contains(c) { continue; } let area = Area::new(Id::unique(("board_connect", side))).fixed_pos(c - vec2(RADIUS, RADIUS)).order(Order::Middle); - let clicked = area + let (clicked, drag_started, drag_stopped) = area .show(ctx, |ui| { - let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * RADIUS, 2.0 * RADIUS), Sense::click()); - let hot = resp.hovered(); + let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * RADIUS, 2.0 * RADIUS), Sense::click_and_drag()); + let hot = resp.hovered() || resp.dragged(); let p = ui.painter(); p.circle_filled(rect.center(), RADIUS, if hot { accent } else { t.panel }); p.circle_stroke(rect.center(), RADIUS, Stroke::new(1.0, if hot { accent } else { t.border })); @@ -80,19 +122,58 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let arm = RADIUS * 0.45; p.line_segment([rect.center() - vec2(arm, 0.0), rect.center() + vec2(arm, 0.0)], ink); p.line_segment([rect.center() - vec2(0.0, arm), rect.center() + vec2(0.0, arm)], ink); - if hot { + // Where a click puts the next one; a drag shows its own preview. + if hot && drag.is_none() && !resp.dragged() { preview(app, ctx, b, next, side, accent); } - resp.on_hover_text(tr("Add a connected shape")).clicked() + let resp = resp.on_hover_text(tr("Add a connected shape")); + (resp.clicked(), resp.drag_started(), resp.drag_stopped()) }) .inner; if clicked { picked = Some(side); } + if drag_started { + started = Some(side); + } + let_go |= drag_stopped; } if let Some(side) = picked { app.run("board.addConnected", json!({"id": id, "side": side, "edit": true})); } + if let Some(side) = started { + ctx.data_mut(|d| d.insert_temp(drag_id(), Drag { from: id, side })); + } + // A drag carries the preview: the next one centred on the pointer, or the object under it, + // which the connector joins instead. + let Some(Drag { from, side }) = drag.filter(|d| d.from == id) else { return }; + let Some(pointer) = ctx.input(|i| i.pointer.latest_pos()) else { return }; + let over = object_under(app, from, pointer); + drag_preview(app, ctx, (b, next, side), pointer, over); + if let_go { + ctx.data_mut(|d| d.remove::(drag_id())); + match over { + Some(to) => { + app.run("board.connector", json!({"from": from, "to": to, "fromSide": connector_side(side)})); + } + None => { + let q = app.to_page(pointer); + app.run("board.addConnected", json!({"id": from, "side": side, "at": [q.x, q.y], "edit": true})); + } + } + } else if !ctx.input(|i| i.pointer.any_down()) { + // The button came up without the handle hearing of it (the pointer left the window). + ctx.data_mut(|d| d.remove::(drag_id())); + } +} + +/// The sticky, shape or other object under the screen point `p` on the canvas, other than `except`. +fn object_under(app: &FigApp, except: NodeId, p: Pos2) -> Option { + if !app.canvas.rect.contains(p) { + return None; + } + let ds = app.session.ds().ok()?; + object_at(&ds.doc, ds.page, app.to_page(p), Some(except)) } /// Where the next one goes for `side`, faintly: its box and the connector's line to it. @@ -107,9 +188,46 @@ fn preview(app: &FigApp, ctx: &egui::Context, b: figcraft_geom::Rect, next: (f64 _ => (from.left_center(), ghost.right_center()), }; let p = ctx.layer_painter(LayerId::new(Order::Middle, Id::unique("board_connect_preview"))); + let faint = Stroke::new(1.5, accent.gamma_multiply(0.6)); + ghost_box(&p, ghost, accent); + p.line_segment([a, z], faint); +} + +/// The next one's box, faintly: a tint and a dashed outline. +fn ghost_box(p: &egui::Painter, ghost: Rect, accent: Color32) { let faint = Stroke::new(1.5, accent.gamma_multiply(0.6)); p.rect_filled(ghost, CornerRadius::same(8), accent.gamma_multiply(0.08)); let corners = [ghost.left_top(), ghost.right_top(), ghost.right_bottom(), ghost.left_bottom(), ghost.left_top()]; p.extend(Shape::dashed_line(&corners, faint, 6.0, 4.0)); - p.line_segment([a, z], faint); +} + +/// The dragged preview: the next one centred on `pointer` and the connector's line to it from +/// `side` of the object's box `b`, or, over another object, that object outlined and the line to +/// it; the line arrives at the side of the box facing the object. +fn drag_preview(app: &FigApp, ctx: &egui::Context, (b, next, side): (figcraft_geom::Rect, (f64, f64), &str), pointer: Pos2, over: Option) { + let accent = crate::theme::canvas_accent(ctx); + let from = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let p = ctx.layer_painter(LayerId::new(Order::Middle, Id::unique("board_connect_preview"))); + let to_box = match over.and_then(|o| app.session.doc().ok()?.world_bounds(o)) { + Some(ob) => { + let r = Rect::from_two_pos(app.to_screen(ob.x0, ob.y0), app.to_screen(ob.x1, ob.y1)); + p.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.5, accent), StrokeKind::Outside); + r + } + None => { + let size = app.to_screen(next.0, next.1) - app.to_screen(0.0, 0.0); + let ghost = Rect::from_center_size(pointer, size); + ghost_box(&p, ghost, accent); + ghost + } + }; + let d = to_box.center() - from.center(); + let z = if d.x.abs() >= d.y.abs() { + if d.x >= 0.0 { to_box.left_center() } else { to_box.right_center() } + } else if d.y >= 0.0 { + to_box.center_top() + } else { + to_box.center_bottom() + }; + p.line_segment([side_middle(from, side), z], Stroke::new(1.5, accent.gamma_multiply(0.6))); } diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 7526d39aa..dd14d1b9f 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-31 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-03 · **Change:** minor (a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -38,7 +38,7 @@ all end with the same geometry. | Shapes with text | done | 23 outlines: rectangle, rounded rectangle, ellipse, diamond, triangle up and down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, chevron, speech bubble, and the flowchart symbols database, document, predefined process, manual input and internal storage; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, the advanced shape libraries (engineering and other icon shapes) | missing | | | Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | -| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before | +| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before. Dragging a "+" carries the preview ("drag the preview to any location on your board"): let go on empty board and the next one is made there, centred on the pointer, joined by a connector whose ends take the sides facing each other (`at: [x, y]`); let go on another sticky or shape and a connector joins the two instead, the object outlined while over it (`board_connect_tests`, #1919) | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side | | Connector paths reshaped by hand | done | a selected elbow or curved connector shows path handles: one on each run long enough to take one, one in the middle of a curve. Dragging a run moves it, and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins and is saved with the file; changing the style or moving an end to something else goes back to routing itself. No way back by itself otherwise: the public help describes none | @@ -70,6 +70,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer and joined by a connector whose ends take the sides facing each other (`board.addConnected` gains `at: [x, y]`); let go on another sticky or shape, a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (two), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. | From 246a89733ca0b4472280d4729e008c75c699d0f5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 12:44:59 -0500 Subject: [PATCH 2/4] M12: quick-create drag: stickies and shapes in sections, parent coordinates, the handle's side, no drop off the canvas (#1919) Review: the drop target is the topmost sticky or shape under the pointer, sections and frames looked into (board::sticky_or_shape_at, instead of the stamp's any-layer hit test on the page's children); the drop point is placed in the parent's coordinates; the connector leaves by the handle's side (as the preview draws it) and arrives by the facing side; a handle let go off the canvas makes nothing; the parity rows on quick-create handles agree. Tests: dropping_a_handle_on_a_shape_inside_a_frame_joins_it, letting_a_handle_go_over_a_panel_makes_nothing, the_next_one_goes_where_it_is_dropped (frame case). Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/board_connected_tests.rs | 13 ++++++- crates/engine/src/boards/board.rs | 35 +++++++++++++++--- crates/engine/src/boards/board/items.rs | 11 +++++- crates/ui-egui/src/board_connect_tests.rs | 43 ++++++++++++++++++++++ crates/ui-egui/src/canvas/board_connect.rs | 19 +++++++--- docs/boards-parity.md | 8 ++-- 6 files changed, 111 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/board_connected_tests.rs b/crates/engine/src/board_connected_tests.rs index d6b962449..d23129c19 100644 --- a/crates/engine/src/board_connected_tests.rs +++ b/crates/engine/src/board_connected_tests.rs @@ -116,7 +116,18 @@ fn the_next_one_goes_where_it_is_dropped() { let (b, c) = (id(&r, "id"), id(&r, "connector")); assert_eq!(at(&s, b), (600.0, 0.0), "centred on the point"); let k = connector(&s, c); - assert_eq!((k.from, k.to), (End::Node { node: a, side: Side::Auto, at: None }, End::Node { node: b, side: Side::Auto, at: None })); + assert_eq!( + (k.from, k.to), + (End::Node { node: a, side: Side::Bottom, at: None }, End::Node { node: b, side: Side::Auto, at: None }), + "leaves by the handle's side" + ); + // Inside a frame at (1000, 500): the page point lands in the frame's coordinates. + let f = id(&s.execute("frame.create", &json!({"x": 1000, "y": 500, "width": 800, "height": 600})).unwrap(), "id"); + let inner = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0, "width": 200, "height": 120, "parent": f})).unwrap(), "id"); + let r = s.execute("board.addConnected", &json!({"id": inner, "at": [1300, 700]})).unwrap(); + let placed = id(&r, "id"); + assert_eq!(node(&s, placed).parent, Some(f), "beside it, in the frame"); + assert_eq!(at(&s, placed), (200.0, 140.0), "centred on the page point, in the frame's coordinates"); for wrong in [json!([1]), json!("x"), json!([0, "y"]), json!({"x": 1, "y": 2})] { assert!(s.execute("board.addConnected", &json!({"id": a, "at": wrong})).is_err(), "{wrong}"); } diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs index d6c5dc255..1f10f5d94 100644 --- a/crates/engine/src/boards/board.rs +++ b/crates/engine/src/boards/board.rs @@ -34,11 +34,34 @@ use connectors::{connector_cmd, reattach}; pub use derived::route; pub(crate) use derived::{settle_routes, settle_stamps, settle_text, stamp_target}; -/// The sticky, shape or other object at `p` on `page`: the topmost layer of the page whose box -/// holds it (stamps, connectors, their labels and ink aside), other than `except`. What a stamp -/// sticks to, and what a "+" handle dragged onto it joins (#1919). -pub fn object_at(doc: &Document, page: NodeId, p: Point, except: Option) -> Option { - stamp_target(doc, page, p, except) +/// The topmost sticky or shape at `p` on `page`, other than `except`, sections and frames looked +/// into: what a "+" handle dragged onto it joins (#1919). +pub fn sticky_or_shape_at(doc: &Document, page: NodeId, p: Point, except: Option) -> Option { + fn find(doc: &Document, id: NodeId, p: Point, except: Option, depth: usize) -> Option { + let n = doc.node(id)?; + for c in n.children.iter().rev() { + let Some(k) = doc.node(*c) else { continue }; + if !k.visible { + continue; + } + match k.board { + Some(BoardItem::Sticky { .. } | BoardItem::Shape { .. }) => { + if Some(*c) != except && doc.world_bounds(*c).is_some_and(|b| b.contains(p)) { + return Some(*c); + } + } + // Not a board object of its own (a section, a frame): what is inside it may be. + None if depth < 32 => { + if let Some(hit) = find(doc, *c, p, except, depth + 1) { + return Some(hit); + } + } + _ => {} + } + } + None + } + find(doc, page, p, except, 0) } pub use edit::label_of; pub(crate) use edit::{copied_items, copy_map, rejoin}; @@ -121,7 +144,7 @@ pub fn specs() -> Vec { spec!( "board.addConnected", "Add connected shape", - "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), at? ([x, y] on the page: the next one's centre, anywhere, instead of its place on that side; the connector then takes the sides facing each other), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector", + "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), at? ([x, y] on the page: the next one's centre, anywhere, instead of its place on that side; the connector then leaves by `side` and arrives by the side facing it), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector", true, add_connected ), diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index 981a8399e..b409eeb90 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -224,9 +224,16 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result { let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side) .ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?; // Dropped somewhere by hand ("drag the preview to any location on your board", #1919): the - // next one is centred there, and the connector joins the sides facing each other. + // next one is centred on that point of the page (placed in the parent's coordinates), and the + // connector leaves by the handle's side and arrives by the side facing it. let (to_at, from_side, to_side) = match drop_point(cmd, p)? { - Some((x, y)) => ((x - next.0 / 2.0, y - next.1 / 2.0), Side::Auto, Side::Auto), + Some((x, y)) => { + let local = doc.world_transform(parent).inverse() * figcraft_geom::Point::new(x, y); + if !(local.x.is_finite() && local.y.is_finite()) { + return Err(bad(cmd, "`at` can't be placed in that parent")); + } + ((local.x - next.0 / 2.0, local.y - next.1 / 2.0), from_side, Side::Auto) + } None => (to_at, from_side, to_side), }; let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?); diff --git a/crates/ui-egui/src/board_connect_tests.rs b/crates/ui-egui/src/board_connect_tests.rs index f3e4dc612..7d109dbb3 100644 --- a/crates/ui-egui/src/board_connect_tests.rs +++ b/crates/ui-egui/src/board_connect_tests.rs @@ -193,3 +193,46 @@ fn dropping_a_handle_on_another_shape_joins_them() { let line = doc.ids().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector"); assert!(matches!(&doc.node(line).unwrap().board, Some(BoardItem::Connector(c)) if c.from.node() == Some(a) && c.to.node() == Some(b))); } + +/// A sticky or shape inside a section or frame takes the drop too (review): the connector joins it. +#[test] +fn dropping_a_handle_on_a_shape_inside_a_frame_joins_it() { + let (mut u, a) = board_with_shape(); + let f = id(&u.app.session.execute("frame.create", &json!({"x": 300, "y": 200, "width": 500, "height": 400})).unwrap()); + let b = id(&u + .app + .session + .execute("board.shape", &json!({"shape": "ellipse", "x": 100, "y": 100, "width": 160, "height": 100, "parent": f})) + .unwrap()); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let from = handle(&u, "right").expect("a handle right").center(); + let to = screen_box(&u, b).center(); + u.drag_to(from, to); + u.button(to, false); + u.frame(vec![]); + let doc = u.app.session.doc().unwrap(); + let line = doc.ids().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector"); + assert!( + matches!(&doc.node(line).unwrap().board, Some(BoardItem::Connector(c)) if c.from.node() == Some(a) && c.to.node() == Some(b)), + "joined to the shape in the frame, not the frame" + ); +} + +/// A handle let go off the canvas, over a panel, makes nothing (review). +#[test] +fn letting_a_handle_go_over_a_panel_makes_nothing() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let before = u.app.session.doc().unwrap().ids().count(); + let from = handle(&u, "left").expect("a handle left").center(); + // The left sidebar. + let to = Pos2::new(u.app.canvas.rect.min.x - 60.0, from.y); + u.drag_to(from, to); + u.button(to, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "nothing made"); +} diff --git a/crates/ui-egui/src/canvas/board_connect.rs b/crates/ui-egui/src/canvas/board_connect.rs index aaac45af9..4ea0d9a4d 100644 --- a/crates/ui-egui/src/canvas/board_connect.rs +++ b/crates/ui-egui/src/canvas/board_connect.rs @@ -2,12 +2,13 @@ //! where the next one goes; clicking it makes the next of the same kind there, joined by a //! connector, and starts typing in it (`board.addConnected`). Dragging one carries the preview //! ("drag the preview to any location on your board", #1919): let go on empty board and the next -//! one is made there, joined; let go on another sticky or shape and a connector joins the two. +//! one is made there, joined by a connector leaving by the handle's side; let go on another sticky +//! or shape (inside a section too) and a connector joins the two; let go off the canvas, nothing. use egui::{Area, Color32, CornerRadius, Id, LayerId, Order, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, pos2, vec2}; use figcraft_doc::{BoardItem, NodeId}; use figcraft_engine::ToolId; -use figcraft_engine::board::{STICKY, connected_place, object_at}; +use figcraft_engine::board::{STICKY, connected_place, sticky_or_shape_at}; use serde_json::json; use crate::FigApp; @@ -149,9 +150,16 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(Drag { from, side }) = drag.filter(|d| d.from == id) else { return }; let Some(pointer) = ctx.input(|i| i.pointer.latest_pos()) else { return }; let over = object_under(app, from, pointer); - drag_preview(app, ctx, (b, next, side), pointer, over); + // Off the canvas (over a panel) there is nothing to show and nothing to make. + let on_canvas = app.canvas.rect.contains(pointer); + if on_canvas { + drag_preview(app, ctx, (b, next, side), pointer, over); + } if let_go { ctx.data_mut(|d| d.remove::(drag_id())); + if !on_canvas { + return; + } match over { Some(to) => { app.run("board.connector", json!({"from": from, "to": to, "fromSide": connector_side(side)})); @@ -167,13 +175,14 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } -/// The sticky, shape or other object under the screen point `p` on the canvas, other than `except`. +/// The sticky or shape under the screen point `p` on the canvas (inside a section or frame too), +/// other than `except`. fn object_under(app: &FigApp, except: NodeId, p: Pos2) -> Option { if !app.canvas.rect.contains(p) { return None; } let ds = app.session.ds().ok()?; - object_at(&ds.doc, ds.page, app.to_page(p), Some(except)) + sticky_or_shape_at(&ds.doc, ds.page, app.to_page(p), Some(except)) } /// Where the next one goes for `side`, faintly: its box and the connector's line to it. diff --git a/docs/boards-parity.md b/docs/boards-parity.md index dd14d1b9f..115885f0b 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -38,13 +38,13 @@ all end with the same geometry. | Shapes with text | done | 23 outlines: rectangle, rounded rectangle, ellipse, diamond, triangle up and down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, chevron, speech bubble, and the flowchart symbols database, document, predefined process, manual input and internal storage; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, the advanced shape libraries (engineering and other icon shapes) | missing | | | Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | -| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before. Dragging a "+" carries the preview ("drag the preview to any location on your board"): let go on empty board and the next one is made there, centred on the pointer, joined by a connector whose ends take the sides facing each other (`at: [x, y]`); let go on another sticky or shape and a connector joins the two instead, the object outlined while over it (`board_connect_tests`, #1919) | +| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before. Dragging a "+" carries the preview ("drag the preview to any location on your board"): let go on empty board and the next one is made there, centred on the pointer, joined by a connector leaving by the handle's side (`at: [x, y]`, a point of the page); let go on another sticky or shape, inside a section too, and a connector joins the two instead, the object outlined while over it; let go off the canvas, nothing (`board_connect_tests`, #1919) | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side | | Connector paths reshaped by hand | done | a selected elbow or curved connector shows path handles: one on each run long enough to take one, one in the middle of a curve. Dragging a run moves it, and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins and is saved with the file; changing the style or moving an end to something else goes back to routing itself. No way back by itself otherwise: the public help describes none | -| Quick-create handles (drag out a connected copy) | missing | | +| Quick-create handles (drag out a connected copy) | done | a "+" dragged carries the preview: the next one is made where it is let go, joined; or a connector joins the sticky or shape it is let go on (#1919) | | Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own | -| Quick-create handles | partial | a "+" beside each side of the one selected sticky or shape makes the next one there, joined by a connector (`board.addConnected`, also Shift+Cmd/Ctrl+Enter); no dragging a handle out to place it | +| Quick-create handles | done | a "+" beside each side of the one selected sticky or shape makes the next one there, joined by a connector (`board.addConnected`, also Shift+Cmd/Ctrl+Enter); dragged, it places the next one where it is let go, or joins the sticky or shape it is let go on (#1919) | | Marker and highlighter | done | the Pencil's stroke fitting with board presets: marker opaque, highlighter wide, see-through and multiplying | | Eraser | done | drag over marker and highlighter strokes to remove them, one undo step | | Pen colours and widths in the toolbar | partial | `tool.marker {color}`, `tool.highlighter {color}`; colour changes on a selected stroke from the contextual bar | @@ -70,7 +70,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer and joined by a connector whose ends take the sides facing each other (`board.addConnected` gains `at: [x, y]`); let go on another sticky or shape, a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (two), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. | +| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas, nothing. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (two), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. | From 96ef28cc67f56421b71b615022a3b6728d29978a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 13:42:03 -0500 Subject: [PATCH 3/4] M12: quick-create drag ends when the selection moves; no drop on the toolbar (#1919) Review: a drag whose object is no longer the selected one is dropped from egui's data at once (it lingered when the selection moved to another sticky or shape and could still act once the first was selected again); a handle let go over the floating toolbar, which lies inside the canvas rectangle, makes nothing, like one let go over a panel. Tests: a_drag_ends_when_the_selection_moves_to_another_shape, letting_a_handle_go_over_the_toolbar_makes_nothing. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/board_connect_tests.rs | 39 ++++++++++++++++++++++ crates/ui-egui/src/canvas/board_connect.rs | 12 +++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/board_connect_tests.rs b/crates/ui-egui/src/board_connect_tests.rs index 7d109dbb3..b6466a9ff 100644 --- a/crates/ui-egui/src/board_connect_tests.rs +++ b/crates/ui-egui/src/board_connect_tests.rs @@ -236,3 +236,42 @@ fn letting_a_handle_go_over_a_panel_makes_nothing() { u.frame(vec![]); assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "nothing made"); } + +/// A handle let go over the floating toolbar at the bottom of the canvas makes nothing either +/// (review): the toolbar lies inside the canvas rectangle. +#[test] +fn letting_a_handle_go_over_the_toolbar_makes_nothing() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let before = u.app.session.doc().unwrap().ids().count(); + let from = handle(&u, "below").expect("a handle below").center(); + let to = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app)).center(); + u.drag_to(from, to); + u.button(to, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "nothing made behind the toolbar"); +} + +/// The selection moving to another shape under a drag ends the drag (review): letting go later, +/// even with the first shape selected again, makes nothing. +#[test] +fn a_drag_ends_when_the_selection_moves_to_another_shape() { + let (mut u, a) = board_with_shape(); + let b = id(&u.app.session.execute("board.shape", &json!({"shape": "ellipse", "x": 600, "y": 0, "width": 160, "height": 100})).unwrap()); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let before = u.app.session.doc().unwrap().ids().count(); + let from = handle(&u, "below").expect("a handle below").center(); + let to = from + egui::vec2(0.0, 160.0); + u.drag_to(from, to); + u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); + u.frame(vec![Event::PointerMoved(to)]); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![Event::PointerMoved(to)]); + u.button(to, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "the drag was over"); +} diff --git a/crates/ui-egui/src/canvas/board_connect.rs b/crates/ui-egui/src/canvas/board_connect.rs index 4ea0d9a4d..b78946ef3 100644 --- a/crates/ui-egui/src/canvas/board_connect.rs +++ b/crates/ui-egui/src/canvas/board_connect.rs @@ -147,11 +147,17 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } // A drag carries the preview: the next one centred on the pointer, or the object under it, // which the connector joins instead. - let Some(Drag { from, side }) = drag.filter(|d| d.from == id) else { return }; + let Some(Drag { from, side }) = drag else { return }; + // The selection moved to another sticky or shape under the drag: it is over too. + if from != id { + ctx.data_mut(|d| d.remove::(drag_id())); + return; + } let Some(pointer) = ctx.input(|i| i.pointer.latest_pos()) else { return }; let over = object_under(app, from, pointer); - // Off the canvas (over a panel) there is nothing to show and nothing to make. - let on_canvas = app.canvas.rect.contains(pointer); + // Off the canvas (over a panel) or over the floating toolbar there is nothing to show and + // nothing to make. + let on_canvas = app.canvas.rect.contains(pointer) && !toolbar.contains(pointer); if on_canvas { drag_preview(app, ctx, (b, next, side), pointer, over); } From 5eb28b310adb6ac9872e69fee0d926198768eea6 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 14:47:46 -0500 Subject: [PATCH 4/4] M12: the bar above the selection stays away while a quick-create handle is dragged (#1919) Review: letting a handle go over the selection bar made the next shape behind it. The bar hides while a "+" is dragged, as it does for any drag (board_connect::dragging), so there is nothing to drop behind; the drop makes the next one where the bar was, and the bar is back once typing in it ends. Test the_bar_stays_away_while_a_handle_is_dragged; the docs rows say the toolbar and the bar. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/board_connect_tests.rs | 34 ++++++++++++++++++++++ crates/ui-egui/src/canvas/board_bar.rs | 5 ++-- crates/ui-egui/src/canvas/board_connect.rs | 9 +++++- docs/boards-parity.md | 4 +-- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/board_connect_tests.rs b/crates/ui-egui/src/board_connect_tests.rs index b6466a9ff..fffa1f61c 100644 --- a/crates/ui-egui/src/board_connect_tests.rs +++ b/crates/ui-egui/src/board_connect_tests.rs @@ -34,6 +34,12 @@ fn handle(u: &Ui, side: &str) -> Option { u.ctx.memory(|m| m.areas().visible_last_frame(&layer)).then(|| u.ctx.memory(|m| m.area_rect(layer.id))).flatten() } +/// Where the bar above the selection shows, if it does. +fn bar(u: &Ui) -> Option { + let layer = LayerId::new(Order::Middle, Id::unique("board_bar")); + u.ctx.memory(|m| m.areas().visible_last_frame(&layer)).then(|| u.ctx.memory(|m| m.area_rect(layer.id))).flatten() +} + fn click(u: &mut Ui, p: Pos2) { for pressed in [true, false] { u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); @@ -275,3 +281,31 @@ fn a_drag_ends_when_the_selection_moves_to_another_shape() { u.frame(vec![]); assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "the drag was over"); } + +/// The bar above the selection stays away while a handle is dragged, as for any drag (review): +/// letting go where it was makes the next one there, not behind it, and the bar is back after. +#[test] +fn the_bar_stays_away_while_a_handle_is_dragged() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let r = bar(&u).expect("the bar shows"); + let from = handle(&u, "above").expect("a handle above").center(); + let to = r.center(); + u.drag_to(from, to); + assert!(bar(&u).is_none(), "no bar while a handle is dragged"); + u.button(to, false); + u.frame(vec![]); + let doc = u.app.session.doc().unwrap(); + let next = doc.ids().find(|i| *i != a && matches!(doc.node(*i).unwrap().board, Some(BoardItem::Shape { .. }))).expect("a new shape"); + let line = doc.ids().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector"); + let centre = screen_box(&u, next).center(); + assert!((centre - to).length() < 1.0, "centred where the bar was: {centre:?}, not {to:?}"); + assert!(matches!(&doc.node(line).unwrap().board, Some(BoardItem::Connector(c)) if c.from.node() == Some(a) && c.to.node() == Some(next))); + // Typing in the new one keeps the bar away, as before; done typing, it is back. + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + assert!(bar(&u).is_some(), "the bar is back for the new selection"); +} diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index f9a43935c..d85f14901 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -191,10 +191,11 @@ pub(crate) fn place(sel: Rect, size: egui::Vec2, canvas: Rect) -> Pos2 { pos2(cx - w / 2.0, y) } -/// The bar above a board's selection, while nothing is being dragged and the file can be edited. +/// The bar above a board's selection, while nothing is being dragged (a "+" handle included) and +/// the file can be edited. pub fn show(app: &mut FigApp, ctx: &egui::Context) { let editable = app.session.active_read_only().is_none(); - if app.session.tool.busy() || !editable || !app.session.doc().is_ok_and(figcraft_engine::board::is_board) { + if app.session.tool.busy() || !editable || !app.session.doc().is_ok_and(figcraft_engine::board::is_board) || crate::board_connect::dragging(ctx) { return; } let Some(h) = has(app) else { return }; diff --git a/crates/ui-egui/src/canvas/board_connect.rs b/crates/ui-egui/src/canvas/board_connect.rs index b78946ef3..ea96e922a 100644 --- a/crates/ui-egui/src/canvas/board_connect.rs +++ b/crates/ui-egui/src/canvas/board_connect.rs @@ -3,7 +3,8 @@ //! connector, and starts typing in it (`board.addConnected`). Dragging one carries the preview //! ("drag the preview to any location on your board", #1919): let go on empty board and the next //! one is made there, joined by a connector leaving by the handle's side; let go on another sticky -//! or shape (inside a section too) and a connector joins the two; let go off the canvas, nothing. +//! or shape (inside a section too) and a connector joins the two; let go off the canvas or over the +//! toolbar, nothing. The bar above the selection stays away while a "+" is dragged, as for any drag. use egui::{Area, Color32, CornerRadius, Id, LayerId, Order, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, pos2, vec2}; use figcraft_doc::{BoardItem, NodeId}; @@ -55,6 +56,12 @@ pub(crate) fn shown(app: &FigApp) -> bool { target(app).is_some() } +/// A "+" is being dragged: the bar above the selection stays away meanwhile, as for any drag, so +/// nothing is made behind it. +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(drag_id())).is_some() +} + /// The centre of the "+" for `side` of the object's screen box `r`. fn handle_at(r: Rect, side: &str) -> Pos2 { match side { diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 115885f0b..2ea4f6e95 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -38,7 +38,7 @@ all end with the same geometry. | Shapes with text | done | 23 outlines: rectangle, rounded rectangle, ellipse, diamond, triangle up and down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, chevron, speech bubble, and the flowchart symbols database, document, predefined process, manual input and internal storage; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, the advanced shape libraries (engineering and other icon shapes) | missing | | | Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | -| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before. Dragging a "+" carries the preview ("drag the preview to any location on your board"): let go on empty board and the next one is made there, centred on the pointer, joined by a connector leaving by the handle's side (`at: [x, y]`, a point of the page); let go on another sticky or shape, inside a section too, and a connector joins the two instead, the object outlined while over it; let go off the canvas, nothing (`board_connect_tests`, #1919) | +| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before. Dragging a "+" carries the preview ("drag the preview to any location on your board"): let go on empty board and the next one is made there, centred on the pointer, joined by a connector leaving by the handle's side (`at: [x, y]`, a point of the page); let go on another sticky or shape, inside a section too, and a connector joins the two instead, the object outlined while over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile (`board_connect_tests`, #1919) | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side | | Connector paths reshaped by hand | done | a selected elbow or curved connector shows path handles: one on each run long enough to take one, one in the middle of a curve. Dragging a run moves it, and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins and is saved with the file; changing the style or moving an end to something else goes back to routing itself. No way back by itself otherwise: the public help describes none | @@ -70,7 +70,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas, nothing. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (two), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. | +| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. |