diff --git a/apps/figcraft-web/src/api.rs b/apps/figcraft-web/src/api.rs
index 167ea7687..cb82820b7 100644
--- a/apps/figcraft-web/src/api.rs
+++ b/apps/figcraft-web/src/api.rs
@@ -37,7 +37,7 @@ use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
-use wasm_bindgen_futures::{JsFuture, future_to_promise};
+use wasm_bindgen_futures::JsFuture;
use crate::saved_files::SavedFiles;
@@ -150,15 +150,6 @@ async fn sleep(ms: i32) {
let _ = JsFuture::from(promise).await;
}
-/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
-fn outcome(reply: &Value) -> Result {
- if reply.get("ok").and_then(Value::as_bool) == Some(true) {
- Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
- } else {
- Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
- }
-}
-
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
@@ -169,20 +160,20 @@ pub fn install() -> Result<(), JsValue> {
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
let window = web_sys::window().ok_or("no window")?;
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
- set_fn(
- &obj,
- "inspect",
- Closure:: js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("document.inspect", json!({})).await) }))
- .into_js_value(),
- )?;
+ // `inspect` is `docInspect` under its earlier name: the crate's function, so it times out too.
+ let doc_inspect = js_sys::Reflect::get(&obj, &"docInspect".into())?;
+ js_sys::Reflect::set(&obj, &"inspect".into(), &doc_inspect)?;
+ // `openFile` runs `file.open` through the crate's `execute`, so a call the app does not answer times out and is cancelled.
+ let execute: js_sys::Function = js_sys::Reflect::get(&obj, &"execute".into())?.unchecked_into();
+ let this = obj.clone();
set_fn(
&obj,
"openFile",
- Closure:: js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
+ Closure:: JsValue>::new(move |name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
- future_to_promise(async move {
- outcome(&send("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})).await)
- })
+ execute
+ .call2(&this, &"file.open".into(), &to_js(&json!({"name": name, "data": data})))
+ .unwrap_or_else(|e| js_sys::Promise::reject(&e).into())
})
.into_js_value(),
)?;
diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs
index cb273fc51..06c82563a 100644
--- a/crates/engine/src/assets/components.rs
+++ b/crates/engine/src/assets/components.rs
@@ -298,6 +298,30 @@ pub fn prop_list(doc: &Document, c: NodeId) -> Vec<(String, PropDef)> {
}
}
+/// The properties an instance of `component` lists: [`prop_list`] without the boolean, text and
+/// instance swap properties that no layer of the component reads. A set's variants share its
+/// definitions, so one a variant's layers don't bind (another variant's text, a leftover of a
+/// deleted variant) stays defined, and shows on the set, but not on this variant's instances
+/// (#2022). Slots stay. Walks as [`slots_of`] does: a nested instance counts, its own layers and
+/// the content put into slots are not entered.
+pub fn instance_prop_list(doc: &Document, component: NodeId) -> Vec<(String, PropDef)> {
+ let mut used: std::collections::HashSet = std::collections::HashSet::new();
+ let mut stack = vec![component];
+ let mut seen = 0usize;
+ while let Some(c) = stack.pop() {
+ seen += 1;
+ if seen > 1_000_000 {
+ break;
+ }
+ let Some(n) = doc.node(c) else { continue };
+ used.extend(n.prop_refs.values().cloned());
+ if c == component || (!is_instance(doc, c) && !holds_content(n)) {
+ stack.extend(n.children.iter().rev().copied());
+ }
+ }
+ prop_list(doc, component).into_iter().filter(|(k, d)| !takes_value(d) || used.contains(k)).collect()
+}
+
/// Whether instances store a value for the property (every kind but slots).
pub fn takes_value(d: &PropDef) -> bool {
!matches!(d, PropDef::Slot { .. })
diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs
index ffaac176f..fc7d21e86 100644
--- a/crates/engine/src/assets/components/tests.rs
+++ b/crates/engine/src/assets/components/tests.rs
@@ -395,8 +395,11 @@ fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId,
ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
assert!(node(s, title).prop_refs.is_empty());
assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
- let desc = ex(s, "component.properties", json!({"id": i}));
+ // "Show dot" is still defined but no layer reads it any more: the main lists it, the instance doesn't (#2022).
+ let desc = ex(s, "component.properties", json!({"id": c}));
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
+ let desc = ex(s, "component.properties", json!({"id": i}));
+ assert!(desc["properties"].as_array().unwrap().is_empty());
// A variant property on a lone component makes it a component set.
let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs
index 2d97d6f0a..97bef3ce2 100644
--- a/crates/engine/src/cmd/component/props.rs
+++ b/crates/engine/src/cmd/component/props.rs
@@ -8,7 +8,7 @@ use super::{
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
variant_options, variant_props, variant_set,
};
-use crate::components::prop_list;
+use crate::components::{instance_prop_list, prop_list};
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
/// boolean property takes a boolean variable, a text or variant property a string one).
@@ -606,7 +606,8 @@ pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
}
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
- for (k, d) in prop_list(doc, comp) {
+ let defs = if values.is_some() { instance_prop_list(doc, comp) } else { prop_list(doc, comp) };
+ for (k, d) in defs {
let mut v = def_json(&k, &d);
if !takes_value(&d) {
let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
diff --git a/crates/engine/src/prop_order_tests.rs b/crates/engine/src/prop_order_tests.rs
index 02f329e15..3b996e250 100644
--- a/crates/engine/src/prop_order_tests.rs
+++ b/crates/engine/src/prop_order_tests.rs
@@ -77,8 +77,9 @@ fn every_kind_of_property_keeps_its_place_on_rename() {
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon_f]})));
let nested = idv(&ex(&mut s, "instance.create", json!({"component": icon, "x": 60, "y": 10, "parent": f})));
let small = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ let zoom_layer = idv(&ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 10, "width": 10, "height": 10, "parent": small})));
// Made in an order that isn't by name: Zoom (boolean), Label (text), Glyph (instance swap), Body (slot).
- ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean"}));
+ ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean", "layer": zoom_layer}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Label", "type": "text", "layer": label}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Glyph", "type": "instanceSwap", "layer": nested}));
ex(&mut s, "component.makeSlot", json!({"id": body}));
@@ -133,3 +134,27 @@ fn make_slot_takes_the_name_typed() {
by_name.sort();
assert_eq!(listed, by_name);
}
+
+/// An instance lists the properties its own variant's layers use (#2022): in Figma's data a set
+/// has one text property per variant (and leftovers of deleted ones), measured: an instance of
+/// variant A lists `State` and `A label`, never `B label` or a property no layer reads.
+#[test]
+fn an_instance_lists_only_the_properties_its_variant_uses() {
+ let mut s = Session::new();
+ let mut variants = Vec::new();
+ let mut texts = Vec::new();
+ for (k, name) in ["Type=Flag, State=Default", "Type=Flag, State=Selected"].iter().enumerate() {
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": k * 60, "width": 120, "height": 40, "name": name})));
+ texts.push(idv(&ex(&mut s, "text.create", json!({"text": "Label", "x": 4, "y": 4, "parent": f, "name": "Label"}))));
+ variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
+ }
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
+ ex(&mut s, "component.addProperty", json!({"id": set, "name": "Default label", "type": "text", "layer": texts[0]}));
+ ex(&mut s, "component.addProperty", json!({"id": set, "name": "Selected label", "type": "text", "layer": texts[1]}));
+ ex(&mut s, "component.addProperty", json!({"id": set, "name": "Gone label", "type": "text"}));
+ assert_eq!(names(&mut s, set), ["Type", "State", "Default label", "Selected label", "Gone label"], "the set keeps every definition");
+ let inst = idv(&ex(&mut s, "instance.create", json!({"component": variants[0], "x": 400, "y": 0})));
+ assert_eq!(names(&mut s, inst), ["Type", "State", "Default label"]);
+ ex(&mut s, "instance.setProperties", json!({"ids": [inst], "properties": {"State": "Selected"}}));
+ assert_eq!(names(&mut s, inst), ["Type", "State", "Selected label"]);
+}
diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs
index adb96d541..4fbe5066a 100644
--- a/crates/engine/src/slot_tests.rs
+++ b/crates/engine/src/slot_tests.rs
@@ -417,12 +417,16 @@ fn assert_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
assert!(html.contains(&format!(">{expected}
")), "slot Label HTML: {html}");
}
-fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
+fn assert_stored_slot_text(s: &Session, instance: NodeId, expected: &str) {
assert!(
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))),
"instance {instance} must store its own Text value {expected:?}: {:?}",
node(s, instance).kind
);
+}
+
+fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
+ assert_stored_slot_text(s, instance, expected);
let properties = props_of(s, instance);
let text = properties.iter().find(|p| p["name"] == "Text").unwrap_or_else(|| panic!("instance {instance} has no Text property: {properties:?}"));
assert_eq!(text["value"], expected, "instance {instance} Text property");
@@ -475,14 +479,15 @@ fn slot_text_properties_do_not_read_an_unbound_base_property() {
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
let nested_base = kids(&s, i)[0];
- assert_local_slot_text(&mut s, nested_base, "Wrong owner");
+ // Bound to no layer, the base's Text isn't listed on its instance (#2269); its value stays stored.
+ assert_stored_slot_text(&s, nested_base, "Wrong owner");
let nested = copy_of(&s, i, main_content);
assert_local_slot_text(&mut s, nested, "Default");
assert_slot_text(&mut s, nested, "Default");
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
assert_local_slot_text(&mut s, nested, "Custom");
assert_slot_text(&mut s, nested, "Custom");
- assert_local_slot_text(&mut s, nested_base, "Wrong owner");
+ assert_stored_slot_text(&s, nested_base, "Wrong owner");
assert_slot_text(&mut s, main_content, "Default");
healthy(&s);
}
diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs
index 24248d32a..150cac39a 100644
--- a/crates/format/src/fig/prop_value_tests.rs
+++ b/crates/format/src/fig/prop_value_tests.rs
@@ -186,3 +186,43 @@ fn property_values_written_only_as_variables_are_applied() {
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
}
+
+/// #2022: a set keeps each named text definition under its own name (Figma writes one per variant,
+/// plus leftovers of deleted variants), and each variant's text reads its own copy.
+#[test]
+fn a_set_keeps_each_named_text_definition_and_layers_read_their_own() {
+ let text = |chars: &str| o(vec![("textValue", o(vec![("characters", s(chars))]))]);
+ let names = ["Type=Flag / State=Default label", "Type=Flag / State=Selected label", "Type=Issue / State=Default label"];
+ let set_defs: Vec> =
+ names.iter().zip([70, 71, 72]).zip(["One", "Two", "Gone"]).map(|((n, id), c)| definition(id, n, "TEXT", text(c))).collect();
+ let copies = |first: u32| Kv::Array((0..3).map(|k| o(vec![("id", g(first + k)), ("parentPropDefId", g(70 + k))])).collect());
+ let msg = message(vec![
+ node(9, "FRAME", "Chip", Some(2), vec![("isStateGroup", Kv::Bool(true)), ("componentPropDefs", Kv::Array(set_defs))]),
+ node(10, "SYMBOL", "Type=Flag, State=Default", Some(9), vec![("componentPropDefs", copies(80))]),
+ node(11, "TEXT", "Label", Some(10), vec![bound("TEXT_DATA", 80)]),
+ node(20, "SYMBOL", "Type=Flag, State=Selected", Some(9), vec![("componentPropDefs", copies(90))]),
+ node(21, "TEXT", "Label", Some(20), vec![bound("TEXT_DATA", 91)]),
+ ]);
+ let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
+ let defs = &by_id(&rest["document"], "9:9")["componentPropertyDefinitions"];
+ for (k, n) in names.iter().enumerate() {
+ assert_eq!(defs[format!("{n}#9:{}", 70 + k)]["type"], "TEXT", "{n}");
+ }
+ assert_eq!(defs.as_object().map(|d| d.len()), Some(3), "{defs}");
+ assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["characters"], format!("{}#9:70", names[0]));
+ assert_eq!(by_id(&rest["document"], "9:21")["componentPropertyReferences"]["characters"], format!("{}#9:71", names[1]));
+
+ let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
+ let refs: Vec