mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 02:34:33 +00:00
M9: .jam import: stickies come in as native board stickies (#1808)
On a FigJam whiteboard the mapper builds a sticky with complete generated records as a native board sticky: the saved colour, size, place, opacity and shadows, the note and the author line as text layers, no background vector. It writes a board field on the REST JSON, which the REST importer reads (junk values are ignored with a warning). Damaged stickies and stickies in a .fig keep the generated layers. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
a2e87433d8
commit
4201fed27d
@@ -148,7 +148,7 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result
|
||||
let max_nodes = layer_budget(packed);
|
||||
drop(message);
|
||||
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
|
||||
let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
|
||||
let (mut json, mut warnings, networks) = map::to_rest_for(&msg, file_name, max_nodes, board);
|
||||
// The message goes before the layers are built, so the images it carries are copied out first.
|
||||
let embedded = embedded_copies(&msg);
|
||||
drop(msg);
|
||||
|
||||
@@ -446,10 +446,106 @@ fn a_jam_file_imports_like_a_fig() {
|
||||
let (doc, warnings) = import(&bytes, "Synthetic board").unwrap();
|
||||
assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board");
|
||||
let note = named(&doc, "Board object");
|
||||
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author");
|
||||
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 2, "the sticky is native: its note and author");
|
||||
assert_eq!(note.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
|
||||
assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame));
|
||||
assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out");
|
||||
assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Import `layer` through the public importer as a FigJam whiteboard (`fig-jam.` header).
|
||||
fn jam(layer: Kv<'static>) -> (Document, Vec<String>) {
|
||||
let mut bytes = canvas(&board_schema(), &message(layer));
|
||||
bytes.splice(..8, *b"fig-jam.");
|
||||
import(&bytes, "Synthetic board").unwrap()
|
||||
}
|
||||
|
||||
/// On a whiteboard a sticky is a native sticky: its colour, size, text and author come along.
|
||||
#[test]
|
||||
fn a_jam_sticky_is_a_native_sticky_with_its_colour_text_and_author() {
|
||||
let (doc, warnings) = jam(board_node("STICKY"));
|
||||
assert_eq!(doc.kind, figcraft_doc::FileKind::Board);
|
||||
let frame = named(&doc, "Board object");
|
||||
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
|
||||
assert_eq!(frame.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
|
||||
assert_eq!((frame.width, frame.height), (240.0, 240.0));
|
||||
assert_eq!(frame.opacity, 0.5);
|
||||
let layout = frame.layout.as_ref().expect("sticky auto layout");
|
||||
assert_eq!(layout.padding, [20.0; 4]);
|
||||
assert_eq!(frame.child_layout.min_h, Some(240.0));
|
||||
assert_eq!(frame.children.len(), 2, "the note and its author, no background vector");
|
||||
let texts: Vec<_> = frame.children.iter().map(|id| doc.node(*id).unwrap()).collect();
|
||||
assert!(texts.iter().all(|n| n.kind.text().is_some()));
|
||||
let note = texts[0].kind.text().unwrap();
|
||||
assert_eq!(note.characters, "Olá\nIdea");
|
||||
assert_eq!(note.style.font_size, 24.0);
|
||||
assert!(note.runs.iter().any(|r| r.style.font_size == 18.0), "inline styles survive");
|
||||
let author = texts[1].kind.text().unwrap();
|
||||
assert_eq!(author.characters, "Test Author");
|
||||
assert_eq!(author.style.font_size, 12.0);
|
||||
assert!(!warnings.iter().any(|w| w.contains("skipped")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A sticky whose author line is not saved has an empty author and just its note.
|
||||
#[test]
|
||||
fn a_jam_sticky_without_an_author_line_has_no_author() {
|
||||
let mut layer = board_node("STICKY");
|
||||
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
|
||||
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text("Olá\nIdea", 24.0))]))]));
|
||||
let (doc, _) = jam(layer);
|
||||
let frame = named(&doc, "Board object");
|
||||
assert_eq!(frame.children.len(), 1);
|
||||
let Some(figcraft_doc::BoardItem::Sticky { author }) = &frame.board else { panic!("{:?}", frame.board) };
|
||||
assert!(author.is_empty());
|
||||
}
|
||||
|
||||
/// A sticky record that isn't complete keeps today's generated layers and no board item.
|
||||
#[test]
|
||||
fn a_damaged_jam_sticky_keeps_the_generated_layers() {
|
||||
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("visible", Kv::Bool(true))]);
|
||||
let cases: Vec<Kv<'static>> = {
|
||||
let mut v = Vec::new();
|
||||
// Layout version 2.
|
||||
let mut a = board_node("STICKY");
|
||||
set(&mut a, "derivedImmutableFrameData", o(vec![("version", u(2))]));
|
||||
v.push(a);
|
||||
// Background record missing.
|
||||
let mut b = board_node("STICKY");
|
||||
set(&mut b, "nodeGenerationData", o(vec![("overrides", arr(vec![record(1, text("Olá", 24.0))]))]));
|
||||
v.push(b);
|
||||
// A stroke on the background.
|
||||
let mut c = board_node("STICKY");
|
||||
let stroked = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)])), ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)]))]);
|
||||
set(&mut c, "nodeGenerationData", o(vec![("overrides", arr(vec![stroked, record(1, text("Olá", 24.0))]))]));
|
||||
v.push(c);
|
||||
// A gradient on the background.
|
||||
let mut d = board_node("STICKY");
|
||||
let graded = record(0, vec![("fillPaints", arr(vec![gradient]))]);
|
||||
set(&mut d, "nodeGenerationData", o(vec![("overrides", arr(vec![graded, record(1, text("Olá", 24.0))]))]));
|
||||
v.push(d);
|
||||
// Blank note.
|
||||
let mut f = board_node("STICKY");
|
||||
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
|
||||
set(&mut f, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text(" ", 24.0))]))]));
|
||||
v.push(f);
|
||||
v
|
||||
};
|
||||
for (i, layer) in cases.into_iter().enumerate() {
|
||||
let (as_fig, _) = imported(layer.clone());
|
||||
let (doc, _) = jam(layer);
|
||||
let frame = named(&doc, "Board object");
|
||||
assert!(frame.board.is_none(), "case {i}");
|
||||
assert_eq!(frame.children.len(), named(&as_fig, "Board object").children.len(), "case {i}: the generated layers, as in a .fig");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same sticky in a `.fig` stays the design frame it always was.
|
||||
#[test]
|
||||
fn a_fig_sticky_stays_a_design_frame() {
|
||||
let (doc, _) = imported(board_node("STICKY"));
|
||||
let frame = named(&doc, "Board object");
|
||||
assert!(frame.board.is_none());
|
||||
assert_eq!(frame.children.len(), 3);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use serde_json::{Map, Value, json};
|
||||
use super::kiwi::Kv;
|
||||
use super::network;
|
||||
|
||||
mod board;
|
||||
mod component;
|
||||
mod connector;
|
||||
mod document;
|
||||
@@ -50,8 +51,16 @@ const MAX_ASSET_PART: usize = 256;
|
||||
|
||||
/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the
|
||||
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
|
||||
#[cfg(test)]
|
||||
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
|
||||
to_rest_for(msg, name, max_nodes, false)
|
||||
}
|
||||
|
||||
/// [`to_rest`] for a FigJam whiteboard (`board`) or a design file: a whiteboard's stickies come
|
||||
/// out as native board items.
|
||||
pub(super) fn to_rest_for(msg: &Kv, name: &str, max_nodes: usize, board: bool) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
|
||||
let mut m = Mapper::new(msg, max_nodes);
|
||||
m.board = board;
|
||||
let _canon = VarCanon::install(&m);
|
||||
let doc = m.document();
|
||||
// Large values are moved in, never passed through `json!`, which serialises its arguments:
|
||||
@@ -361,6 +370,8 @@ struct Mapper<'a> {
|
||||
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
|
||||
/// file, since layers with region paints don't share their network.
|
||||
region_paint_elements: usize,
|
||||
/// The file is a FigJam whiteboard: its objects are built as native board items.
|
||||
board: bool,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
@@ -438,6 +449,7 @@ impl<'a> Mapper<'a> {
|
||||
max_nodes,
|
||||
reserved: 0,
|
||||
networks: Vec::new(),
|
||||
board: false,
|
||||
network_ids: HashMap::new(),
|
||||
region_paint_elements: 0,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! FigJam whiteboard objects as native board items: what a `.jam` import builds in place of the
|
||||
//! generated frames a `.fig` keeps. The REST JSON carries them as a `board` field
|
||||
//! ([`crate::rest`] reads it).
|
||||
|
||||
use super::layer::{own_text, valid_size};
|
||||
use super::paint::paint;
|
||||
use super::{Affine, Kv, MAX_DEPTH, Mapper, Obj, Value, View, generated_record, json, num};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// A sticky as a native board sticky: its frame (size, place, opacity and shadows as saved,
|
||||
/// the saved colour) holding the note and, when saved, the author line. `None` (nothing
|
||||
/// changed) unless every generated record is complete: then the caller keeps the generated
|
||||
/// layers.
|
||||
pub(super) fn native_sticky(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option<Vec<Value>> {
|
||||
if v.get("nodeGenerationData").is_none() || v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) {
|
||||
return None;
|
||||
}
|
||||
let record = |id: u32| {
|
||||
let (style, bounds) = (generated_record(v, "nodeGenerationData", id)?, generated_record(v, "derivedImmutableFrameData", id)?);
|
||||
let view = View::of(style).with([(bounds, 1.0)]);
|
||||
(valid_size(view.get("size")).is_some() && view.transform().is_some()).then_some(view)
|
||||
};
|
||||
let background = record(0)?;
|
||||
let fills = background.list("fillPaints");
|
||||
let solid = matches!(fills, [p] if p.get("type").and_then(Kv::str) == Some("SOLID") && p.get("visible").and_then(Kv::bool) != Some(false));
|
||||
if !solid || !background.list("strokePaints").is_empty() {
|
||||
return None;
|
||||
}
|
||||
let note = record(1).filter(|n| own_text(n).is_some())?;
|
||||
let author = record(2).filter(|a| a.flag("visible") != Some(false) && own_text(a).is_some());
|
||||
let wanted = 1 + usize::from(author.is_some());
|
||||
if depth >= MAX_DEPTH || self.count.saturating_add(wanted) > self.budget() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = v.size().or_else(|| o.get("size").and_then(|s| Some((s.get("x")?.as_f64()?, s.get("y")?.as_f64()?))))?;
|
||||
let at = note.transform().unwrap_or_default().as_coeffs();
|
||||
let pad = |x: f64| if x.is_finite() { x.clamp(0.0, (w.min(h) / 2.0).max(0.0)) } else { 0.0 };
|
||||
let (pad_x, pad_y) = (pad(at[4]), pad(at[5]));
|
||||
// Everything is valid: build.
|
||||
let paints: Vec<Value> = fills.iter().filter_map(|p| paint(p, &mut |m| self.warn(m))).collect();
|
||||
o.insert("fills".into(), Value::Array(paints));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
match background.len("cornerRadius") {
|
||||
Some(r) => o.insert("cornerRadius".into(), json!(r)),
|
||||
None => o.remove("cornerRadius"),
|
||||
};
|
||||
for (k, val) in [
|
||||
("layoutMode", json!("VERTICAL")),
|
||||
("primaryAxisAlignItems", json!("SPACE_BETWEEN")),
|
||||
("counterAxisAlignItems", json!("MIN")),
|
||||
("primaryAxisSizingMode", json!("AUTO")),
|
||||
("counterAxisSizingMode", json!("FIXED")),
|
||||
("minHeight", json!(h)),
|
||||
("itemSpacing", json!(8.0)),
|
||||
("paddingTop", json!(pad_y)),
|
||||
("paddingBottom", json!(pad_y)),
|
||||
("paddingLeft", json!(pad_x)),
|
||||
("paddingRight", json!(pad_x)),
|
||||
] {
|
||||
o.insert(k.into(), val);
|
||||
}
|
||||
let mut kids = Vec::new();
|
||||
let mut written = String::new();
|
||||
for (local, view) in [(1, Some(note)), (2, author)] {
|
||||
let Some(view) = view else { continue };
|
||||
let chars = own_text(&view).unwrap_or_default();
|
||||
let mut layer = Obj::new();
|
||||
layer.insert("id".into(), json!(format!("{rest_id};generated{local}")));
|
||||
layer.insert("type".into(), json!("TEXT"));
|
||||
layer.insert("name".into(), json!(if local == 1 { chars.chars().take(40).collect::<String>() } else { "Author".to_string() }));
|
||||
self.common(&mut layer, &view, "TEXT", Affine::IDENTITY, None);
|
||||
self.text(&mut layer, &view);
|
||||
if let Some(Value::Object(style)) = layer.get_mut("style") {
|
||||
style.insert("textAutoResize".into(), json!("HEIGHT"));
|
||||
style.insert("textAlignHorizontal".into(), json!("LEFT"));
|
||||
}
|
||||
layer.insert("layoutSizingHorizontal".into(), json!("FILL"));
|
||||
if local == 2 {
|
||||
written = chars.trim().to_string();
|
||||
}
|
||||
self.count += 1;
|
||||
kids.push(Value::Object(layer));
|
||||
}
|
||||
o.insert("board".into(), json!({"type": "sticky", "author": written}));
|
||||
Some(kids)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,9 @@ impl<'a> Mapper<'a> {
|
||||
// A label without a snapshot: the connector becomes a frame holding its line and its label.
|
||||
let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth);
|
||||
let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? };
|
||||
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
|
||||
// A whiteboard's sticky says it imports as a frame only when it can't be a native one.
|
||||
let native_sticky = self.board && own == "STICKY";
|
||||
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !native_sticky) {
|
||||
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
|
||||
}
|
||||
if connector_snapshot {
|
||||
@@ -262,12 +264,18 @@ impl<'a> Mapper<'a> {
|
||||
});
|
||||
kids.splice(0..0, connector_kids);
|
||||
if stand.is_some_and(|s| s.0 == "FRAME") {
|
||||
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
|
||||
let native = if native_sticky { self.native_sticky(&mut o, &view, &rest_id, depth) } else { None };
|
||||
if let Some(native) = native {
|
||||
kids.splice(0..0, native);
|
||||
} else if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
|
||||
kids.splice(0..0, snapshot);
|
||||
} else {
|
||||
kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
|
||||
kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
|
||||
}
|
||||
if native_sticky && !o.contains_key("board") {
|
||||
self.warn("sticky layers import as frames with their text");
|
||||
}
|
||||
}
|
||||
if !kids.is_empty() {
|
||||
o.insert("children".into(), Value::Array(kids));
|
||||
|
||||
@@ -442,6 +442,8 @@ pub(crate) struct Ctx<'a> {
|
||||
set_defs: BTreeMap<NodeId, BTreeMap<String, PropDef>>,
|
||||
/// Prototype starting points: (page, name, REST node id).
|
||||
flows: Vec<(NodeId, String, String)>,
|
||||
/// Board connectors and labels, resolved once every layer is imported (their ends name layers).
|
||||
pending_boards: Vec<(NodeId, &'a Value)>,
|
||||
/// Layers with prototype interactions, resolved once every layer is imported.
|
||||
protos: Vec<(NodeId, &'a Value)>,
|
||||
/// Layers that carry overlay settings (position, background, outside clicks), by REST id.
|
||||
@@ -478,6 +480,7 @@ impl<'a> Ctx<'a> {
|
||||
swap_defaults: Vec::new(),
|
||||
set_defs: BTreeMap::new(),
|
||||
flows: Vec::new(),
|
||||
pending_boards: Vec::new(),
|
||||
protos: Vec::new(),
|
||||
overlay_meta: BTreeMap::new(),
|
||||
var_kinds: BTreeMap::new(),
|
||||
@@ -781,6 +784,9 @@ impl<'a> Ctx<'a> {
|
||||
self.overlay_meta.entry(rid).or_insert(j);
|
||||
}
|
||||
self.bindings(n, j);
|
||||
if let Some(b) = j.get("board") {
|
||||
self.board_item(n, b);
|
||||
}
|
||||
let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original));
|
||||
// Styles and bindings were read against the stroke: they follow its paints to the fill.
|
||||
if outlined && !restored {
|
||||
@@ -788,6 +794,32 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `board` field (a FigCraft extension): what the object is on a board. A sticky or shape
|
||||
/// is set at once; anything else, or junk, is ignored with a warning.
|
||||
fn board_item(&mut self, n: &mut Node, b: &'a Value) {
|
||||
let item = match text(b, "type") {
|
||||
Some("sticky") => {
|
||||
let author = text(b, "author").unwrap_or("").trim();
|
||||
Some(figcraft_doc::BoardItem::Sticky { author: author.chars().take(64).collect() })
|
||||
}
|
||||
Some("shape") => text(b, "shape").and_then(figcraft_doc::BoardShape::from_id).map(|shape| figcraft_doc::BoardItem::Shape { shape }),
|
||||
_ => None,
|
||||
};
|
||||
match item {
|
||||
Some(item) => n.board = Some(item),
|
||||
None => self.warn(format!("unknown board item on {} was ignored", n.name)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Board items that name other layers (connector ends, a label's connector), once every
|
||||
/// layer is imported and `ids` can resolve them.
|
||||
fn resolve_boards(&mut self) {
|
||||
for (id, _) in std::mem::take(&mut self.pending_boards) {
|
||||
let name = self.doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
|
||||
self.warn(format!("unknown board item on {name} was ignored"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or
|
||||
/// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its
|
||||
/// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints
|
||||
@@ -1096,6 +1128,7 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
}
|
||||
self.resolve_instances();
|
||||
self.resolve_boards();
|
||||
if let Some(sources) = self.fig_sources.take() {
|
||||
let missing = sources.apply(&mut self.doc, &self.ids);
|
||||
if missing > 0 {
|
||||
|
||||
@@ -93,3 +93,23 @@ fn thousands_of_distinct_style_references_import_in_linear_time() {
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert_eq!(doc.styles.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_board_field_becomes_the_nodes_board_item() {
|
||||
let frame = json!({"id": "1:1", "name": "Note", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200},
|
||||
"board": {"type": "sticky", "author": "A"}});
|
||||
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
|
||||
assert_eq!(named(&doc, "Note").board, Some(figcraft_doc::BoardItem::Sticky { author: "A".into() }));
|
||||
assert!(!warnings.iter().any(|w| w.contains("board item")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_board_fields_are_ignored_with_a_warning() {
|
||||
for junk in [json!(5), json!({"type": "nope"}), json!({"type": "shape", "shape": "blob"}), json!({"type": "connector"})] {
|
||||
let frame =
|
||||
json!({"id": "1:1", "name": "Junk", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}, "board": junk});
|
||||
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
|
||||
assert!(named(&doc, "Junk").board.is_none(), "{junk}");
|
||||
assert!(warnings.iter().any(|w| w.contains("unknown board item on Junk was ignored")), "{junk}: {warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1885,6 +1885,7 @@ name = "English"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
|
||||
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count"
|
||||
"sticky layers import as frames with their text" = "sticky layers import as frames with their text"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke"
|
||||
"text path underline or strikethrough decoration is not rendered" = "text path underline or strikethrough decoration is not rendered"
|
||||
"text style overrides refer to missing table entries" = "text style overrides refer to missing table entries"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boards parity
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); 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,13 +66,14 @@ 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 (an elbowed connector keeps the path it was saved with, #1910); 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 come in as native stickies (their colour, text and author kept; size as saved), its shapes with text as frames with their generated layers and its connectors as vectors, not yet 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
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. |
|
||||
| 2026-11-10 | minor | M9, #1808: a `.jam` sticky imports as a native board sticky (`BoardItem::Sticky`): its frame keeps the saved size (240 pt, not FigCraft's 200), place, opacity and shadows, the saved colour is kept as it is (not snapped to the board colours), the note and the author line are its text layers, and it lays out like one made here (vertical, space between, hugging the height down to the saved size, the saved inset as padding). The importer reads a `board` field on the REST JSON (a FigCraft extension) for this; a sticky whose generated records are damaged, or the same sticky in a `.fig`, keeps the generated layers as before. Shapes, connectors, stamps and tables are unchanged; the note's text keeps the file's size until it is edited. Tests: `board_import_tests.rs` (native sticky, no author, damaged records, `.fig` unchanged), `import_regression_tests.rs` (the `board` field and junk values). 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 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user