mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 12:13:14 +00:00
Merge pull request #1987 from TrevorBrown-dev/fix/jam-connector-control-points
FigJam import: an elbow connector saved with its path keeps it instead of being routed again (#1910)
This commit is contained in:
@@ -72,6 +72,12 @@ pub fn attached_sides(from: RouteEnd, to: RouteEnd) -> (Option<Side>, Option<Sid
|
||||
(attach(from, to.centre()).1, attach(to, from.centre()).1)
|
||||
}
|
||||
|
||||
/// Where a connector's ends attach, as routed now: the points [`ConnectorPath::Runs`] is measured
|
||||
/// from the middle of.
|
||||
pub fn attach_points(from: RouteEnd, to: RouteEnd) -> (Point, Point) {
|
||||
(attach(from, to.centre()).0, attach(to, from.centre()).0)
|
||||
}
|
||||
|
||||
/// The route's path handles: one for each run of an elbow and one for a curve, with the portion
|
||||
/// of the path each moves and where it sits.
|
||||
pub fn path_handles(pts: &[Point], routing: Routing) -> Vec<(usize, Point)> {
|
||||
@@ -96,8 +102,9 @@ pub fn reshaped(from: RouteEnd, to: RouteEnd, routing: Routing, path: Option<&Co
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another.
|
||||
const MAX_RUNS: usize = 64;
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another, and an
|
||||
/// importer leaves a saved one to the router.
|
||||
pub const MAX_RUNS: usize = 64;
|
||||
|
||||
/// The runs of the right-angled route `pts` with run `portion` moved to `p`.
|
||||
fn reshaped_runs(pts: &[Point], portion: usize, p: Point) -> Option<ConnectorPath> {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use board_route::{END_GAP, MAX_RUNS, RouteEnd, attach_points, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::layer::{cap_name, own_text, svg_path, valid_size};
|
||||
use super::paint::{paint, stroke};
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
|
||||
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
|
||||
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
|
||||
use figcraft_doc::{ConnectorPath, MAX_RUNS, RouteEnd, Routing, Side, attach_points, attached_sides, route_along};
|
||||
|
||||
/// How a connector imports: from its saved snapshot (`terminal`: as one vector, no room for its
|
||||
/// label), or drawn from its ends, as a frame with its line and `label` when it has one.
|
||||
@@ -48,7 +48,8 @@ impl<'a> Mapper<'a> {
|
||||
|
||||
/// A connector's end caps, and when it has no saved outline or network its line between its
|
||||
/// ends (free points, or a sibling layer): straight to the point given on the layer (its
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs.
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs;
|
||||
/// an elbow saved with its path (FigJam's control points) runs along it (#1910).
|
||||
/// Returns the middle of that line by length, in the connector's own coordinates.
|
||||
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) -> Option<Point> {
|
||||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||||
@@ -94,11 +95,13 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
ok.then_some(end)
|
||||
};
|
||||
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
|
||||
let ends = end("connectorStart").zip(end("connectorEnd"));
|
||||
let path = ends.filter(|_| routing == Routing::Elbow).and_then(|(a, b)| saved_runs(v, a, b));
|
||||
let Some(pts) = ends.and_then(|(a, b)| route_along(a, b, routing, path.as_ref())) else {
|
||||
self.warn("connectors whose ends can't be placed are empty");
|
||||
return None;
|
||||
};
|
||||
if routing != Routing::Straight {
|
||||
if routing != Routing::Straight && path.is_none() {
|
||||
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
|
||||
}
|
||||
// A curved route is one curve: its ends and the two control points between them.
|
||||
@@ -407,6 +410,49 @@ pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool {
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The path a FigJam elbow connector was saved with: its `connectorControlPoints`, one per run
|
||||
/// between the first and the last, each a point on its run (in the connector's parent's
|
||||
/// coordinates, like its ends) with the run's axis; the first run leaves the start's side and the
|
||||
/// last arrives at the end's, so neither is saved. `None` without any, or when they don't make
|
||||
/// such a path: a point off both axes, two runs in a row the same way, a first or last run that
|
||||
/// would leave or arrive sideways to its side, or more runs than a hand could make here.
|
||||
fn saved_runs(v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option<ConnectorPath> {
|
||||
let saved = v.list("connectorControlPoints");
|
||||
if saved.is_empty() || saved.len() + 2 > MAX_RUNS {
|
||||
return None;
|
||||
}
|
||||
let (p0, p1) = attach_points(from, to);
|
||||
let mid = p0.midpoint(p1);
|
||||
let mut at = Vec::with_capacity(saved.len());
|
||||
let (mut first, mut last) = (None, None);
|
||||
for p in saved {
|
||||
let pos = p.get("position").and_then(|q| Some(Point::new(num(q, "x")?, num(q, "y")?)))?;
|
||||
let (ax, ay) = p.get("axis").and_then(|q| Some((num(q, "x")?, num(q, "y")?)))?;
|
||||
let sideways = match (ax != 0.0, ay != 0.0) {
|
||||
(true, false) => true,
|
||||
(false, true) => false,
|
||||
_ => return None,
|
||||
};
|
||||
if last == Some(sideways) {
|
||||
return None;
|
||||
}
|
||||
first.get_or_insert(!sideways);
|
||||
last = Some(sideways);
|
||||
at.push(if sideways { pos.y - mid.y } else { pos.x - mid.x });
|
||||
}
|
||||
// The run through each end goes the way its side faces, out of the object: the first leaves
|
||||
// the start's side for the first saved run, the last arrives at the end's from the last saved
|
||||
// run. One along the side, or back into the object, doesn't.
|
||||
let (across, last) = (first?, last?);
|
||||
let (&a0, &a1) = (at.first()?, at.last()?);
|
||||
// Where the run from `p` (`sideways`: going across) meets the saved run at `v`.
|
||||
let onto = |sideways: bool, p: Point, v: f64| if sideways { Point::new(mid.x + v, p.y) } else { Point::new(p.x, mid.y + v) };
|
||||
let out_of = |s: Option<Side>, p: Point, q: Point| s.is_none_or(|s| (q - p).dot(s.normal()) > 0.0);
|
||||
let (s0, s1) = attached_sides(from, to);
|
||||
let fits = out_of(s0, p0, onto(across, p0, a0)) && out_of(s1, p1, onto(!last, p1, a1));
|
||||
fits.then_some(ConnectorPath::Runs { across, at })
|
||||
}
|
||||
|
||||
/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge
|
||||
/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end.
|
||||
fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
|
||||
|
||||
@@ -863,3 +863,85 @@ fn write_fixture() {
|
||||
std::fs::write(out, zip_file(&tail_canvas())).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// An elbow connector saved with its path (#1910): FigJam's control points, one per run between
|
||||
/// the first and the last, each on its run with the run's axis, give the path the connector keeps;
|
||||
/// the first run leaves the start's side and the last arrives at the end's.
|
||||
#[test]
|
||||
fn an_elbow_connector_saved_with_its_path_keeps_it() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let box_at = |id, name: &str, x, y| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, y))]);
|
||||
let point = |x, y, ax, ay| o(vec![("position", xy(x, y)), ("axis", xy(ax, ay))]);
|
||||
// From A's bottom, across at y 740, down at x 200, into B's left: four runs.
|
||||
let saved = node(
|
||||
66,
|
||||
Some((1, "+")),
|
||||
"CONNECTOR",
|
||||
"Saved",
|
||||
vec![
|
||||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("connectorStart", o(vec![("endpointNodeID", g(64)), ("relativePosition", xy(0.5, 1.0))])),
|
||||
("connectorEnd", o(vec![("endpointNodeID", g(65)), ("relativePosition", xy(0.0, 0.5))])),
|
||||
("connectorLineStyle", e("ELBOWED")),
|
||||
("connectorControlPoints", arr(vec![point(150.0, 740.0, 1.0, 0.0), point(200.0, 800.0, 0.0, 1.0)])),
|
||||
],
|
||||
);
|
||||
nodes.extend([box_at(64, "A", 0.0, 600.0), box_at(65, "B", 300.0, 800.0), saved]);
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Saved path").unwrap();
|
||||
let world = doc.world_transform(named(&doc, "Saved").id);
|
||||
let line: Vec<_> =
|
||||
network_of(&doc, "Saved").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
// Each end starts `END_GAP` (4) outside its side's border.
|
||||
assert_eq!(line, [(50.0, 704.0), (50.0, 740.0), (200.0, 740.0), (200.0, 850.0), (296.0, 850.0)]);
|
||||
// The fixture's own Link, saved without a path, is routed here: the note counts it alone.
|
||||
let routed: Vec<_> = warnings.iter().filter(|w| w.contains("FigCraft's own routing")).collect();
|
||||
assert_eq!(routed.len(), 1, "{warnings:?}");
|
||||
assert!(!routed[0].contains('×'), "a connector with its path isn't counted among those routed here: {routed:?}");
|
||||
}
|
||||
|
||||
/// Saved control points that don't make a path from the connector's sides (#1910): a first run
|
||||
/// that would leave the bottom sideways, a point off both axes, or two runs in a row the same way
|
||||
/// leave the connector to FigCraft's routing, as one saved without a path is.
|
||||
#[test]
|
||||
fn an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let box_at = |id, name: &str, x, y| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, y))]);
|
||||
let point = |x, y, ax, ay| o(vec![("position", xy(x, y)), ("axis", xy(ax, ay))]);
|
||||
let elbow = |id, name: &str, points: Vec<Kv<'static>>| {
|
||||
let mut fields = vec![
|
||||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("connectorStart", o(vec![("endpointNodeID", g(64)), ("relativePosition", xy(0.5, 1.0))])),
|
||||
("connectorEnd", o(vec![("endpointNodeID", g(65)), ("relativePosition", xy(0.0, 0.5))])),
|
||||
("connectorLineStyle", e("ELBOWED")),
|
||||
];
|
||||
if !points.is_empty() {
|
||||
fields.push(("connectorControlPoints", arr(points)));
|
||||
}
|
||||
node(id, Some((1, "+")), "CONNECTOR", name, fields)
|
||||
};
|
||||
nodes.extend([
|
||||
box_at(64, "A", 0.0, 600.0),
|
||||
box_at(65, "B", 300.0, 800.0),
|
||||
elbow(66, "Plain", vec![]),
|
||||
elbow(67, "Sideways", vec![point(100.0, 650.0, 0.0, 1.0)]),
|
||||
elbow(68, "Askew", vec![point(150.0, 740.0, 1.0, 1.0)]),
|
||||
elbow(69, "Repeated", vec![point(150.0, 740.0, 1.0, 0.0), point(150.0, 760.0, 1.0, 0.0)]),
|
||||
// The first run would go up from A's bottom, through A; the last would arrive at B's left
|
||||
// from inside B. Each run is at right angles to its side, so only the way it goes is wrong.
|
||||
elbow(70, "Backwards", vec![point(100.0, 650.0, 1.0, 0.0), point(200.0, 740.0, 0.0, 1.0)]),
|
||||
elbow(71, "Inside", vec![point(150.0, 740.0, 1.0, 0.0), point(350.0, 800.0, 0.0, 1.0)]),
|
||||
]);
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Unfit paths").unwrap();
|
||||
let line = |name: &str| -> Vec<(f64, f64)> {
|
||||
let world = doc.world_transform(named(&doc, name).id);
|
||||
network_of(&doc, name).vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect()
|
||||
};
|
||||
let plain = line("Plain");
|
||||
assert!(plain.len() >= 3, "routed in right angles: {plain:?}");
|
||||
for name in ["Sideways", "Askew", "Repeated", "Backwards", "Inside"] {
|
||||
assert_eq!(line(name), plain, "{name} is routed as one without a path");
|
||||
}
|
||||
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ message VariableSetID GUID guid, AssetRef assetRef
|
||||
message ConditionalActions Action[] actions, VariableData condition
|
||||
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
|
||||
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
|
||||
message ConnectorControlPoint Vector position, Vector axis
|
||||
message VariableData AnyValue value, DataType dataType
|
||||
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
|
||||
message ParentIndex GUID guid, string position
|
||||
@@ -74,7 +75,7 @@ message Event InteractionType interactionType
|
||||
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
|
||||
message Interaction Event event, Action[] actions
|
||||
message StartingPoint string name, string position
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern, ConnectorControlPoint[] connectorControlPoints
|
||||
message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boards parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
|
||||
|
||||
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
|
||||
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
|
||||
@@ -66,7 +66,7 @@ all end with the same geometry.
|
||||
| Audio and huddles | missing | |
|
||||
| AI features (summaries, sorting stickies, generating boards) | missing | |
|
||||
| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none |
|
||||
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies and shapes with text come in as frames with their generated layers and its connectors as vectors, not as native board objects; no export | see [`file-format-parity.md`](file-format-parity.md) |
|
||||
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies and shapes with text come in as frames with their generated layers and its connectors as vectors, not as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) |
|
||||
|
||||
## Revision history
|
||||
|
||||
@@ -74,6 +74,7 @@ all end with the same geometry.
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. |
|
||||
| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. |
|
||||
| 2026-11-04 | patch | M6, #1910: a `.jam` elbowed connector saved with its path (FigJam's `connectorControlPoints`) keeps it on import: the points become the `ConnectorPath::Runs` a connector reshaped by hand has and the line is walked along them, so a connector drawn around a layer stays around it; one whose points don't make a path from its ends' sides is routed as before. Still a vector, not a native board object. Details in [`file-format-parity.md`](file-format-parity.md). |
|
||||
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
|
||||
| 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. |
|
||||
| 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. |
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user