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 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-03 12:44:59 -05:00
1 parent d48aa59ec7
commit 246a89733c
6 files changed
+111 -18

No files matched your search

+12 -1
View File
@@ -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}");
}
+29 -6
View File
@@ -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<NodeId>) -> Option<NodeId> {
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<NodeId>) -> Option<NodeId> {
fn find(doc: &Document, id: NodeId, p: Point, except: Option<NodeId>, depth: usize) -> Option<NodeId> {
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<CommandSpec> {
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
),
+9 -2
View File
@@ -224,9 +224,16 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
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()?);
+43
View File
@@ -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");
}
+14 -5
View File
@@ -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>(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<NodeId> {
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.
+4 -4
View File
@@ -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. |