M1: import component slot content from .fig files

Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main.

Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed.
This commit is contained in:
Skyfuller1303
2026-10-16 04:59:11 +02:00
parent d2606b3f51
commit 8408cc72b8
5 changed files with 346 additions and 7 deletions
+104 -3
View File
@@ -295,6 +295,10 @@ struct Inst<'a> {
path: Vec<String>,
overrides: Rc<Overrides<'a>>,
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
/// Slot property GUID → private slot-content node GUID.
slot_contents: Rc<BTreeMap<String, String>>,
/// Layers imported from a slot's independent content subtree.
slot_content: bool,
/// Mains being expanded (an instance inside its own main stops there).
symbols: Vec<String>,
}
@@ -768,7 +772,13 @@ impl<'a> Mapper<'a> {
// Private provenance for the .fig importer; ordinary REST import ignores it.
o.insert(
crate::rest::FIG_SOURCE_FIELD.into(),
json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}),
json!({
"origin": id,
"expanded": inst.is_some(),
"wireFrame": own == "FRAME",
"wireInstance": own == "INSTANCE",
"slotContent": inst.is_some_and(|i| i.slot_content),
}),
);
o.insert("type".into(), json!(ty));
copy_str(&mut o, &view, "name", "name");
@@ -797,6 +807,16 @@ impl<'a> Mapper<'a> {
self.component(&mut o, &view, &rest_id);
}
self.prop_refs(&mut o, &view, inst);
let slot_content = if own == "FRAME" {
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned()))
} else {
None
};
if slot_content.is_some()
&& let Some(source) = o.get_mut(crate::rest::FIG_SOURCE_FIELD).and_then(Value::as_object_mut)
{
source.insert("slotOverride".into(), json!(true));
}
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
let mut kids = Vec::new();
if ty == "INSTANCE" {
@@ -808,6 +828,14 @@ impl<'a> Mapper<'a> {
kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None));
}
}
} else if let Some(content) = slot_content {
let mut content_inst = inst.cloned();
if let Some(content_inst) = &mut content_inst {
content_inst.slot_content = true;
}
for c in self.children(&content) {
kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));
}
} else {
// Inside an instance a layer's children are its main's: they follow it from the main's
// size to its own.
@@ -1282,6 +1310,11 @@ impl<'a> Mapper<'a> {
None => continue,
},
"VARIANT" => json!({"type": "VARIANT"}),
"SLOT" => {
self.warn_non_default_slot_config(d);
self.warn_dropped_slot_preferred_values(d);
json!({"type": "SLOT"})
}
_ => {
self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase()));
continue;
@@ -1298,6 +1331,34 @@ impl<'a> Mapper<'a> {
}
}
/// Preferred slot values are source component keys, but this mapper has no proven way to map
/// those keys to file GUIDs. They are dropped instead of guessing native NodeIds.
fn warn_dropped_slot_preferred_values(&mut self, def: &Kv<'a>) {
let entries = def.get("preferredValues").and_then(|v| v.get("instanceSwapValues")).map(Kv::list).unwrap_or_default();
if !entries.is_empty() {
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
}
}
fn warn_non_default_slot_config(&mut self, def: &Kv<'a>) {
let Some(config) = def.get("slotPropConfig") else { return };
let non_default = [
("stretchChildOnInsert", Some(false)),
("displayByDefault", Some(false)),
("minChildren", None),
("maxChildren", None),
("allowPreferredValuesOnly", Some(false)),
]
.into_iter()
.any(|(field, bool_default)| {
let Some(value) = config.get(field) else { return false };
if let Some(default) = bool_default { value.bool() != Some(default) } else { value.f64() != Some(0.0) }
});
if non_default {
self.warn("non-default slot settings are not represented and were skipped");
}
}
/// A layer's component property references, and inside an instance the values they take.
fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) {
let mut refs = Obj::new();
@@ -1306,6 +1367,7 @@ impl<'a> Mapper<'a> {
Some("VISIBLE") => "visible",
Some("TEXT_DATA") => "characters",
Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent",
Some("SLOT_CONTENT_ID") => "slot",
_ => continue,
};
let Some(def) =
@@ -1313,7 +1375,11 @@ impl<'a> Mapper<'a> {
else {
continue;
};
if let Some((name, _)) = self.prop_defs.get(&def) {
// Expanded frames must follow their main frame's source link. A direct slot reference
// makes the engine resolve ownership against the instance, where the property is not defined.
if !(field == "slot" && inst.is_some())
&& let Some((name, _)) = self.prop_defs.get(&def)
{
refs.insert(field.into(), json!(name));
}
let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
@@ -1445,15 +1511,50 @@ impl<'a> Mapper<'a> {
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal));
}
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
all_props.entry(d).or_insert(val);
}
let Some(def) = a.get("defID").and_then(guid) else { continue };
if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
continue;
}
let var_value = a.get("varValue");
if var_value.and_then(|v| v.get("dataType")).and_then(Kv::str) != Some("SLOT_CONTENT_ID") {
self.warn("malformed slot content assignment was skipped");
continue;
}
let target = var_value.and_then(|v| v.get("value")).and_then(|v| v.get("slotContentIdValue")).and_then(|v| v.get("guid")).and_then(guid);
let Some(target) = target else {
self.warn("malformed slot content assignment was skipped");
continue;
};
if target == "4294967295:4294967295" {
self.warn("slot content assignment uses the empty GUID sentinel and was skipped");
continue;
}
if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
slot_contents.entry(def).or_insert(target);
}
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
symbols.push(sym.clone());
let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone());
Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym))
Some((
Inst {
root,
path: own_path,
overrides: Rc::new(overrides),
props: Rc::new(all_props),
slot_contents: Rc::new(slot_contents),
slot_content: false,
symbols,
},
sym,
))
}
fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
+2
View File
@@ -21,6 +21,8 @@ pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod source_identity_tests;
#[cfg(test)]
mod tests;
+225
View File
@@ -0,0 +1,225 @@
//! Component slot content from `.fig` files: SLOT definitions, the frames that reference them and
//! the content subtrees that instances assign, on synthetic messages.
use serde_json::Value;
use super::kiwi::Kv;
use super::map::to_rest;
fn g(local: u32) -> Kv<'static> {
Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))])
}
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn node(local: u32, kind: &'static str, parent: Option<u32>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", Kv::Str("Synthetic".into()))];
if let Some(parent) = parent {
all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("!".into()))])));
}
all.append(&mut fields);
o(all)
}
fn mapped(msg: &Kv<'_>) -> Value {
to_rest(msg, "Synthetic resources").0
}
fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> {
if v.get("id").and_then(Value::as_str) == Some(id) {
return Some(v);
}
v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| named_id(v, id))
}
fn changes<'a>(msg: &'a mut Kv<'static>) -> &'a mut Vec<Kv<'static>> {
let Kv::Object(fields) = msg else { panic!() };
let (_, Kv::Array(rows)) = fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!() };
rows
}
fn slot_message(content: Option<u32>, preferred: bool) -> Kv<'static> {
let slot_ref = o(vec![
("variableField", Kv::Enum("SLOT_CONTENT_ID")),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])),
]);
let mut instance_fields = vec![("symbolData", o(vec![("symbolID", g(10))]))];
if let Some(content) = content {
instance_fields.push((
"componentPropAssignments",
Kv::Array(vec![o(vec![
("defID", g(70)),
("value", o(vec![])),
(
"varValue",
o(vec![
("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])),
("dataType", Kv::Enum("SLOT_CONTENT_ID")),
("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")),
]),
),
])]),
));
}
let mut definition = vec![("id", g(70)), ("name", Kv::Str("Content".into())), ("type", Kv::Enum("SLOT"))];
if preferred {
definition.push((
"preferredValues",
o(vec![("instanceSwapValues", Kv::Array(vec![o(vec![("type", Kv::Enum("COMPONENT")), ("key", Kv::Str("unresolved-key".into()))])]))]),
));
}
o(vec![(
"nodeChanges",
Kv::Array(vec![
node(1, "DOCUMENT", None, vec![]),
node(2, "CANVAS", Some(1), vec![]),
node(3, "CANVAS", Some(1), vec![("internalOnly", Kv::Bool(true))]),
node(10, "SYMBOL", Some(2), vec![("componentPropDefs", Kv::Array(vec![o(definition)]))]),
node(12, "FRAME", Some(10), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]),
node(13, "RECTANGLE", Some(12), vec![]),
node(20, "INSTANCE", Some(2), instance_fields),
node(30, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]),
node(31, "RECTANGLE", Some(30), vec![]),
node(32, "ELLIPSE", Some(30), vec![]),
node(33, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]),
]),
)])
}
#[test]
fn slot_definition_reference_and_arbitrary_override_children_are_imported() {
let rest = mapped(&slot_message(Some(30), false));
let main = named_id(&rest["document"], "9:10").unwrap();
assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT");
let main_slot = named_id(&rest["document"], "9:12").unwrap();
assert_eq!(main_slot["componentPropertyReferences"]["slot"], "Content#9:70");
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert!(slot.get("componentPropertyReferences").is_none());
assert_eq!(slot[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true);
let kids = slot["children"].as_array().unwrap();
assert_eq!(kids.len(), 2);
assert_eq!(kids[0]["id"], "I9:20;9:31");
assert_eq!(kids[1]["id"], "I9:20;9:32");
let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let main = doc
.node(*doc.pages.first().unwrap())
.unwrap()
.children
.iter()
.find_map(|id| doc.node(*id).filter(|n| n.name == "Synthetic" && matches!(n.kind, figcraft_doc::NodeKind::Component { .. })))
.unwrap();
assert!(
matches!(&main.kind, figcraft_doc::NodeKind::Component { props, .. } if matches!(props.get("Content"), Some(figcraft_doc::PropDef::Slot { preferred }) if preferred.is_empty()))
);
let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap();
let slot = doc.node(instance.children[0]).unwrap();
assert!(!slot.prop_refs.contains_key("slot"));
assert_eq!(slot.source, Some(main.children[0]));
assert!(slot.overrides.contains("slot"));
assert_eq!(slot.children.len(), 2);
assert!(slot.children.iter().all(|id| doc.node(*id).is_some_and(|child| child.source.is_none())));
let (public, _) = crate::rest::import(&rest, &crate::rest::no_images).unwrap();
let public_instance = public.ids().filter_map(|id| public.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap();
let public_slot = public.node(public_instance.children[0]).unwrap();
assert!(!public_slot.overrides.contains("slot"));
}
#[test]
fn nested_instance_in_own_slot_content_keeps_its_own_source_tree() {
let mut msg = slot_message(Some(30), false);
let rows = changes(&mut msg);
let slot_frame = rows.iter_mut().find(|row| row.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(31.0)).unwrap();
let Kv::Object(fields) = slot_frame else { panic!() };
fields.iter_mut().find(|(key, _)| *key == "type").unwrap().1 = Kv::Enum("FRAME");
rows.extend([
node(34, "INSTANCE", Some(31), vec![("symbolData", o(vec![("symbolID", g(40))]))]),
node(40, "SYMBOL", Some(2), vec![]),
node(41, "FRAME", Some(40), vec![]),
node(42, "RECTANGLE", Some(41), vec![]),
]);
let rest = mapped(&msg);
let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let outer = doc
.ids()
.filter_map(|id| doc.node(id))
.find(|n| {
matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })
&& !doc.ancestors(n.id).iter().any(|a| doc.node(*a).is_some_and(|p| matches!(p.kind, figcraft_doc::NodeKind::Instance { .. })))
})
.unwrap();
let slot = doc.node(outer.children[0]).unwrap();
let own_frame = slot.children.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap();
assert!(own_frame.source.is_none());
let nested = slot
.children
.iter()
.filter_map(|id| doc.node(*id))
.flat_map(|n| n.children.iter())
.filter_map(|id| doc.node(*id))
.find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }))
.unwrap();
let figcraft_doc::NodeKind::Instance { component, .. } = nested.kind else { panic!("not an instance") };
assert_eq!(nested.source, Some(component));
let main_frame = doc.node(doc.node(component).unwrap().children[0]).unwrap();
let expanded_frame = doc.node(nested.children[0]).unwrap();
assert_eq!(expanded_frame.source, Some(main_frame.id));
assert_eq!(expanded_frame.children.len(), 1);
assert_eq!(doc.node(expanded_frame.children[0]).unwrap().source, Some(main_frame.children[0]));
}
#[test]
fn outer_slot_assignment_wins_over_a_nested_instances_own() {
let mut msg = slot_message(Some(30), false);
let assignment = |content: u32| {
Kv::Array(vec![o(vec![
("defID", g(70)),
("value", o(vec![])),
(
"varValue",
o(vec![
("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])),
("dataType", Kv::Enum("SLOT_CONTENT_ID")),
("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")),
]),
),
])])
};
// The nested instance inside the main assigns the empty slot content; the outer instance's content wins.
let slot_ref = o(vec![
("variableField", Kv::Enum("SLOT_CONTENT_ID")),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])),
]);
changes(&mut msg).extend([
node(14, "INSTANCE", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), ("componentPropAssignments", assignment(33))]),
node(40, "SYMBOL", Some(2), vec![]),
node(41, "FRAME", Some(40), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]),
]);
let rest = mapped(&msg);
let slot = named_id(&rest["document"], "I9:20;9:14;9:41").unwrap();
assert_eq!(slot["children"].as_array().map_or(0, Vec::len), 2, "{slot}");
}
#[test]
fn unassigned_slot_keeps_default_and_bad_content_target_warns() {
let rest = mapped(&slot_message(None, false));
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
assert!(slot.get("overrides").is_none());
let (rest, warnings) = to_rest(&slot_message(Some(999), false), "Synthetic");
assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}");
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
let (rest, warnings) = to_rest(&slot_message(Some(33), true), "Synthetic");
let main = named_id(&rest["document"], "9:10").unwrap();
assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT");
assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}");
let empty = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(empty["children"].as_array().map_or(0, Vec::len), 0);
assert_eq!(empty[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true);
}
+8 -3
View File
@@ -17,6 +17,7 @@ struct Origin<'a> {
rest: &'a str,
guid: &'a str,
expanded: bool,
slot_content: bool,
wire_frame: bool,
wire_instance: bool,
component: Option<&'a str>,
@@ -75,7 +76,8 @@ impl<'a> Sources<'a> {
&& meta.get("wireInstance").and_then(Value::as_bool) == Some(true)
&& node.get("type").and_then(Value::as_str) == Some("INSTANCE");
let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten();
self.nodes.insert(id, Origin { rest, guid: origin, expanded, wire_frame, wire_instance, component });
let slot_content = meta.get("slotContent").and_then(Value::as_bool).unwrap_or(false);
self.nodes.insert(id, Origin { rest, guid: origin, expanded, slot_content, wire_frame, wire_instance, component });
}
fn origin(&self, id: NodeId) -> Option<Origin<'a>> {
@@ -211,6 +213,9 @@ impl<'a> Sources<'a> {
let found = (|| {
let meta = self.origin(id)?;
let _ = doc.node(id)?;
if meta.slot_content {
return if matches!(doc.node(id)?.kind, NodeKind::Instance { .. }) { self.root_source(id, doc) } else { None };
}
if !meta.expanded {
return self.root_source(id, doc);
}
@@ -269,8 +274,8 @@ impl<'a> Sources<'a> {
}
let mut missing = self.rejected;
for (&id, meta) in &self.nodes {
let needs_source =
(meta.expanded && !self.owned_layer(id, doc, ids)) || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }));
let needs_source = (!meta.slot_content && meta.expanded && !self.owned_layer(id, doc, ids))
|| doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }));
if !needs_source {
continue;
}
+7 -1
View File
@@ -451,6 +451,10 @@ impl<'a> Ctx<'a> {
let id = self.doc.alloc_id();
let label = kind.label();
let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h);
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true)
{
n.overrides.insert("slot".into());
}
n.transform = local(space.world, world);
self.common(&mut n, j, parent_mode);
let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode));
@@ -605,7 +609,8 @@ impl<'a> Ctx<'a> {
n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode);
n.exports = props::export_settings(j);
for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() {
if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) {
let supported = matches!(field.as_str(), "visible" | "characters" | "mainComponent") || (field == "slot" && self.fig_sources.is_some());
if let (Some(p), true) = (prop.as_str(), supported) {
n.prop_refs.insert(field.clone(), p.to_string());
}
}
@@ -709,6 +714,7 @@ impl<'a> Ctx<'a> {
continue;
}
Some("VARIANT") => continue,
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
_ => {
self.warn("unknown component property types were skipped");
continue;