M12: a connector end on any part of an object with Cmd/Ctrl (#1733)

This commit is contained in:
BFlat
2026-10-31 10:27:56 +07:00
committed by GitHub
parent 6bf38fa963
commit 12e9b409ed
22 changed files with 257 additions and 54 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ fn a_shape_gets_the_next_one_joined_on_each_side() {
assert!(matches!(nb.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })), "{side}: the same shape");
assert_eq!(colour(&s, b), colour(&s, a), "{side}: the same colour");
let k = connector(&s, c);
assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0 }, End::Node { node: b, side: ends.1 }), "{side}");
assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0, at: None }, End::Node { node: b, side: ends.1, at: None }), "{side}");
assert_eq!(k.routing, s.tool.board.routing, "{side}: as the connector tool makes one");
assert_eq!(s.selection(), vec![b], "{side}: the new one is selected");
}
@@ -93,7 +93,7 @@ fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_wer
let (b, c) = (id(&r, "id"), id(&r, "connector"));
assert!(matches!(node(&s, b).board, Some(BoardItem::Shape { shape: BoardShape::Rectangle })));
assert_eq!(at(&s, b), (200.0 + CONNECTED_GAP, 0.0));
assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right });
assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right, at: None });
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
// Another side, as the command takes one.
let r = s.execute("board.nextSticky", &json!({"id": a, "side": "below"})).unwrap();
+10 -4
View File
@@ -25,7 +25,7 @@ fn three() -> (Session, [figcraft_doc::NodeId; 4]) {
fn reattach_moves_one_end_and_undoes() {
let (mut s, [a, b, c, k]) = three();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap();
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top });
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top, at: None });
assert_eq!(connector(&s, k).from.node(), Some(a));
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k));
s.execute("edit.undo", &json!({})).unwrap();
@@ -257,7 +257,7 @@ fn an_end_dropped_along_a_side_attaches_to_that_side() {
let want = (side != Side::Auto).then(|| side.point(bounds)).map(|p| [p.x, p.y]);
assert_eq!(lit, want.into_iter().collect::<Vec<_>>(), "{drop:?}");
s.pointer(&ev(PointerKind::Up, drop)).unwrap();
assert_eq!(connector(&s, k).from, End::Node { node: a, side }, "{drop:?}");
assert_eq!(connector(&s, k).from, End::Node { node: a, side, at: None }, "{drop:?}");
if side != Side::Auto {
assert_eq!(drawn(&s, k)[0], side.point(bounds), "the route leaves from that side");
}
@@ -277,7 +277,10 @@ fn the_connector_tool_takes_the_side_it_starts_and_ends_on() {
s.pointer(&ev(PointerKind::Move, 590.0, 330.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 590.0, 330.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Bottom }, End::Node { node: b, side: Side::Right }));
assert_eq!(
(connector(&s, k).from, connector(&s, k).to),
(End::Node { node: a, side: Side::Bottom, at: None }, End::Node { node: b, side: Side::Right, at: None })
);
}
#[test]
@@ -300,6 +303,9 @@ fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top }));
assert_eq!(
(connector(&s, k).from, connector(&s, k).to),
(End::Node { node: a, side: Side::Right, at: None }, End::Node { node: b, side: Side::Top, at: None })
);
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
}
+2 -2
View File
@@ -71,8 +71,8 @@ fn a_reshaped_connector_keeps_the_sides_it_left_by() {
let (portion, at) = handles[handles.len() / 2];
reshape(&mut s, k, portion as f64, at.x + 30.0, at.y + 30.0).unwrap();
let c = connector(&s, k);
assert!(matches!(c.from, End::Node { node, side } if node == a && side != Side::Auto), "{c:?}");
assert!(matches!(c.to, End::Node { node, side } if node == b && side != Side::Auto), "{c:?}");
assert!(matches!(c.from, End::Node { node, side, .. } if node == a && side != Side::Auto), "{c:?}");
assert!(matches!(c.to, End::Node { node, side, .. } if node == b && side != Side::Auto), "{c:?}");
}
/// "For the start and end portions of a connector, dragging can add an extra portion to the path."
+6 -1
View File
@@ -201,7 +201,12 @@ fn connectors_follow_what_they_join_and_undo_with_it() {
fn routes_are_the_same_for_the_same_document() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id");
let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow, path: None };
let c = Connector {
from: End::Node { node: a, side: Side::Bottom, at: None },
to: End::Point { x: 300.0, y: 500.0 },
routing: Routing::Elbow,
path: None,
};
let doc = s.doc().unwrap();
let r = route(doc, &c).unwrap();
assert_eq!(r, route(&doc.clone(), &c).unwrap());
+1 -1
View File
@@ -97,7 +97,7 @@ fn routes_take_named_sides_and_free_points() {
let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id");
let doc = s.doc().unwrap();
let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow, path: None }).unwrap();
let side = |n, side| End::Node { node: n, side };
let side = |n, side| End::Node { node: n, side, at: None };
let p = |x, y| End::Point { x, y };
let pt = Point::new;
// Side to side: out, across halfway, in.
+2 -2
View File
@@ -128,14 +128,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.connector",
"Connector",
"{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins",
"{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), fromAt?, toAt? ([x, y] on the layer, fractions of its box), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins",
true,
connector_cmd
),
spec!(
"board.reattach",
"Reattach connector",
"{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector",
"{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left), at? ([x, y] on the layer, fractions of its box)} → {id} moves one end of a connector",
true,
reattach
),
+27 -6
View File
@@ -6,8 +6,21 @@ use super::{
VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string,
};
/// One connector end from the params: a layer on this page, or a point.
fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result<End> {
/// Where on a layer a connector end sits, from the params: `[x, y]`, fractions of its box.
fn at_param(cmd: &str, p: &Value, key: &str) -> Result<Option<[f64; 2]>> {
let Some(v) = get(p, key).filter(|v| !v.is_null()) else { return Ok(None) };
match v.as_array().map(Vec::as_slice) {
Some([x, y]) => match (x.as_f64().filter(|x| x.is_finite()), y.as_f64().filter(|y| y.is_finite())) {
(Some(x), Some(y)) => Ok(Some([x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)])),
_ => Err(bad(cmd, format!("`{key}` is [x, y], fractions of the layer's box"))),
},
_ => Err(bad(cmd, format!("`{key}` is [x, y], fractions of the layer's box"))),
}
}
/// One connector end from the params (`keys`: its target, side and place on the layer): a layer on
/// this page, or a point.
fn end_param(cmd: &str, s: &Session, p: &Value, [key, side_key, at_key]: [&str; 3]) -> Result<End> {
let side = match string(p, side_key) {
Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?,
None => Side::Auto,
@@ -30,7 +43,7 @@ fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Re
if matches!(n.board, Some(BoardItem::Connector(_))) {
return Err(bad(cmd, format!("`{key}` can't be another connector")));
}
Ok(End::Node { node, side })
Ok(End::Node { node, side, at: at_param(cmd, p, at_key)? })
}
None => Err(bad(cmd, format!("`{key}` is required"))),
}
@@ -39,8 +52,8 @@ fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Re
pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.connector";
require(s, cmd)?;
let from = end_param(cmd, s, p, "from", "fromSide")?;
let to = end_param(cmd, s, p, "to", "toSide")?;
let from = end_param(cmd, s, p, ["from", "fromSide", "fromAt"])?;
let to = end_param(cmd, s, p, ["to", "toSide", "toAt"])?;
if from.node().is_some() && from.node() == to.node() {
return Err(bad(cmd, "connect two different layers"));
}
@@ -106,7 +119,7 @@ pub(super) fn reattach(s: &mut Session, p: &Value) -> Result<Value> {
Some("to") => false,
_ => return Err(bad(cmd, "`end` is from or to")),
};
let end = end_param(cmd, s, p, "target", "side")?;
let end = end_param(cmd, s, p, ["target", "side", "at"])?;
let other = if from { c.to } else { c.from };
if end.node().is_some() && end.node() == other.node() {
return Err(bad(cmd, "connect two different layers"));
@@ -141,6 +154,14 @@ pub(super) fn side_json(e: End) -> Value {
}
}
/// Where a connector end sits on its layer, as the commands take it.
pub(super) fn at_json(e: End) -> Value {
match e {
End::Node { at: Some(at), .. } => json!(at),
_ => Value::Null,
}
}
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
match &doc.node(id)?.board {
+4 -1
View File
@@ -297,7 +297,10 @@ pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
pub(super) fn route_ends(doc: &Document, c: &Connector) -> Option<(RouteEnd, RouteEnd)> {
let end = |e: &End| match e {
End::Point { x, y } => Some(RouteEnd::Point(Point::new(*x, *y))),
End::Node { node, side } => doc.world_bounds(*node).map(|b| RouteEnd::Box(b, *side)),
End::Node { node, side, at } => doc.world_bounds(*node).map(|b| match at {
Some([x, y]) => RouteEnd::On(b, *side, Point::new(b.x0 + x * b.width(), b.y0 + y * b.height())),
None => RouteEnd::Box(b, *side),
}),
};
Some((end(&c.from)?, end(&c.to)?))
}
+6 -1
View File
@@ -232,7 +232,12 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
None => make_sticky(doc, parent, to_at, "", color, &author)?,
Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?,
};
let connector = Connector { from: End::Node { node: from, side: from_side }, to: End::Node { node: id, side: to_side }, routing, path: None };
let connector = Connector {
from: End::Node { node: from, side: from_side, at: None },
to: End::Node { node: id, side: to_side, at: None },
routing,
path: None,
};
let line = make(doc, page, connector, CAPS)?;
s.set_selection(vec![id]);
if boolean(p, "edit") == Some(true) {
+1 -1
View File
@@ -16,7 +16,7 @@ const MIN_RUN_PX: f64 = 28.0;
/// `end` with the side it leaves by named: a reshaped path holds only as long as its ends do.
fn pinned(end: End, side: Option<Side>) -> End {
match (end, side) {
(End::Node { node, .. }, Some(side)) => End::Node { node, side },
(End::Node { node, at, .. }, Some(side)) => End::Node { node, side, at },
_ => end,
}
}
+16 -6
View File
@@ -1,4 +1,4 @@
use super::connectors::{CAPS, end_json, make, other_end, side_json};
use super::connectors::{CAPS, at_json, end_json, make, other_end, side_json};
use super::derived::{ends_now, route};
use super::edit::label_of;
use super::items::edit_text;
@@ -10,6 +10,11 @@ use super::{
/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector
/// end to attach there, however small the object is on screen.
const ATTACH_PX: f64 = 10.0;
/// Point `p` as fractions of box `b` (`None`: a box with no area).
fn part_at(b: figcraft_geom::Rect, p: Point) -> Option<[f64; 2]> {
(b.width() > 0.0 && b.height() > 0.0).then(|| [((p.x - b.x0) / b.width()).clamp(0.0, 1.0), ((p.y - b.y0) / b.height()).clamp(0.0, 1.0)])
}
/// The sides an object takes a connector end at (their middles are its attach points).
const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
@@ -92,7 +97,8 @@ impl Session {
}
/// What a connector end at the pointer attaches to: the object there (at the side whose middle
/// is within [`ATTACH_PX`], else the side [`side_at`] the pointer), or the point.
/// is within [`ATTACH_PX`], else the side [`side_at`] the pointer; with Cmd/Ctrl held, right
/// there), or the point.
fn end_at(&self, ev: &PointerEvent) -> End {
let zoom = ev.zoom.max(1e-6);
let hit = (|| {
@@ -109,7 +115,10 @@ impl Session {
let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX);
let near = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map(|(s, _)| s);
let side = near.or(b.map(|b| side_at(b, ev.p()))).unwrap_or(Side::Auto);
Some(End::Node { node: t, side })
match b.and_then(|b| part_at(b, ev.p())).filter(|_| ev.cmd) {
Some(at) => Some(End::Node { node: t, side: Side::Auto, at: Some(at) }),
None => Some(End::Node { node: t, side, at: None }),
}
})();
hit.unwrap_or(End::Point { x: ev.x, y: ev.y })
}
@@ -128,7 +137,7 @@ impl Session {
/// (`at`, else its named side's) lit.
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
let attach = match end {
Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
Some(End::Node { node, side, .. }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
}),
@@ -179,7 +188,8 @@ impl Session {
self.cancel_interaction()?;
let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) };
let style = self.tool.board.routing.id();
let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style});
let (from_at, to_at) = (at_json(from), at_json(to));
let p = json!({"from": end_json(from), "fromSide": side_json(from), "fromAt": from_at, "to": end_json(to), "toSide": side_json(to), "toAt": to_at, "style": style});
self.execute("board.connector", &p)?;
self.board_placed();
Ok(())
@@ -214,7 +224,7 @@ impl Session {
return Ok(());
}
let end = if from { "from" } else { "to" };
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?;
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target), "at": at_json(target)}))?;
Ok(())
}
@@ -0,0 +1,100 @@
//! A connector end placed on any part of an object: holding Cmd/Ctrl while moving it puts it right
//! where the pointer is, and it stays at that part of the object when the object moves or resizes.
use figcraft_doc::{End, NodeId, Side};
use figcraft_geom::Point;
use serde_json::json;
use crate::Session;
use crate::board_tests::{board, connector, drawn, id, near};
use crate::tools::{PointerEvent, PointerKind};
/// A board with two 200 × 100 shapes and a straight connector between them: (a, b, connector).
fn scene() -> (Session, NodeId, NodeId, NodeId) {
let mut s = board();
let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 200, "height": 100})).unwrap(), "id");
let (a, b) = (shape(0.0), shape(500.0));
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id");
s.execute("select.set", &json!({"ids": [k]})).unwrap();
(s, a, b, k)
}
/// Drag the connector's end at `from` to `to`, with Cmd/Ctrl held or not.
fn drag_end(s: &mut Session, from: Point, to: Point, cmd: bool) {
let ev = |kind, p: Point| PointerEvent { cmd, ..PointerEvent::at(kind, p.x, p.y, 1.0) };
s.pointer(&ev(PointerKind::Down, from)).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(300.0, 300.0))).unwrap();
s.pointer(&ev(PointerKind::Move, to)).unwrap();
s.pointer(&ev(PointerKind::Up, to)).unwrap();
}
#[test]
fn cmd_puts_an_end_on_any_part_of_the_object() {
let (mut s, _, b, k) = scene();
let end = *drawn(&s, k).last().unwrap();
drag_end(&mut s, end, Point::new(650.0, 25.0), true);
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Auto, at: Some([0.75, 0.25]) });
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 25.0)), "{:?}", drawn(&s, k));
// It undoes as one step, back to the side.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Auto, at: None });
}
#[test]
fn without_cmd_an_end_keeps_snapping_to_a_side() {
let (mut s, _, b, k) = scene();
let end = *drawn(&s, k).last().unwrap();
drag_end(&mut s, end, Point::new(650.0, 25.0), false);
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Right, at: None });
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0, 50.0)));
}
#[test]
fn the_end_stays_on_that_part_when_the_object_moves_or_resizes() {
let (mut s, _, b, k) = scene();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.5]})).unwrap();
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(600.0, 50.0)));
s.execute("node.set", &json!({"id": b, "props": {"x": 600, "y": 100, "width": 400, "height": 200}})).unwrap();
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(800.0, 200.0)), "{:?}", drawn(&s, k));
}
#[test]
fn an_end_moved_back_without_cmd_leaves_its_place() {
let (mut s, _, b, k) = scene();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.5]})).unwrap();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "side": "top"})).unwrap();
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Top, at: None });
}
#[test]
fn the_commands_take_a_place_and_refuse_junk() {
let (mut s, a, b, _) = scene();
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "fromAt": [0.25, 0.5], "toAt": [9, -1]})).unwrap(), "id");
assert!(matches!(connector(&s, k).from, End::Node { at: Some([0.25, 0.5]), .. }));
assert!(matches!(connector(&s, k).to, End::Node { at: Some([1.0, 0.0]), .. }), "kept on the object");
for junk in [json!("middle"), json!([1]), json!([1, 2, 3]), json!(["x", 0]), json!({"x": 0, "y": 0})] {
assert!(s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": junk})).is_err());
}
}
#[test]
fn a_reshaped_path_keeps_the_end_where_it_is() {
let (mut s, a, b, _) = scene();
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "curved", "toAt": [0.5, 0.5]})).unwrap(), "id");
s.execute("board.reshape", &json!({"id": k, "portion": 0, "x": 350, "y": 200})).unwrap();
assert!(matches!(connector(&s, k).to, End::Node { at: Some([0.5, 0.5]), .. }));
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(600.0, 50.0)), "{:?}", drawn(&s, k));
}
#[test]
fn it_saves_only_when_placed_by_hand() {
let (mut s, _, b, k) = scene();
let saved = |s: &Session| figcraft_format::to_json(s.doc().unwrap()).unwrap().to_string();
assert!(!saved(&s).contains("\"at\":"), "older files stay as they are");
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.25]})).unwrap();
assert!(saved(&s).contains("\"at\":[0.5,0.25]"));
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (open, _) = figcraft_format::load_bytes(&bytes).unwrap();
let kept = open.node(k).and_then(|n| n.board.clone());
assert!(matches!(kept, Some(figcraft_doc::BoardItem::Connector(c)) if matches!(c.to, End::Node { at: Some([0.5, 0.25]), .. })));
}
+2
View File
@@ -263,6 +263,8 @@ mod components_behaviour_tests;
#[cfg(test)]
mod components_scale_tests;
#[cfg(test)]
mod connector_anywhere_tests;
#[cfg(test)]
mod convert_container_tests;
#[cfg(test)]
mod cursor_tests;