mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
Merge pull request #1947 from TrevorBrown-dev/feat/board-quick-create-drag
Boards: drag a "+" handle to drop the next shape anywhere, or onto another object to join them (#1919)
This commit is contained in:
7 files changed
+410
-16
No files matched your search
@@ -104,3 +104,31 @@ 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::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}");
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,36 @@ 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 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};
|
||||
use edit::{label_cmd, set_color, set_style};
|
||||
@@ -114,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), 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
|
||||
),
|
||||
|
||||
@@ -223,6 +223,19 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 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)) => {
|
||||
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()?);
|
||||
if editing.is_some() {
|
||||
textedit::end(s)?;
|
||||
@@ -246,6 +259,16 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Option<(f64, f64)>> {
|
||||
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,
|
||||
|
||||
@@ -34,6 +34,12 @@ fn handle(u: &Ui, side: &str) -> Option<Rect> {
|
||||
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<Rect> {
|
||||
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 }]);
|
||||
@@ -140,3 +146,166 @@ 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)));
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! 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 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 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, 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, sticky_or_shape_at};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
@@ -18,6 +22,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();
|
||||
@@ -41,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>(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 {
|
||||
@@ -51,8 +72,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<Drag> = 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>(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 +111,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 +130,72 @@ 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 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>(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) 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);
|
||||
}
|
||||
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)}));
|
||||
}
|
||||
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>(drag_id()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
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.
|
||||
@@ -107,9 +210,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<NodeId>) {
|
||||
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)));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boards parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); 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-04 · **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 attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); 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
|
||||
@@ -37,14 +37,16 @@ all end with the same geometry.
|
||||
| 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; an end dropped on the outer half of an object takes the side it is towards (a drop along a whole edge chooses that edge), one in the middle takes the facing side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal and curves leave tangent to it (#1916) |
|
||||
| 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 or over the toolbar, nothing, and the bar above the selection stays away meanwhile (`board_connect_tests`, #1919) |
|
||||
| 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; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal and curves leave tangent to it (#1916) |
|
||||
| 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 |
|
||||
| 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,6 +72,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 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-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). 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. |
|
||||
|
||||
Reference in new issue
Block a user