mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge pull request #847 from MortenHusted/fix/fig-component-props
M3: .fig imports keep component property bindings and values
This commit is contained in:
@@ -368,6 +368,9 @@ struct Mapper<'a> {
|
||||
blobs: Vec<&'a [u8]>,
|
||||
/// Component property definitions: id → (REST name "Name#id", type).
|
||||
prop_defs: BTreeMap<String, (String, &'a str)>,
|
||||
/// The copies of a set's definitions each variant keeps (no name or type of their own; the
|
||||
/// variant's layers and its instances' assignments use them): copy id → the definition's id.
|
||||
prop_parents: BTreeMap<String, String>,
|
||||
/// File nodes by asset `key` and `version`: references can name a collection, variable or
|
||||
/// style as the asset instead of the guid, and the file carries the resources to resolve
|
||||
/// them (the collection node itself says the same `key`).
|
||||
@@ -402,6 +405,7 @@ impl<'a> Mapper<'a> {
|
||||
let mut nodes = BTreeMap::new();
|
||||
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
|
||||
let mut prop_defs = BTreeMap::new();
|
||||
let mut prop_parents = BTreeMap::new();
|
||||
let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default();
|
||||
// The last change of a GUID wins; a REMOVED change deletes what came before it.
|
||||
for n in changes {
|
||||
@@ -423,10 +427,11 @@ impl<'a> Mapper<'a> {
|
||||
kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
|
||||
}
|
||||
for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() {
|
||||
if let (Some(did), Some(name), Some(ty)) =
|
||||
(d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str))
|
||||
{
|
||||
let Some(did) = d.get("id").and_then(guid) else { continue };
|
||||
if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) {
|
||||
prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty));
|
||||
} else if let Some(parent) = d.get("parentPropDefId").and_then(guid) {
|
||||
prop_parents.insert(did, parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,6 +455,7 @@ impl<'a> Mapper<'a> {
|
||||
kids,
|
||||
blobs,
|
||||
prop_defs,
|
||||
prop_parents,
|
||||
assets,
|
||||
styles: Obj::new(),
|
||||
components: Obj::new(),
|
||||
@@ -640,7 +646,7 @@ impl<'a> Mapper<'a> {
|
||||
targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid));
|
||||
for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() {
|
||||
if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) {
|
||||
targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid));
|
||||
targets.extend(assigned(assignment).and_then(prop_guid));
|
||||
}
|
||||
}
|
||||
let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default();
|
||||
@@ -651,7 +657,31 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
|
||||
fn is_instance_swap(&self, def: &Kv) -> Option<()> {
|
||||
matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
|
||||
matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
|
||||
}
|
||||
|
||||
/// The definition a copy of it was made from, followed to the original: the values an instance
|
||||
/// sets are kept under it, since an assignment and the layers that read it may name different
|
||||
/// copies of one definition.
|
||||
fn prop_root(&self, def: &str) -> String {
|
||||
let mut def = def;
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let Some(parent) = self.prop_parents.get(def) else { break };
|
||||
def = parent;
|
||||
}
|
||||
def.to_string()
|
||||
}
|
||||
|
||||
/// The definition `def` names: its own, or the set's for a variant's copy of it.
|
||||
fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> {
|
||||
let mut def = def;
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
if let Some(found) = self.prop_defs.get(def) {
|
||||
return Some(found);
|
||||
}
|
||||
def = self.prop_parents.get(def)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The branches of the internal canvas `id` that hold the `required_mains` (with the frames
|
||||
@@ -908,7 +938,7 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
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()))
|
||||
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1892,11 +1922,11 @@ impl<'a> Mapper<'a> {
|
||||
// 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)
|
||||
&& let Some((name, _)) = self.prop_def(&def)
|
||||
{
|
||||
refs.insert(field.into(), json!(name));
|
||||
}
|
||||
let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
|
||||
let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
|
||||
match field {
|
||||
"visible" => {
|
||||
if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
|
||||
@@ -1911,7 +1941,7 @@ impl<'a> Mapper<'a> {
|
||||
if own_text(v) != Some(t) {
|
||||
o.remove("characterStyleOverrides");
|
||||
o.remove("styleOverrideTable");
|
||||
if let Some(tv) = val.get("textValue") {
|
||||
if let Some(tv) = prop_text_data(val) {
|
||||
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
|
||||
char_styles(o, tv, size, &mut |w| self.warn(w));
|
||||
}
|
||||
@@ -1933,7 +1963,7 @@ impl<'a> Mapper<'a> {
|
||||
// An instance swap property value picks the main.
|
||||
if let Some(i) = outer
|
||||
&& let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
|
||||
&& let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid)
|
||||
&& let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p))
|
||||
{
|
||||
sym = Some(g);
|
||||
}
|
||||
@@ -1945,12 +1975,12 @@ impl<'a> Mapper<'a> {
|
||||
let assignments = v.list("componentPropAssignments");
|
||||
let mut props = Obj::new();
|
||||
for a in assignments {
|
||||
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue };
|
||||
let val = a.get("value");
|
||||
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue };
|
||||
let val = assigned(a);
|
||||
let p = match ty {
|
||||
"BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})),
|
||||
"TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})),
|
||||
"INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
|
||||
"INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(p) = p {
|
||||
@@ -2027,11 +2057,11 @@ impl<'a> Mapper<'a> {
|
||||
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);
|
||||
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
|
||||
all_props.entry(self.prop_root(&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") {
|
||||
if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
|
||||
continue;
|
||||
}
|
||||
let var_value = a.get("varValue");
|
||||
@@ -2052,7 +2082,7 @@ impl<'a> Mapper<'a> {
|
||||
self.warn("slot content assignment target is missing or is not slot content");
|
||||
continue;
|
||||
}
|
||||
slot_contents.entry(def).or_insert(target);
|
||||
slot_contents.entry(self.prop_root(&def)).or_insert(target);
|
||||
}
|
||||
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
|
||||
symbols.push(sym.clone());
|
||||
@@ -2777,7 +2807,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dyn FnMut(Str
|
||||
|
||||
/// The text of a component property value.
|
||||
fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
|
||||
v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
|
||||
prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str)
|
||||
}
|
||||
|
||||
/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form.
|
||||
fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
|
||||
v.get("textValue").or_else(|| v.get("textDataValue"))
|
||||
}
|
||||
|
||||
/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form.
|
||||
fn prop_guid(v: &Kv) -> Option<String> {
|
||||
v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid))
|
||||
}
|
||||
|
||||
/// The value a component property assignment sets: `value`, or the variable form `varValue` holds
|
||||
/// when `value` is empty, as current files write many booleans and instance swaps.
|
||||
fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
|
||||
assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value"))
|
||||
}
|
||||
|
||||
/// The property a layer's `field` is bound to (`parameterConsumptionMap`).
|
||||
|
||||
@@ -26,6 +26,8 @@ pub mod kiwi;
|
||||
mod map;
|
||||
mod network;
|
||||
#[cfg(test)]
|
||||
mod prop_value_tests;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod source_identity_tests;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a
|
||||
//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and
|
||||
//! assignments that carry their value only in the variable form (`varValue`).
|
||||
|
||||
use figcraft_doc::NodeKind;
|
||||
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 s(text: &str) -> Kv<'static> {
|
||||
Kv::Str(text.into())
|
||||
}
|
||||
|
||||
fn node(local: u32, kind: &'static str, name: &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", s(name))];
|
||||
if let Some(parent) = parent {
|
||||
all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))])));
|
||||
}
|
||||
all.append(&mut fields);
|
||||
o(all)
|
||||
}
|
||||
|
||||
/// A layer's `field` bound to property definition `def`.
|
||||
fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) {
|
||||
let entry = o(vec![
|
||||
("variableField", Kv::Enum(field)),
|
||||
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])),
|
||||
]);
|
||||
("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))]))
|
||||
}
|
||||
|
||||
fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> {
|
||||
o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)])
|
||||
}
|
||||
|
||||
/// An assignment of `def` that leaves `value` empty and says it in `varValue` only.
|
||||
fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> {
|
||||
o(vec![
|
||||
("defID", g(def)),
|
||||
("value", o(vec![])),
|
||||
("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])),
|
||||
])
|
||||
}
|
||||
|
||||
fn message(nodes: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
|
||||
all.extend(nodes);
|
||||
o(vec![("nodeChanges", Kv::Array(all))])
|
||||
}
|
||||
|
||||
fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value {
|
||||
fn find<'v>(v: &'v Value, id: &str) -> Option<&'v 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| find(v, id))
|
||||
}
|
||||
find(v, id).unwrap_or_else(|| panic!("no layer {id}"))
|
||||
}
|
||||
|
||||
fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap<String, Value> {
|
||||
let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap();
|
||||
let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") };
|
||||
props
|
||||
}
|
||||
|
||||
/// A "Field" set with a BOOL definition (9:70), a variant whose "Hint text" reads its copy (9:71),
|
||||
/// and an instance of the variant that sets the property false through definition `assigned`.
|
||||
fn field_set(assigned: u32) -> Kv<'static> {
|
||||
message(vec![
|
||||
node(
|
||||
9,
|
||||
"FRAME",
|
||||
"Field",
|
||||
Some(2),
|
||||
vec![
|
||||
("isStateGroup", Kv::Bool(true)),
|
||||
("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])),
|
||||
],
|
||||
),
|
||||
node(
|
||||
10,
|
||||
"SYMBOL",
|
||||
"State=Default",
|
||||
Some(9),
|
||||
vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))],
|
||||
),
|
||||
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]),
|
||||
node(
|
||||
20,
|
||||
"INSTANCE",
|
||||
"Instance",
|
||||
Some(2),
|
||||
vec![
|
||||
("symbolData", o(vec![("symbolID", g(10))])),
|
||||
("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])),
|
||||
],
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_layers_bind_to_the_set_properties_their_copies_name() {
|
||||
let msg = field_set(71);
|
||||
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
|
||||
assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN");
|
||||
assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70");
|
||||
assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false);
|
||||
|
||||
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
|
||||
let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap();
|
||||
assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint"));
|
||||
assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false)));
|
||||
}
|
||||
|
||||
/// An assignment reaches layers that read another copy of its definition: here the instance sets
|
||||
/// the set's own definition while the variant's layer reads the variant's copy.
|
||||
#[test]
|
||||
fn assignments_reach_layers_that_read_another_copy_of_their_definition() {
|
||||
let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES);
|
||||
let instance = by_id(&rest["document"], "9:20");
|
||||
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
|
||||
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_values_written_only_as_variables_are_applied() {
|
||||
let msg = message(vec![
|
||||
node(
|
||||
10,
|
||||
"SYMBOL",
|
||||
"Field",
|
||||
Some(2),
|
||||
vec![(
|
||||
"componentPropDefs",
|
||||
Kv::Array(vec![
|
||||
definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])),
|
||||
definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])),
|
||||
definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])),
|
||||
]),
|
||||
)],
|
||||
),
|
||||
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]),
|
||||
node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]),
|
||||
node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]),
|
||||
node(40, "SYMBOL", "Mail", Some(2), vec![]),
|
||||
node(41, "SYMBOL", "Card", Some(2), vec![]),
|
||||
node(
|
||||
20,
|
||||
"INSTANCE",
|
||||
"Instance",
|
||||
Some(2),
|
||||
vec![
|
||||
("symbolData", o(vec![("symbolID", g(10))])),
|
||||
(
|
||||
"componentPropAssignments",
|
||||
Kv::Array(vec![
|
||||
variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])),
|
||||
variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])),
|
||||
variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
|
||||
let instance = by_id(&rest["document"], "9:20");
|
||||
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
|
||||
assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card");
|
||||
assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41");
|
||||
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
|
||||
assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card");
|
||||
assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41");
|
||||
|
||||
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
|
||||
let props = instance_props(&doc);
|
||||
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
|
||||
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
|
||||
}
|
||||
Reference in New Issue
Block a user