M12: the connector tool previews the connector it will make, ends snapping to attach points (#1387) (#1526)

This commit is contained in:
BFlat
2026-10-29 18:29:48 +07:00
committed by GitHub
parent e32f8f2e07
commit d4e6ad5d81
10 changed files with 213 additions and 50 deletions
+2 -1
View File
@@ -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-29 · **Change:** minor (large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
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
@@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M12, #1387: 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 |
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged |
@@ -177,3 +177,76 @@ fn reattach_survives_junk() {
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
/// The connector on the page, if any (the one being drawn shows in the document).
fn the_connector(s: &Session) -> Option<figcraft_doc::Node> {
s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).cloned()
}
#[test]
fn the_connector_being_drawn_is_the_one_made() {
for style in ["straight", "elbow", "curved"] {
for to in [Point::new(350.0, 250.0), Point::new(700.0, 600.0)] {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (_, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": style})).unwrap();
let undo = s.undo_depth().0;
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
s.pointer(&ev(PointerKind::Down, Point::new(50.0, 50.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(200.0, 150.0))).unwrap();
s.pointer(&ev(PointerKind::Move, to)).unwrap();
let drawing = the_connector(&s).unwrap_or_else(|| panic!("{style}: the connector shows while dragged"));
let ov = &s.tool.overlay;
if to.x < 400.0 {
// Over b: its four attach points, the one the end is at lit, and b outlined.
assert_eq!(ov.drop_target, Some(b));
let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect();
let end = *drawn(&s, drawing.id).last().unwrap();
assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}");
} else {
assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end");
}
assert!(s.tool.overlay.rubber.is_none(), "no rubber band: the connector itself shows");
s.pointer(&ev(PointerKind::Up, to)).unwrap();
let made = the_connector(&s).unwrap();
assert_eq!(made.kind, drawing.kind, "{style} to {to:?}: the same path");
assert_eq!((made.transform, made.width, made.height), (drawing.transform, drawing.width, drawing.height));
assert_eq!((&made.strokes, &made.stroke, &made.board), (&drawing.strokes, &drawing.stroke, &drawing.board), "line, caps and ends");
assert_eq!(s.undo_depth().0, undo + 1, "one undo step");
assert!(s.tool.overlay.attach.is_empty());
}
}
// Escape mid-drag: the preview goes and nothing is made.
let mut s = board();
s.execute("tool.connector", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 200.0, 0.0, 1.0)).unwrap();
assert!(the_connector(&s).is_some());
s.escape().unwrap();
assert!(the_connector(&s).is_none());
}
#[test]
fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (a, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
let ev = |k, x, y| PointerEvent::at(k, x, y, 1.0);
// Hovering: the object's attach points, lit only near one.
s.pointer(&ev(PointerKind::Hover, 50.0, 50.0)).unwrap();
assert_eq!(s.tool.overlay.attach.len(), 4);
assert!(s.tool.overlay.attach.iter().all(|a| !a.1));
s.pointer(&ev(PointerKind::Hover, 50.0, 3.0)).unwrap();
assert_eq!(s.tool.overlay.attach.iter().filter(|a| a.1).map(|a| a.0).collect::<Vec<_>>(), vec![[50.0, 0.0]]);
s.pointer(&ev(PointerKind::Hover, 900.0, 900.0)).unwrap();
assert!(s.tool.overlay.attach.is_empty());
// From near a's right middle to near b's top middle: those sides, kept.
s.pointer(&ev(PointerKind::Down, 97.0, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top }));
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
}
+1 -1
View File
@@ -207,7 +207,7 @@ fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))), "nothing previewed: none would be made");
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
// From the shape out to empty canvas: a connector to a free point.
+29 -12
View File
@@ -48,11 +48,26 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => routing_of(cmd, &v)?,
None => s.tool.board.routing,
};
let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow);
let c = Connector { from, to, routing };
let start = cap_of(cmd, p, "startCap")?.unwrap_or(CAPS.0);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(CAPS.1);
let page = s.page()?;
let doc = s.doc_mut()?;
let id = make(doc, page, Connector { from, to, routing }, (start, end))?;
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
}
/// A new connector's (start, end) caps.
pub(super) const CAPS: (StrokeCap, StrokeCap) = (StrokeCap::None, StrokeCap::TriangleArrow);
/// Connector `c` added to `page` with its line, `caps` (start, end) and route: what
/// `board.connector` makes, and the connector tool shows while it is dragged.
pub(super) fn make(doc: &mut Document, page: NodeId, c: Connector, caps: (StrokeCap, StrokeCap)) -> Result<NodeId> {
let cmd = "board.connector";
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
if pts.windows(2).all(|w| w.first() == w.get(1)) {
return Err(bad(cmd, "the ends are in the same place"));
@@ -62,17 +77,11 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.strokes = vec![Paint::solid(LINE)];
n.stroke.weight = LINE_WEIGHT;
n.stroke.join = StrokeJoin::Round;
n.stroke.cap_start = start;
n.stroke.cap_end = end;
(n.stroke.cap_start, n.stroke.cap_end) = caps;
n.board = Some(BoardItem::Connector(c));
})?;
place_route(doc, id, &pts, routing);
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
place_route(doc, id, &pts, c.routing);
Ok(id)
}
/// The connector `id` names: a connector, or the label on one.
@@ -121,6 +130,14 @@ pub(super) fn end_json(e: End) -> Value {
}
}
/// The side a connector end names, as the commands take it (none for a free point).
pub(super) fn side_json(e: End) -> Value {
match e {
End::Node { side, .. } => json!(side),
End::Point { .. } => Value::Null,
}
}
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
match &doc.node(id)?.board {
+82 -32
View File
@@ -1,12 +1,18 @@
use super::connectors::{end_json, other_end};
use super::derived::ends_now;
use super::connectors::{CAPS, end_json, make, other_end, side_json};
use super::derived::{ends_now, route};
use super::edit::label_of;
use super::items::edit_text;
use super::{
BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
is_board, json,
BoardItem, Connector, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId,
id_of, is_board, json,
};
/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector
/// end to attach there rather than to the side facing the other end.
const ATTACH_PX: f64 = 10.0;
/// The sides an object takes a connector end at (their middles are its attach points).
const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
// ---------------------------------------------------------------------------------------------
// The board tools.
@@ -51,7 +57,8 @@ impl Session {
ToolId::Connector => {
let from = self.end_at(ev);
self.tool.gesture = Gesture::Connect { from, start: ev.p() };
Ok(())
// While it is dragged the connector shows as it will be made (a preview).
self.begin_interaction("Connector")
}
ToolId::Eraser => {
self.begin_interaction("Erase")?;
@@ -69,47 +76,87 @@ impl Session {
}
}
/// What a connector end at the pointer attaches to: the object there, or the point.
/// What a connector end at the pointer attaches to: the object there (at the side whose middle
/// is within [`ATTACH_PX`], else the side facing the other end), or the point.
fn end_at(&self, ev: &PointerEvent) -> End {
let zoom = ev.zoom.max(1e-6);
let hit = (|| {
let doc = self.doc().ok()?;
let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?;
let ds = self.ds().ok()?;
// Under the connector being dragged out (previewed in the document), not on it.
let doc = ds.interaction.as_ref().map_or(&*ds.doc, |it| &*it.doc);
let leaf = self.hit(doc, ds.page, ev.p(), 2.0 / zoom)?;
let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false);
let n = doc.node(t)?;
(!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t)
if matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink)) {
return None;
}
let b = doc.world_bounds(t);
let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX);
let side = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map_or(Side::Auto, |(s, _)| s);
Some(End::Node { node: t, side })
})();
match hit {
Some(node) => End::Node { node, side: Side::Auto },
None => End::Point { x: ev.x, y: ev.y },
}
hit.unwrap_or(End::Point { x: ev.x, y: ev.y })
}
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
let a = match from {
End::Point { x, y } => Point::new(x, y),
End::Node { .. } => start,
/// Where a connector dragged from `from` (pressed at `start`) to the pointer ends: none for a
/// click, or back on the object it started from (nothing is made).
fn drag_end(&self, ev: &PointerEvent, from: End, start: Point) -> Option<End> {
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return None;
}
let to = self.end_at(ev);
(to.node().is_none() || to.node() != from.node()).then_some(to)
}
/// The object `end` is on (if any) outlined, with its attach points; the one the end is at
/// (`at`, else its named side's) lit.
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
let attach = match end {
Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
}),
_ => None,
};
self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]);
self.tool.overlay.drop_target = from.node();
self.tool.overlay.attach = attach.unwrap_or_default();
self.tool.overlay.drop_target = end.and_then(|e| e.node());
}
/// The pointer moving with the connector tool and no button down: the attach points under it.
pub(crate) fn connector_hover(&mut self, ev: &PointerEvent) {
let end = self.doc().is_ok_and(is_board).then(|| self.end_at(ev));
self.show_attach(end, None);
}
/// The connector being dragged out, drawn as it will be made (a preview) to the attach point
/// or free point under the pointer.
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
self.tool.gesture = Gesture::Connect { from, start };
let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing });
if let Ok(page) = self.page() {
// One that can't be placed shows nothing, as none would be made.
let _ = self.preview(|d, _| {
if let Some(c) = c {
let _ = make(d, page, c, CAPS);
}
Ok(())
});
}
let at = c.and_then(|c| route(self.doc().ok()?, &c)).and_then(|pts| pts.last().copied());
self.show_attach(c.map(|c| c.to), at);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
}
pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.tool.overlay.drop_target = None;
// A click, not a drag: nothing to join.
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
let to = self.end_at(ev);
if to.node().is_some() && to.node() == from.node() {
return Ok(());
}
self.show_attach(None, None);
// The preview goes: the command makes the connector, one undo step.
self.cancel_interaction()?;
let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) };
let style = self.tool.board.routing.id();
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style});
self.execute("board.connector", &p)?;
self.board_placed();
Ok(())
}
@@ -122,7 +169,8 @@ impl Session {
let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a });
let other = other_end(doc, id, from).and_then(|e| e.node());
self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]);
self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other);
let target = Some(self.end_at(ev)).filter(|e| e.node().is_none() || e.node() != other);
self.show_attach(target, None);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
@@ -132,6 +180,7 @@ impl Session {
/// A click, or a drop on the object the other end is on, changes nothing.
pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.show_attach(None, None);
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
@@ -140,7 +189,8 @@ impl Session {
if target.node().is_some() && target.node() == other {
return Ok(());
}
self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?;
let end = if from { "from" } else { "to" };
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?;
Ok(())
}
+3
View File
@@ -336,6 +336,9 @@ pub struct Overlay {
pub rubber_handle: Option<[f64; 2]>,
/// Frame/section the pointer is drawing or dropping into (highlighted).
pub drop_target: Option<NodeId>,
/// Connector tool: the attach points (side middles, page x, y) of the object under the pointer,
/// and whether the connector end is at each.
pub attach: Vec<([f64; 2], bool)>,
/// Auto layout insertion indicator (x0, y0, x1, y1).
pub insert_line: Option<[f64; 4]>,
/// The instance an Alt-drop of a component from the Assets panel would swap (highlighted).
@@ -257,6 +257,9 @@ impl Session {
}
}
self.tool.overlay.guides = guides;
if tool == ToolId::Connector {
self.connector_hover(ev);
}
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
+15 -1
View File
@@ -71,6 +71,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0));
}
slot_badges(app, painter, &doc, &sel, ov.drop_target);
attach_points(app, painter, &ov.attach);
if let Some(t) = ov.swap_target {
swap_highlight(app, painter, &doc, t);
}
@@ -333,6 +334,18 @@ fn selection_overlay(
size_label
}
/// The connector tool's attach points on the object under the pointer; the one its end is at is a
/// larger see-through ring, so the end's cap shows inside it.
fn attach_points(app: &FigApp, painter: &egui::Painter, points: &[([f64; 2], bool)]) {
let col = accent(painter);
for ([x, y], on) in points {
let c = app.to_screen(*x, *y);
let (r, fill) = if *on { (7.0, col.gamma_multiply(0.25)) } else { (4.0, Color32::WHITE) };
painter.circle_filled(c, r, fill);
painter.circle_stroke(c, r, stroke(col, 1.5));
}
}
fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
let ov = &app.session.tool.overlay;
if !ov.pen.is_empty() {
@@ -369,7 +382,8 @@ fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
}
}
} else if let Some([x0, y0, x1, y1]) = ov.rubber {
// A connector being drawn, or one of its ends dragged: from where it starts to the pointer.
// A selected connector's end being dragged: from its other end to the pointer (one being
// drawn shows itself, previewed in the document).
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5));
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Boards parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (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-29 · **Change:** minor (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
@@ -34,7 +34,7 @@ all end with the same geometry.
| Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks |
| 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); re-routed when either end moves or resizes; a deleted end stays where it was |
| 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 |
| 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 +63,7 @@ all end with the same geometry.
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h |
| 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h |
| 2026-10-24 | minor | Templates (retrospective, brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, and tables (`board.table`, `board.tableEdit`): breadth ~35% → ~40%, ready ~20% → ~24%, remaining 30–60 h → 26–54 h |
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
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
@@ -75,6 +75,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M12, #1387: 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 |
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged |