From d13ac58a15c45d618f25910772502e38c4ce1d4c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 06:26:14 -0500 Subject: [PATCH] craft-fig: map FigJam stickies to native board items * craft-fig: map FigJam stickies to native board items --- crates/fig/src/rest.rs | 6 ++- crates/fig/src/rest/layer.rs | 85 +++++++++++++++++++++++++++++++++++- crates/fig/src/rest/tests.rs | 40 +++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/crates/fig/src/rest.rs b/crates/fig/src/rest.rs index bbe9a31d1..675b9a8ea 100644 --- a/crates/fig/src/rest.rs +++ b/crates/fig/src/rest.rs @@ -101,11 +101,13 @@ pub struct Options<'g> { /// The plugin data (plugin id, key) under which a writer kept what a layer written as the /// outline of its stroke was: read back as `outlinedStroke` (an extension of the file JSON). pub outlined_stroke: Option<(&'g str, &'g str)>, + /// Map FigJam sticky notes to native board items instead of generic frames. + pub board: bool, } impl Default for Options<'_> { fn default() -> Self { - Options { max_nodes: MAX_NODES, geometry: &Straight, outlined_stroke: None } + Options { max_nodes: MAX_NODES, geometry: &Straight, outlined_stroke: None, board: false } } } @@ -431,6 +433,7 @@ struct Mapper<'a> { warnings: craft_figma_rest::Warnings, geometry: &'a dyn Geometry, outlined_stroke: Option<(&'a str, &'a str)>, + board: bool, count: usize, /// The mains in the file that instances or instance swap defaults name, and the mains mapped /// as layers: the difference is what the layer cap kept out. @@ -526,6 +529,7 @@ impl<'a> Mapper<'a> { warnings: craft_figma_rest::Warnings::default(), geometry: options.geometry, outlined_stroke: options.outlined_stroke, + board: options.board, count: 0, referenced_mains: BTreeSet::new(), mapped_mains: BTreeSet::new(), diff --git a/crates/fig/src/rest/layer.rs b/crates/fig/src/rest/layer.rs index 370e5017b..fc96bbe7d 100644 --- a/crates/fig/src/rest/layer.rs +++ b/crates/fig/src/rest/layer.rs @@ -216,7 +216,7 @@ 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") { + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !(self.board && own == "STICKY")) { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } if connector_snapshot { @@ -353,12 +353,19 @@ impl<'a> Mapper<'a> { let Head { mut o, view, own, rest_id, stand, mut kids, connector_kids, .. } = *head; 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_sticky = self.board && own == "STICKY"; + 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)); @@ -366,6 +373,80 @@ impl<'a> Mapper<'a> { Value::Object(o) } + fn native_sticky(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option> { + 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])); + let paints: Vec = 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::() } 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) + } + fn mapped_type<'s>( own: &'s str, view: &View<'a>, diff --git a/crates/fig/src/rest/tests.rs b/crates/fig/src/rest/tests.rs index 92cf9aee6..e277814a5 100644 --- a/crates/fig/src/rest/tests.rs +++ b/crates/fig/src/rest/tests.rs @@ -418,6 +418,46 @@ fn the_fixture_maps_onto_the_rest_shape() { assert!(out.warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{:?}", out.warnings); } +#[test] +fn a_complete_board_sticky_maps_to_a_native_board_item() { + let record = |id: u64, mut fields: Vec<(&'static str, Kv<'static>)>| { + fields.push(("guidPath", o(vec![("guids", arr(vec![o(vec![("sessionID", u(40_000_000)), ("localID", u(id))])]))]))); + o(fields) + }; + let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]); + let note = record( + 1, + vec![ + ("textData", o(vec![("characters", s("Board note"))])), + ("fontSize", f(18.0)), + ("fontName", o(vec![("family", s("Synthetic Sans")), ("style", s("Regular"))])), + ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ], + ); + let bounds = |id, w, h, x, y| record(id, vec![("size", xy(w, h)), ("transform", at(x, y))]); + let sticky = node( + 2, + Some((1, "!")), + "STICKY", + "Sticky", + vec![ + ("size", xy(240.0, 240.0)), + ("nodeGenerationData", o(vec![("overrides", arr(vec![background, note]))])), + ( + "derivedImmutableFrameData", + o(vec![("version", u(1)), ("overrides", arr(vec![bounds(0, 240.0, 240.0, 0.0, 0.0), bounds(1, 200.0, 160.0, 20.0, 20.0)]))]), + ), + ], + ); + let msg = + o(vec![("nodeChanges", arr(vec![node(0, None, "DOCUMENT", "Doc", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![]), sticky]))]); + let out = to_rest(&msg, "Board", &Options { board: true, ..Options::default() }); + let item = &out.json["document"]["children"][0]["children"][0]; + assert_eq!(item["board"]["type"], "sticky"); + assert_eq!(item["children"][0]["characters"], "Board note"); + assert!(!out.warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{:?}", out.warnings); +} + #[test] fn the_result_reads_as_the_typed_model() { let msg = message();