mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 07:39:20 +00:00
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:
1 parent
d17de284da
commit
34fce9bfcf
37 files changed
+500
-15
No files matched your search
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer anLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-valiLine truncated
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -356,6 +356,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. |
|
||||
| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). |
|
||||
| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). |
|
||||
| 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
|
||||
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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+'"]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -39,6 +39,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "الحجم الفعلي (100%)"
|
||||
"Add a category" = "إضافة فئة"
|
||||
"Add a comment" = "إضافة تعليق"
|
||||
"Add a connected shape" = "إضافة شكل متصل"
|
||||
"Add a description" = "إضافة وصف"
|
||||
"Add a frame (F) or a shape (R) to get started" = "أضف إطارًا (F) أو شكلًا (R) للبدء"
|
||||
"Add a layer or component to tile first" = "أضف طبقة أو مكوّنًا أولًا لتكراره بنمط متجاور"
|
||||
@@ -1907,6 +1908,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "تقريب إلى البكسلات"
|
||||
"arrange.spacing" = "التباعد"
|
||||
"arrange.tidy" = "ترتيب منظم"
|
||||
"board.addConnected" = "إضافة شكل متصل"
|
||||
"board.connector" = "موصل"
|
||||
"board.ink" = "ضربة قلم التحديد"
|
||||
"board.label" = "تسمية الموصل"
|
||||
|
||||
@@ -34,6 +34,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "প্রকৃত আকার (১০০%)"
|
||||
"Add a category" = "একটি বিভাগ যোগ করুন"
|
||||
"Add a comment" = "একটি মন্তব্য যোগ করুন"
|
||||
"Add a connected shape" = "সংযুক্ত আকার যোগ করুন"
|
||||
"Add a description" = "একটি বিবরণ যোগ করুন"
|
||||
"Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন"
|
||||
"Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন"
|
||||
@@ -1822,6 +1823,7 @@ status = "draft — needs native review"
|
||||
|
||||
|
||||
[commands]
|
||||
"board.addConnected" = "সংযুক্ত আকার যোগ করুন"
|
||||
"board.connector" = "সংযোগকারী"
|
||||
"board.ink" = "মার্কারের টান"
|
||||
"board.label" = "সংযোগকারীর লেবেল"
|
||||
|
||||
@@ -30,6 +30,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Skutečná velikost (100 %)"
|
||||
"Add a category" = "Přidat kategorii"
|
||||
"Add a comment" = "Přidat komentář"
|
||||
"Add a connected shape" = "Přidat propojený tvar"
|
||||
"Add a description" = "Přidat popis"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Začněte přidáním rámce (F) nebo tvaru (R)"
|
||||
"Add a layer or component to tile first" = "Nejprve přidejte vrstvu nebo komponentu k opakování"
|
||||
@@ -1809,6 +1810,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Zaokrouhlit na pixely"
|
||||
"arrange.spacing" = "Rozestupy"
|
||||
"arrange.tidy" = "Uspořádat"
|
||||
"board.addConnected" = "Přidat propojený tvar"
|
||||
"board.connector" = "Spojnice"
|
||||
"board.ink" = "Tah fixem"
|
||||
"board.label" = "Popisek spojnice"
|
||||
|
||||
@@ -31,6 +31,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Originalgröße (100 %)"
|
||||
"Add a category" = "Kategorie hinzufügen"
|
||||
"Add a comment" = "Kommentar hinzufügen"
|
||||
"Add a connected shape" = "Verbundene Form hinzufügen"
|
||||
"Add a description" = "Beschreibung hinzufügen"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Fügen Sie zum Start einen Rahmen (F) oder eine Form (R) hinzu"
|
||||
"Add a layer or component to tile first" = "Fügen Sie zuerst eine Ebene oder Komponente zum Kacheln hinzu"
|
||||
@@ -1767,6 +1768,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Auf Pixel runden"
|
||||
"arrange.spacing" = "Abstand"
|
||||
"arrange.tidy" = "Aufräumen"
|
||||
"board.addConnected" = "Verbundene Form hinzufügen"
|
||||
"board.connector" = "Verbinder"
|
||||
"board.ink" = "Markerstrich"
|
||||
"board.label" = "Verbinderbeschriftung"
|
||||
|
||||
@@ -33,6 +33,7 @@ name = "English"
|
||||
"Actual size (100%)" = "Actual size (100%)"
|
||||
"Add a category" = "Add a category"
|
||||
"Add a comment" = "Add a comment"
|
||||
"Add a connected shape" = "Add a connected shape"
|
||||
"Add a description" = "Add a description"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Add a frame (F) or a shape (R) to get started"
|
||||
"Add a layer or component to tile first" = "Add a layer or component to tile first"
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Español"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"Add a connected shape" = "Añadir una forma conectada"
|
||||
"Add a minute" = "Añadir un minuto"
|
||||
"Add column" = "Añadir columna"
|
||||
"Add measurement" = "Añadir medición"
|
||||
@@ -1895,6 +1896,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Redondear a píxeles"
|
||||
"arrange.spacing" = "Espaciado"
|
||||
"arrange.tidy" = "Ordenar"
|
||||
"board.addConnected" = "Añadir forma conectada"
|
||||
"board.connector" = "Conector"
|
||||
"board.ink" = "Trazo de rotulador"
|
||||
"board.label" = "Etiqueta del conector"
|
||||
|
||||
@@ -35,6 +35,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Todellinen koko (100 %)"
|
||||
"Add a category" = "Lisää luokka"
|
||||
"Add a comment" = "Lisää kommentti"
|
||||
"Add a connected shape" = "Lisää yhdistetty muoto"
|
||||
"Add a description" = "Lisää kuvaus"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Aloita lisäämällä kehys (F) tai muoto (R)"
|
||||
"Add a layer or component to tile first" = "Lisää ensin laatoitettava taso tai komponentti"
|
||||
@@ -1852,6 +1853,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Pyöristä pikseleihin"
|
||||
"arrange.spacing" = "Välistys"
|
||||
"arrange.tidy" = "Järjestä tasavälein"
|
||||
"board.addConnected" = "Lisää yhdistetty muoto"
|
||||
"board.connector" = "Yhdistin"
|
||||
"board.ink" = "Tussiviiva"
|
||||
"board.label" = "Yhdistimen nimiö"
|
||||
|
||||
@@ -34,6 +34,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Taille réelle (100 %)"
|
||||
"Add a category" = "Ajouter une catégorie"
|
||||
"Add a comment" = "Ajouter un commentaire"
|
||||
"Add a connected shape" = "Ajouter une forme reliée"
|
||||
"Add a description" = "Ajouter une description"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Ajoutez un cadre (F) ou une forme (R) pour commencer"
|
||||
"Add a layer or component to tile first" = "Ajoutez d’abord un calque ou un composant à répéter en mosaïque"
|
||||
@@ -1853,6 +1854,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Arrondir aux pixels"
|
||||
"arrange.spacing" = "Espacement"
|
||||
"arrange.tidy" = "Ranger"
|
||||
"board.addConnected" = "Ajouter une forme reliée"
|
||||
"board.connector" = "Connecteur"
|
||||
"board.ink" = "Trait de marqueur"
|
||||
"board.label" = "Libellé du connecteur"
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Bahasa Indonesia"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"Add a connected shape" = "Tambahkan bentuk yang terhubung"
|
||||
"Relative to" = "Relatif terhadap"
|
||||
"Scroll wheel zoom" = "Zoom dengan roda gulir"
|
||||
"Trigger layer" = "Layer pemicu"
|
||||
@@ -1813,6 +1814,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Bulatkan ke piksel"
|
||||
"arrange.spacing" = "Spasi"
|
||||
"arrange.tidy" = "Rapikan"
|
||||
"board.addConnected" = "Tambahkan bentuk terhubung"
|
||||
"board.connector" = "Konektor"
|
||||
"board.ink" = "Goresan spidol"
|
||||
"board.label" = "Label konektor"
|
||||
|
||||
@@ -33,6 +33,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Dimensione reale (100%)"
|
||||
"Add a category" = "Aggiungi una categoria"
|
||||
"Add a comment" = "Aggiungi un commento"
|
||||
"Add a connected shape" = "Aggiungi una forma collegata"
|
||||
"Add a description" = "Aggiungi una descrizione"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Aggiungi un frame (F) o una forma (R) per iniziare"
|
||||
"Add a layer or component to tile first" = "Aggiungi prima un livello o un componente da affiancare"
|
||||
@@ -1788,6 +1789,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Arrotonda ai pixel"
|
||||
"arrange.spacing" = "Spaziatura"
|
||||
"arrange.tidy" = "Riordina"
|
||||
"board.addConnected" = "Aggiungi forma collegata"
|
||||
"bool.exclude" = "Escludi selezione"
|
||||
"bool.flatten" = "Appiattisci booleano"
|
||||
"bool.intersect" = "Interseca selezione"
|
||||
|
||||
@@ -36,6 +36,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "実際のサイズ(100%)"
|
||||
"Add a category" = "カテゴリを追加"
|
||||
"Add a comment" = "コメントを追加"
|
||||
"Add a connected shape" = "接続されたシェイプを追加"
|
||||
"Add a description" = "説明を追加"
|
||||
"Add a frame (F) or a shape (R) to get started" = "フレーム(F)またはシェイプ(R)を追加して開始"
|
||||
"Add a layer or component to tile first" = "先に並べるレイヤーまたはコンポーネントを追加"
|
||||
@@ -1833,6 +1834,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "ピクセルに丸める"
|
||||
"arrange.spacing" = "間隔"
|
||||
"arrange.tidy" = "整列"
|
||||
"board.addConnected" = "接続されたシェイプを追加"
|
||||
"board.connector" = "コネクタ"
|
||||
"board.ink" = "マーカーの線"
|
||||
"board.label" = "コネクタのラベル"
|
||||
|
||||
@@ -36,6 +36,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Actual size (100%)" = "რეალური ზომა (100%)"
|
||||
"Add a category" = "კატეგორიის დამატება"
|
||||
"Add a comment" = "კომენტარის დამატება"
|
||||
"Add a connected shape" = "დაკავშირებული ფიგურის დამატება"
|
||||
"Add a description" = "აღწერის დამატება"
|
||||
"Add a frame (F) or a shape (R) to get started" = "დასაწყებად დაამატეთ ჩარჩო (F) ან ფიგურა (R)"
|
||||
"Add a layer or component to tile first" = "ჯერ დაამატეთ შრე ან კომპონენტი ფილებად დასაწყობად"
|
||||
@@ -1905,6 +1906,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"arrange.roundToPixels" = "პიქსელებამდე დამრგვალება"
|
||||
"arrange.spacing" = "ინტერვალი"
|
||||
"arrange.tidy" = "მოწესრიგება"
|
||||
"board.addConnected" = "დაკავშირებული ფიგურის დამატება"
|
||||
"board.connector" = "შემაერთებელი"
|
||||
"board.ink" = "მარკერის შტრიხი"
|
||||
"board.label" = "შემაერთებლის წარწერა"
|
||||
|
||||
@@ -32,6 +32,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "실제 크기(100%)"
|
||||
"Add a category" = "카테고리 추가"
|
||||
"Add a comment" = "댓글 추가"
|
||||
"Add a connected shape" = "연결된 도형 추가"
|
||||
"Add a description" = "설명 추가"
|
||||
"Add a frame (F) or a shape (R) to get started" = "프레임(F) 또는 도형(R)을 추가해 시작하세요"
|
||||
"Add a layer or component to tile first" = "먼저 바둑판식으로 배열할 레이어나 컴포넌트를 추가하세요"
|
||||
@@ -1873,6 +1874,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "픽셀에 맞춰 반올림"
|
||||
"arrange.spacing" = "간격"
|
||||
"arrange.tidy" = "정돈"
|
||||
"board.addConnected" = "연결된 도형 추가"
|
||||
"board.connector" = "커넥터"
|
||||
"board.ink" = "마커 획"
|
||||
"board.label" = "커넥터 레이블"
|
||||
|
||||
@@ -35,6 +35,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Werkelijke grootte (100%)"
|
||||
"Add a category" = "Categorie toevoegen"
|
||||
"Add a comment" = "Reactie toevoegen"
|
||||
"Add a connected shape" = "Verbonden vorm toevoegen"
|
||||
"Add a description" = "Beschrijving toevoegen"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Voeg een frame (F) of een vorm (R) toe om te beginnen"
|
||||
"Add a layer or component to tile first" = "Voeg eerst een laag of component toe om als tegel te gebruiken"
|
||||
@@ -1851,6 +1852,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Afronden op pixels"
|
||||
"arrange.spacing" = "Afstand"
|
||||
"arrange.tidy" = "Opruimen"
|
||||
"board.addConnected" = "Verbonden vorm toevoegen"
|
||||
"board.connector" = "Verbinding"
|
||||
"board.ink" = "Stiftlijn"
|
||||
"board.label" = "Label van verbinding"
|
||||
|
||||
@@ -33,6 +33,7 @@ name = "Polski"
|
||||
"Actual size (100%)" = "Rozmiar rzeczywisty (100%)"
|
||||
"Add a category" = "Dodaj kategorię"
|
||||
"Add a comment" = "Dodaj komentarz"
|
||||
"Add a connected shape" = "Dodaj połączony kształt"
|
||||
"Add a description" = "Dodaj opis"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Dodaj ramkę (F) lub kształt (R), aby rozpocząć"
|
||||
"Add a layer or component to tile first" = "Najpierw dodaj warstwę lub komponent do powielenia"
|
||||
@@ -1864,6 +1865,7 @@ name = "Polski"
|
||||
"arrange.roundToPixels" = "Zaokrąglij do pikseli"
|
||||
"arrange.spacing" = "Odstępy"
|
||||
"arrange.tidy" = "Uporządkuj"
|
||||
"board.addConnected" = "Dodaj połączony kształt"
|
||||
"board.connector" = "Łącznik"
|
||||
"board.ink" = "Pociągnięcie markera"
|
||||
"board.label" = "Etykieta łącznika"
|
||||
|
||||
@@ -30,6 +30,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Tamanho real (100%)"
|
||||
"Add a category" = "Adicionar uma categoria"
|
||||
"Add a comment" = "Adicione um comentário"
|
||||
"Add a connected shape" = "Adicionar uma forma conectada"
|
||||
"Add a description" = "Adicionar uma descrição"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Adicione um quadro (F) ou uma forma (R) para começar"
|
||||
"Add a layer or component to tile first" = "Adicione primeiro uma camada ou um componente para o mosaico"
|
||||
@@ -1870,6 +1871,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Arredondar para pixels"
|
||||
"arrange.spacing" = "Espaçamento"
|
||||
"arrange.tidy" = "Organizar"
|
||||
"board.addConnected" = "Adicionar forma conectada"
|
||||
"board.connector" = "Conector"
|
||||
"board.ink" = "Traço de marcador"
|
||||
"board.label" = "Rótulo do conector"
|
||||
|
||||
@@ -33,6 +33,7 @@ name = "Русский"
|
||||
"Actual size (100%)" = "Фактический размер (100%)"
|
||||
"Add a category" = "Добавить категорию"
|
||||
"Add a comment" = "Добавить комментарий"
|
||||
"Add a connected shape" = "Добавить связанную фигуру"
|
||||
"Add a description" = "Добавить описание"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Для начала добавьте Frame (F) или фигуру (R)"
|
||||
"Add a layer or component to tile first" = "Сначала добавьте слой или компонент для повторения"
|
||||
@@ -1864,6 +1865,7 @@ name = "Русский"
|
||||
"arrange.roundToPixels" = "Округлить до пикселей"
|
||||
"arrange.spacing" = "Интервал"
|
||||
"arrange.tidy" = "Упорядочить"
|
||||
"board.addConnected" = "Добавить связанную фигуру"
|
||||
"board.connector" = "Соединитель"
|
||||
"board.ink" = "Штрих маркера"
|
||||
"board.label" = "Подпись соединителя"
|
||||
|
||||
@@ -33,6 +33,7 @@ name = "Svenska"
|
||||
"Actual size (100%)" = "Verklig storlek (100 %)"
|
||||
"Add a category" = "Lägg till en kategori"
|
||||
"Add a comment" = "Lägg till en kommentar"
|
||||
"Add a connected shape" = "Lägg till en ansluten form"
|
||||
"Add a description" = "Lägg till en beskrivning"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Lägg till en frame (F) eller en form (R) för att komma igång"
|
||||
"Add a layer or component to tile first" = "Lägg först till ett lager eller en komponent att lägga sida vid sida"
|
||||
@@ -1935,6 +1936,7 @@ name = "Svenska"
|
||||
"arrange.roundToPixels" = "Avrunda till pixlar"
|
||||
"arrange.spacing" = "Avstånd"
|
||||
"arrange.tidy" = "Snygga till"
|
||||
"board.addConnected" = "Lägg till ansluten form"
|
||||
"board.connector" = "Koppling"
|
||||
"board.ink" = "Märkpennsdrag"
|
||||
"board.label" = "Kopplingsetikett"
|
||||
|
||||
@@ -38,6 +38,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "ขนาดจริง (100%)"
|
||||
"Add a category" = "เพิ่มหมวดหมู่"
|
||||
"Add a comment" = "เพิ่มความคิดเห็น"
|
||||
"Add a connected shape" = "เพิ่มรูปทรงที่เชื่อมต่อ"
|
||||
"Add a description" = "เพิ่มคำอธิบาย"
|
||||
"Add a frame (F) or a shape (R) to get started" = "เพิ่มเฟรม (F) หรือรูปร่าง (R) เพื่อเริ่มต้น"
|
||||
"Add a layer or component to tile first" = "เพิ่มเลเยอร์หรือคอมโพเนนต์ที่จะเรียงต่อกันก่อน"
|
||||
@@ -1838,6 +1839,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "ปัดเป็นพิกเซล"
|
||||
"arrange.spacing" = "ระยะห่าง"
|
||||
"arrange.tidy" = "จัดระเบียบ"
|
||||
"board.addConnected" = "เพิ่มรูปทรงที่เชื่อมต่อ"
|
||||
"board.connector" = "ตัวเชื่อม"
|
||||
"board.ink" = "เส้นปากกามาร์กเกอร์"
|
||||
"board.label" = "ป้ายตัวเชื่อม"
|
||||
|
||||
@@ -34,6 +34,7 @@ name = "Türkçe"
|
||||
"Actual size (100%)" = "Gerçek boyut (%100)"
|
||||
"Add a category" = "Kategori ekle"
|
||||
"Add a comment" = "Yorum ekle"
|
||||
"Add a connected shape" = "Bağlı şekil ekle"
|
||||
"Add a description" = "Açıklama ekle"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Başlamak için bir çerçeve (F) veya bir şekil (R) ekleyin"
|
||||
"Add a layer or component to tile first" = "Önce döşenecek bir katman veya bileşen ekleyin"
|
||||
@@ -1813,6 +1814,7 @@ name = "Türkçe"
|
||||
"arrange.roundToPixels" = "Piksellere yuvarla"
|
||||
"arrange.spacing" = "Aralık"
|
||||
"arrange.tidy" = "Toparla"
|
||||
"board.addConnected" = "Bağlı şekil ekle"
|
||||
"board.connector" = "Bağlayıcı"
|
||||
"board.ink" = "Keçeli kalem çizgisi"
|
||||
"board.label" = "Bağlayıcı etiketi"
|
||||
|
||||
@@ -34,6 +34,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "Kích thước thực (100%)"
|
||||
"Add a category" = "Thêm danh mục"
|
||||
"Add a comment" = "Thêm bình luận"
|
||||
"Add a connected shape" = "Thêm hình được nối"
|
||||
"Add a description" = "Thêm mô tả"
|
||||
"Add a frame (F) or a shape (R) to get started" = "Thêm khung (F) hoặc hình dạng (R) để bắt đầu"
|
||||
"Add a layer or component to tile first" = "Hãy thêm một lớp hoặc thành phần để lặp ô trước"
|
||||
@@ -1789,6 +1790,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "Làm tròn theo pixel"
|
||||
"arrange.spacing" = "Khoảng cách"
|
||||
"arrange.tidy" = "Sắp xếp gọn"
|
||||
"board.addConnected" = "Thêm hình được nối"
|
||||
"bool.exclude" = "Loại trừ vùng chọn"
|
||||
"bool.flatten" = "Làm phẳng nhóm boolean"
|
||||
"bool.intersect" = "Giao vùng chọn"
|
||||
|
||||
@@ -33,6 +33,7 @@ status = "draft — needs native review"
|
||||
"Actual size (100%)" = "实际大小(100%)"
|
||||
"Add a category" = "添加类别"
|
||||
"Add a comment" = "添加评论"
|
||||
"Add a connected shape" = "添加相连的形状"
|
||||
"Add a description" = "添加描述"
|
||||
"Add a frame (F) or a shape (R) to get started" = "添加画框(F)或形状(R)以开始"
|
||||
"Add a layer or component to tile first" = "请先添加要平铺的图层或组件"
|
||||
@@ -1766,6 +1767,7 @@ status = "draft — needs native review"
|
||||
"arrange.roundToPixels" = "取整到像素"
|
||||
"arrange.spacing" = "间距"
|
||||
"arrange.tidy" = "整理"
|
||||
"board.addConnected" = "添加相连的形状"
|
||||
"board.connector" = "连接线"
|
||||
"board.ink" = "马克笔笔画"
|
||||
"board.label" = "连接线标签"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The "+" handles beside a board's selected sticky or shape (#1597), drawn headless: four, only
|
||||
//! for one sticky or shape with the Move tool, clear of the bar above it; clicking one makes the
|
||||
//! next shape on that side joined by a connector and starts typing in it; one undo takes both away.
|
||||
|
||||
use egui::{Event, Id, LayerId, Modifiers, Order, PointerButton, Pos2, Rect, pos2};
|
||||
use figcraft_doc::{BoardItem, NodeId};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::tools::ToolId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::board_connect::REACH;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn board_with_shape() -> (Ui, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newBoard", &json!({})).unwrap();
|
||||
let a = id(&s.execute("board.shape", &json!({"shape": "roundedRectangle", "x": 0, "y": 0, "width": 160, "height": 100})).unwrap());
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
// The shape in the middle of the canvas at 100%.
|
||||
let view = &mut u.app.session.ds_mut().unwrap().view;
|
||||
(view.zoom, view.cx, view.cy) = (1.0, 80.0, 50.0);
|
||||
u.frame(vec![]);
|
||||
(u, a)
|
||||
}
|
||||
|
||||
/// Where the "+" for a side shows, if it does.
|
||||
fn handle(u: &Ui, side: &str) -> Option<Rect> {
|
||||
let layer = LayerId::new(Order::Middle, Id::unique(("board_connect", side)));
|
||||
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 }]);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
fn screen_box(u: &Ui, n: NodeId) -> Rect {
|
||||
let b = u.app.session.doc().unwrap().world_bounds(n).unwrap();
|
||||
Rect::from_two_pos(u.app.to_screen(b.x0, b.y0), u.app.to_screen(b.x1, b.y1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_handles_show_for_one_shape_and_clear_of_the_bar() {
|
||||
let (mut u, a) = board_with_shape();
|
||||
u.app.session.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(handle(&u, "right").is_none(), "nothing selected");
|
||||
u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
let r = screen_box(&u, a);
|
||||
for (side, want) in [
|
||||
("above", pos2(r.center().x, r.min.y - REACH)),
|
||||
("right", pos2(r.max.x + REACH, r.center().y)),
|
||||
("below", pos2(r.center().x, r.max.y + REACH)),
|
||||
("left", pos2(r.min.x - REACH, r.center().y)),
|
||||
] {
|
||||
let h = handle(&u, side).unwrap_or_else(|| panic!("{side}: no handle"));
|
||||
assert!((h.center() - want).length() < 1.0, "{side}: {h:?}");
|
||||
}
|
||||
let bar = u.ctx.memory(|m| m.area_rect(Id::unique("board_bar"))).expect("the bar shows");
|
||||
assert!(!bar.intersects(handle(&u, "above").unwrap()), "the bar leaves the handle above free");
|
||||
// Another tool, or two layers selected: none.
|
||||
u.app.session.set_tool(ToolId::Hand);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(handle(&u, "right").is_none(), "another tool");
|
||||
u.app.session.set_tool(ToolId::Move);
|
||||
let b = id(&u.app.session.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap());
|
||||
u.app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(handle(&u, "right").is_none(), "two layers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_handle_adds_the_next_shape_joined_and_typing() {
|
||||
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 at = handle(&u, "below").expect("a handle below").center();
|
||||
click(&mut u, at);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let made: Vec<NodeId> = doc.ids().filter(|i| doc.node(*i).is_some_and(|n| n.board.is_some())).collect();
|
||||
let next = made.iter().copied().find(|i| *i != a && matches!(doc.node(*i).unwrap().board, Some(BoardItem::Shape { .. }))).expect("a new shape");
|
||||
let line = made.iter().copied().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector");
|
||||
assert!(screen_box(&u, next).min.y > screen_box(&u, a).max.y, "below it");
|
||||
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");
|
||||
}
|
||||
@@ -215,6 +215,23 @@ fn the_next_sticky_from_the_keyboard_goes_on_typing() {
|
||||
assert_eq!(doc.node(owner).unwrap().transform.translation().x, 224.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing() {
|
||||
let mut u = board();
|
||||
let a = id(&u.app.run("board.shape", json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
|
||||
// Shift+Cmd/Ctrl+Enter on a shape: the next shape, joined to it (#1597).
|
||||
crate::menus::invoke(&mut u.app, "board.nextSticky");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let te = u.app.session.ds().unwrap().text_edit.clone().expect("typing in the new shape");
|
||||
let b = figcraft_engine::board::owner_of_text(doc, te.id).unwrap();
|
||||
assert!(matches!(doc.node(b).unwrap().board, Some(BoardItem::Shape { shape: BoardShape::Diamond })));
|
||||
let joined = doc.nodes().any(|n| {
|
||||
matches!(&n.board, Some(BoardItem::Connector(k)) if k.from == figcraft_doc::End::Node { node: a, side: figcraft_doc::Side::Right }
|
||||
&& k.to == figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Left })
|
||||
});
|
||||
assert!(joined, "a connector from the first shape's right side to the new one's left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_board_page_has_a_dot_grid_under_its_layers_thinned_out_zoomed_out() {
|
||||
let mut u = board();
|
||||
|
||||
@@ -31,6 +31,7 @@ use render::{JobKey, request_render, shows};
|
||||
pub use rulers::fmt;
|
||||
pub(crate) use rulers::{status_badge, status_label};
|
||||
pub mod board_bar;
|
||||
pub mod board_connect;
|
||||
pub mod board_facilitate;
|
||||
pub mod comments;
|
||||
pub mod cursors;
|
||||
@@ -299,6 +300,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
crate::board_facilitate::badges(app, &painter);
|
||||
crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p)));
|
||||
crate::board_bar::show(app, &ctx);
|
||||
crate::board_connect::show(app, &ctx);
|
||||
crate::board_facilitate::show(app, &ctx, rect);
|
||||
crate::widget_bar::show(app, &ctx);
|
||||
crate::selection_spacing::draw(app, &painter);
|
||||
|
||||
@@ -172,7 +172,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
let Some(h) = has(app) else { return };
|
||||
let Some(b) = app.session.ds().ok().and_then(|ds| ds.doc.bounds_of(&ds.selection)) else { return };
|
||||
let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
|
||||
let mut sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
|
||||
// Clear of the "+" handles above and below a sticky or shape.
|
||||
if crate::board_connect::shown(app) {
|
||||
sel = sel.expand2(vec2(0.0, crate::board_connect::REACH));
|
||||
}
|
||||
let size = vec2(h.width(), 40.0);
|
||||
let at = place(sel, size, app.canvas.rect);
|
||||
let t = Tokens::get(ctx);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 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`).
|
||||
|
||||
use egui::{Area, Color32, CornerRadius, Id, LayerId, Order, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
|
||||
use figcraft_doc::{BoardItem, NodeId};
|
||||
use figcraft_engine::ToolId;
|
||||
use figcraft_engine::board::{STICKY, connected_place};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::tr;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// How far each "+" sits outside the object, from its edge to its centre (screen points): clear
|
||||
/// of the selection's handles and of the size label under it.
|
||||
pub(crate) const REACH: f32 = 36.0;
|
||||
const RADIUS: f32 = 9.0;
|
||||
const SIDES: [&str; 4] = ["above", "right", "below", "left"];
|
||||
|
||||
/// 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();
|
||||
if app.session.tool.busy() || !editable || app.session.tool.tool != ToolId::Move || !app.session.doc().is_ok_and(figcraft_engine::board::is_board)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let ds = app.session.ds().ok()?;
|
||||
let ([id], None) = (ds.selection.as_slice(), &ds.text_edit) else { return None };
|
||||
let n = ds.doc.node(*id)?;
|
||||
let next = match n.board {
|
||||
Some(BoardItem::Sticky { .. }) => (STICKY, STICKY),
|
||||
Some(BoardItem::Shape { .. }) => (n.width, n.height),
|
||||
_ => return None,
|
||||
};
|
||||
Some((*id, ds.doc.world_bounds(*id)?, next))
|
||||
}
|
||||
|
||||
/// Handles show for the selection: the bar above it leaves them room.
|
||||
pub(crate) fn shown(app: &FigApp) -> bool {
|
||||
target(app).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 {
|
||||
"above" => pos2(r.center().x, r.min.y - REACH),
|
||||
"right" => pos2(r.max.x + REACH, r.center().y),
|
||||
"below" => pos2(r.center().x, r.max.y + REACH),
|
||||
_ => pos2(r.min.x - REACH, r.center().y),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let Some((id, b, next)) = target(app) else { 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 {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let accent = crate::theme::canvas_accent(ctx);
|
||||
// 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;
|
||||
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
|
||||
.show(ctx, |ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * RADIUS, 2.0 * RADIUS), Sense::click());
|
||||
let hot = resp.hovered();
|
||||
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 }));
|
||||
let ink = Stroke::new(1.5, if hot { Color32::WHITE } else { t.text_secondary });
|
||||
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 {
|
||||
preview(app, ctx, b, next, side, accent);
|
||||
}
|
||||
resp.on_hover_text(tr("Add a connected shape")).clicked()
|
||||
})
|
||||
.inner;
|
||||
if clicked {
|
||||
picked = Some(side);
|
||||
}
|
||||
}
|
||||
if let Some(side) = picked {
|
||||
app.run("board.addConnected", json!({"id": id, "side": side, "edit": true}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the next one goes for `side`, faintly: its box and the connector's line to it.
|
||||
fn preview(app: &FigApp, ctx: &egui::Context, b: figcraft_geom::Rect, next: (f64, f64), side: &str, accent: Color32) {
|
||||
let Some(((x, y), ..)) = connected_place((b.x0, b.y0), (b.width(), b.height()), next, side) else { return };
|
||||
let ghost = Rect::from_two_pos(app.to_screen(x, y), app.to_screen(x + next.0, y + next.1));
|
||||
let from = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
|
||||
let (a, z) = match side {
|
||||
"above" => (from.center_top(), ghost.center_bottom()),
|
||||
"right" => (from.right_center(), ghost.left_center()),
|
||||
"below" => (from.center_bottom(), ghost.center_top()),
|
||||
_ => (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));
|
||||
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);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub mod ui_scale;
|
||||
pub mod widgets;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
mod board_connect_tests;
|
||||
#[cfg(test)]
|
||||
mod board_picker_tests;
|
||||
#[cfg(test)]
|
||||
@@ -257,6 +259,7 @@ mod windows_qa_tests;
|
||||
#[cfg(test)]
|
||||
mod windows_tests;
|
||||
pub use canvas::board_bar;
|
||||
pub use canvas::board_connect;
|
||||
pub use canvas::board_facilitate;
|
||||
pub use canvas::comments;
|
||||
pub use canvas::cursors;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boards parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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
|
||||
@@ -35,6 +35,7 @@ all end with the same geometry.
|
||||
| Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar |
|
||||
| Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | 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 |
|
||||
| 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) |
|
||||
| Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own |
|
||||
| Quick-create handles (drag out a connected copy) | missing | |
|
||||
@@ -63,6 +64,8 @@ all end with the same geometry.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | #1597: Shift+Cmd/Ctrl+Enter on a selected shape adds the next shape joined by a connector and starts typing in it; on a sticky it still adds a sticky beside it. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | A row for creating the next shape already connected: "+" handles on a selected sticky's or shape's sides make the next one joined by a connector, ready for typing (#1597). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). |
|
||||
| 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences; the default grid looks as before. Numbers unchanged (beyond the incumbent's behaviour) |
|
||||
| 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged |
|
||||
|
||||
+7
-1
@@ -457,7 +457,13 @@ one undo step and returns the ids it made:
|
||||
defaults to your name (`pref.userName`, or the one a live session gave you; `false` for none).
|
||||
- `board.nextSticky {id?, side?: right|below|left|above, edit?}` → `{id, text}`: a sticky of the
|
||||
same colour beside the selected (or edited) one; with `edit: true` the app goes on typing in it
|
||||
(`Shift+Cmd/Ctrl+Enter` in the app does both).
|
||||
(`Shift+Cmd/Ctrl+Enter` in the app does both). On a shape it does what `board.addConnected` does:
|
||||
the next shape, joined to it by a connector (`{id, text, connector}`).
|
||||
- `board.addConnected {id?, side?: right|below|left|above, edit?}` → `{id, text, connector}`: the
|
||||
next sticky or shape of the same kind, size and colour on that side of the selected (or edited)
|
||||
one, 80 units away, joined to it by a connector the way the Connector tool makes one; one undo
|
||||
step. With `edit: true` the app goes on typing in it (the "+" beside a selected sticky or shape
|
||||
in the app does both).
|
||||
- `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred,
|
||||
wrapping text: `rectangle roundedRectangle ellipse diamond triangle speechBubble chevron`.
|
||||
- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow|curved, startCap?, endCap?, label?}`
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts oLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copiLine truncated
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -76,6 +76,8 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M12, #1597: Shift+Cmd/Ctrl+Enter on a board shape adds the next shape already connected (stickies unchanged). Tests: `board_connected_tests`, `board_ui_tests` |
|
||||
| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). |
|
||||
| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). |
|
||||
| 2026-10-30 | minor | M8, #1085 (the rest): a panic in a `Session::grouped` closure outside any command (plugin, widget, data fill and CLI groups) skipped the group rollback, leaving documents half edited, the history limit unbounded, live documents held and the group depth raised. `checkpointed` now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the outer guard still reports it. Journal rollover and the browser (wasm aborts) are unchanged. 3 regression tests (`group_panic_tests`). |
|
||||
| 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged |
|
||||
|
||||
Reference in new issue
Block a user