M12: create the next board shape already connected (#1631)

* M12: create the next board shape already connected (#1597)

A selected sticky or shape shows a + beside each side; hovering one previews where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector, and starts typing in it, in one undo step. board.addConnected {id?, side?, edit?} does it from commands.

* M12: the connected-shape handle and command in every full catalog (#1597)

* M12: Shift+Cmd/Ctrl+Enter on a board shape adds the next one already connected (#1597)
This commit is contained in:
BFlat
2026-10-30 15:59:13 +07:00
committed by GitHub
parent d17de284da
commit 34fce9bfcf
37 changed files with 500 additions and 15 deletions
+106
View File
@@ -0,0 +1,106 @@
//! `board.addConnected` (#1597): the next sticky or shape on a side of the selected one, of the
//! same kind, size and colour, joined to it by a connector, in one undo step.
use figcraft_doc::{BoardItem, BoardShape, Connector, End, NodeId, Side};
use serde_json::json;
use crate::Session;
use crate::board::CONNECTED_GAP;
use crate::board_tests::{board, id, node};
fn connector(s: &Session, c: NodeId) -> Connector {
match &node(s, c).board {
Some(BoardItem::Connector(k)) => *k,
other => panic!("not a connector: {other:?}"),
}
}
fn at(s: &Session, n: NodeId) -> (f64, f64) {
let t = node(s, n).transform.translation();
(t.x, t.y)
}
#[test]
fn a_shape_gets_the_next_one_joined_on_each_side() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120, "color": "blue"})).unwrap(), "id");
let colour = |s: &Session, n: NodeId| node(s, node(s, n).children[0]).fills[0].solid_color();
let g = CONNECTED_GAP;
for (side, want, ends) in [
("right", (200.0 + g, 0.0), (Side::Right, Side::Left)),
("below", (0.0, 120.0 + g), (Side::Bottom, Side::Top)),
("left", (-200.0 - g, 0.0), (Side::Left, Side::Right)),
("above", (0.0, -120.0 - g), (Side::Top, Side::Bottom)),
] {
let r = s.execute("board.addConnected", &json!({"id": a, "side": side})).unwrap();
let (b, c) = (id(&r, "id"), id(&r, "connector"));
assert_eq!(at(&s, b), want, "{side}");
let nb = node(&s, b);
assert_eq!((nb.width, nb.height), (200.0, 120.0), "{side}: the same size");
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.routing, s.tool.board.routing, "{side}: as the connector tool makes one");
assert_eq!(s.selection(), vec![b], "{side}: the new one is selected");
}
}
#[test]
fn a_sticky_gets_a_sticky_of_its_colour_and_typing_goes_on_in_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink", "text": "First"})).unwrap(), "id");
s.ui_requests.clear();
// The selection is the default, and editing is asked for.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("board.addConnected", &json!({"side": "below", "edit": true})).unwrap();
let b = id(&r, "id");
assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { .. })));
assert_eq!(node(&s, b).fills[0].solid_color(), node(&s, a).fills[0].solid_color());
assert_eq!(at(&s, b).1, node(&s, a).height + CONNECTED_GAP);
assert!(s.ui_requests.iter().any(|q| q.kind == "board.editText" && q.data["id"] == json!(b)));
}
#[test]
fn it_is_one_undo_step_and_refuses_what_it_cant_join() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0})).unwrap(), "id");
let before = s.doc().unwrap().ids().count();
let r = s.execute("board.addConnected", &json!({"id": a})).unwrap();
assert_eq!(at(&s, id(&r, "id")).0, 160.0 + CONNECTED_GAP, "right by default");
assert!(s.doc().unwrap().ids().count() > before);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().ids().count(), before, "the shape and its connector go together");
// Not a sticky or shape, not a side, nothing selected.
let c = id(&s.execute("board.stamp", &json!({"x": 400, "y": 0, "stamp": "star"})).unwrap(), "id");
assert!(s.execute("board.addConnected", &json!({"id": c})).is_err());
assert!(s.execute("board.addConnected", &json!({"id": a, "side": "diagonal"})).is_err());
s.execute("select.set", &json!({"ids": []})).unwrap();
assert!(s.execute("board.addConnected", &json!({})).is_err());
// Not on a design file.
let mut d = Session::new();
assert!(d.execute("board.addConnected", &json!({})).is_err());
}
#[test]
fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id");
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let undo = s.ds().unwrap().history.undo.len();
// Shift+Cmd/Ctrl+Enter on a shape: the next shape on the right, joined by a connector (#1597).
let r = s.execute("board.nextSticky", &json!({})).unwrap();
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!(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();
assert_eq!(at(&s, id(&r, "id")), (0.0, 120.0 + CONNECTED_GAP));
// A sticky still gets a sticky beside it, without a connector.
let st = id(&s.execute("board.sticky", &json!({"x": 1000, "y": 0})).unwrap(), "id");
let r = s.execute("board.nextSticky", &json!({"id": st})).unwrap();
assert!(r.get("connector").is_none(), "{r}");
assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. })));
}
+10 -2
View File
@@ -36,8 +36,9 @@ pub use edit::label_of;
pub(crate) use edit::{copied_items, copy_map, rejoin};
use edit::{label_cmd, set_color, set_style};
pub(crate) use items::add_text;
pub use items::{CONNECTED_GAP, connected_place};
use items::{add_connected, ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd};
pub use items::{edit_text, owner_of_text, sticky_font_size, text_of};
use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd};
/// Why a board command refuses on a design file.
pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board";
@@ -103,10 +104,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.nextSticky",
"Next sticky note",
"{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it",
"{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?} a sticky of the same colour beside a sticky; after a shape, the next shape joined to it by a connector, as board.addConnected",
true,
next_sticky
),
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",
true,
add_connected
),
spec!(
"board.shape",
"Shape with text",
+76 -7
View File
@@ -1,9 +1,10 @@
use super::connectors::{CAPS, make};
use super::{
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError,
HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP,
STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork,
author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string,
textedit,
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Connector, Constraints, CrossAlign, Document, Effect, EffectKind, End,
EngineError, HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY,
STICKY_GAP, STICKY_PAD, Session, Shadow, Side, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint,
Value, VectorNetwork, author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of,
stamp_of, string, textedit,
};
/// A new layer at the top of `parent`, at `at` in it, made by `f`.
@@ -144,10 +145,14 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
let from = id(p, "id")
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?;
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?;
let n = doc.get(from)?;
if matches!(n.board, Some(BoardItem::Shape { .. })) {
// A shape gets the next one joined to it, as the "+" beside it makes (#1597).
return add_connected(s, p);
}
if !matches!(n.board, Some(BoardItem::Sticky { .. })) {
return Err(bad(cmd, "that isn't a sticky"));
return Err(bad(cmd, "that isn't a sticky or a shape"));
}
let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky);
let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?;
@@ -172,6 +177,70 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id, "text": note}))
}
/// The room between a board object and the next one `board.addConnected` makes, joined by a
/// connector: enough for the arrow to read.
pub const CONNECTED_GAP: f64 = 80.0;
/// Where `board.addConnected` puts the next object on `side` of one at `at` sized `size`, `next`
/// big (its top-left), and the sides the connector joins (from, to). `None`: not a side.
pub fn connected_place(at: (f64, f64), size: (f64, f64), next: (f64, f64), side: &str) -> Option<((f64, f64), Side, Side)> {
let ((x, y), (w, h), (nw, nh)) = (at, size, next);
let (cx, cy) = (x + (w - nw) / 2.0, y + (h - nh) / 2.0);
Some(match side {
"right" => ((x + w + CONNECTED_GAP, cy), Side::Right, Side::Left),
"left" => ((x - CONNECTED_GAP - nw, cy), Side::Left, Side::Right),
"below" => ((cx, y + h + CONNECTED_GAP), Side::Bottom, Side::Top),
"above" => ((cx, y - CONNECTED_GAP - nh), Side::Top, Side::Bottom),
_ => return None,
})
}
/// `board.addConnected`: the next sticky or shape on a side of one, of the same kind, size and
/// colour, joined to it by a connector the way the connector tool makes one; one undo step.
pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.addConnected";
require(s, cmd)?;
let editing = s.ds()?.text_edit.as_ref().map(|t| t.id);
let doc = s.doc()?;
let from = id(p, "id")
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?;
let n = doc.get(from)?;
let shape = match &n.board {
Some(BoardItem::Sticky { .. }) => None,
Some(BoardItem::Shape { shape }) => Some(*shape),
_ => return Err(bad(cmd, "that isn't a sticky or a shape")),
};
let side = string(p, "side").unwrap_or_else(|| "right".into());
let parent = n.parent.ok_or_else(|| bad(cmd, "that object has no parent"))?;
let at = n.transform.translation();
let colour_of = |n: &Node| n.fills.iter().find_map(Paint::solid_color);
let (next, color) = match shape {
None => ((STICKY, STICKY), colour_of(n)),
Some(_) => ((n.width, n.height), n.children.first().and_then(|c| doc.node(*c)).and_then(colour_of)),
};
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}`")))?;
let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?);
if editing.is_some() {
textedit::end(s)?;
}
let doc = s.doc_mut()?;
let (id, text) = match shape {
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 };
let line = make(doc, page, connector, CAPS)?;
s.set_selection(vec![id]);
if boolean(p, "edit") == Some(true) {
s.request_ui("board.editText", json!({"id": id}));
}
Ok(json!({"id": id, "text": text, "connector": line}))
}
/// A shape with text's layers: the frame, its outline and its text.
fn make_shape(
doc: &mut Document,
+1 -1
View File
@@ -201,7 +201,7 @@ pub static KEYS: &[Binding] = &[
k(Tools, "tool.highlighter", &["U"]),
k(Tools, "tool.eraser", &["E"]),
k(Tools, "tool.stamp", &["W"]),
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(),
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one, or the next shape joined to it").both(),
// View
// The documentation gives both keys for the pixel grid in different places.
k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]),
+2
View File
@@ -201,6 +201,8 @@ mod batch_undo_snapshot_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod board_connected_tests;
#[cfg(test)]
mod board_connector_tests;
#[cfg(test)]
mod board_grid_tests;