Merge remote-tracking branch 'origin/main' into landb/2200

This commit is contained in:
bflatastic committed 2026-11-12 10:51:44 +07:00
commit cdaee03e5b
13 files changed
+229 -37

No files matched your search

+11 -20
View File
@@ -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<JsValue, JsValue> {
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::<dyn Fn() -> 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::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
Closure::<dyn Fn(String, js_sys::Uint8Array) -> 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(),
)?;
+24
View File
@@ -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<String> = 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 { .. })
+4 -1
View File
@@ -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 { .. }));
+3 -2
View File
@@ -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<NodeId> = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
+26 -1
View File
@@ -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"]);
}
+8 -3
View File
@@ -417,12 +417,16 @@ fn assert_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
assert!(html.contains(&format!(">{expected}</p>")), "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);
}
+40
View File
@@ -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<Kv<'static>> =
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<Option<String>> = doc
.ids()
.filter_map(|id| doc.node(id))
.filter(|n| n.name == "Label" && n.source.is_none())
.map(|n| n.prop_refs.get("characters").cloned())
.collect();
assert_eq!(refs.len(), 2, "{refs:?}");
assert!(refs.contains(&Some(names[0].to_string())) && refs.contains(&Some(names[1].to_string())), "{refs:?}");
for set in doc.ids().filter_map(|id| doc.node(id)).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
let NodeKind::Component { props, .. } = &set.kind else { continue };
assert!(names.iter().all(|n| props.contains_key(*n)), "{} holds {:?}", set.name, props.keys().collect::<Vec<_>>());
}
}
+69
View File
@@ -128,6 +128,7 @@ name = "Svenska"
"Another server, or one that needs its key. The usual server is set in Preferences." = "En annan server, eller en som kräver sin nyckel. Standardservern anges i Inställningar."
"Any status" = "Valfri status"
"Anyone on your network with this code can edit the file, while this app stays open." = "Alla i ditt nätverk som har den här koden kan redigera filen så länge appen är öppen."
"Anyone on your network with this code can look at the file, not change it, while this app stays open." = "Alla i ditt nätverk som har den här koden kan titta på filen, men inte ändra den, så länge appen är öppen."
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Alla med redigeringslänken kan ändra filen; visningslänken låter bara andra titta. Länkarna innehåller sina hemligheter: dela dem som lösenord. `figcraft-server links` skapar fler och återkallar dem."
"Anyone with this code can edit the file." = "Alla med den här koden kan redigera filen."
"Anyone with this code can look at the file, not change it." = "Alla med den här koden kan titta på filen, men inte ändra den."
@@ -388,6 +389,8 @@ name = "Svenska"
"Crop marks" = "Skärmärken"
"Ctrl+arrows" = "Ctrl+piltangenter"
"Ctrl+click" = "Ctrl+klick"
"Ctrl+click a padding field" = "Ctrl+klicka på ett padding-fält"
"⌘ click a padding field" = "⌘ klicka på ett padding-fält"
"Ctrl+scroll" = "Ctrl+rullning"
"Current name" = "Aktuellt namn"
"Current version" = "Aktuell version"
@@ -407,6 +410,40 @@ name = "Svenska"
"Decoration style" = "Dekorationsstil"
"Decoration" = "Dekoration"
"Deep select" = "Djupmarkering"
"Scrub a field faster (2x) or slower (1/2, 1/4)" = "Dragjustera ett fält snabbare (2x) eller långsammare (1/2, 1/4)"
"Move up or down while dragging its label" = "Flytta upp eller ned medan du drar dess etikett"
"Scrubbing at 2x" = "Dragjustering med 2x hastighet"
"Scrubbing at 1x" = "Dragjustering med 1x hastighet"
"Scrubbing at 1/2" = "Dragjustering med 1/2 hastighet"
"Scrubbing at 1/4" = "Dragjustering med 1/4 hastighet"
"Delete {n} variables" = { one = "Radera {n} variabel", other = "Radera {n} variabler" }
"Move out of any group" = "Flytta ut ur alla grupper"
"Move to group {group}" = "Flytta till gruppen {group}"
"Scope: all supported properties" = "Omfång: alla egenskaper som stöds"
"Scope: hidden from pickers" = "Omfång: dold i väljare"
"Scope: only {scope}" = "Omfång: endast {scope}"
"Disconnected. Reconnecting…" = "Frånkopplad. Återansluter…"
"Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…" = "Frånkopplad. Dina redigeringar finns kvar här och synkas när anslutningen är tillbaka. Återansluter…"
"Dismiss" = "Stäng"
"Not connected: {reason}. Your work is still in this tab." = "Inte ansluten: {reason}. Ditt arbete finns kvar i den här fliken."
"Retry now" = "Försök igen nu"
"Save a copy…" = "Spara en kopia…"
"Wrap style" = "Radbrytningsstil"
"Text direction" = "Textriktning"
"Auto: by the first letter with a direction" = "Auto: efter den första bokstaven som har en riktning"
"Left to right" = "Vänster till höger"
"Right to left" = "Höger till vänster"
"Balance" = "Balans"
"Pretty" = "Snygg"
"Auto: as many words as fit on each line" = "Auto: så många ord som får plats på varje rad"
"Balance: every line close to the same length" = "Balans: alla rader ungefär lika långa"
"Pretty: full lines, no one word alone on the last" = "Snygg: fulla rader, inget ensamt ord på den sista"
"Set hug contents" = "Ställ in Omslut innehåll"
"Set fill container" = "Ställ in Fyll behållaren"
"Double-click an edge of an auto layout frame" = "Dubbelklicka på en kant av en auto layout-frame"
"{name}, disconnected from its live session" = "{name}, frånkopplad från sin livesession"
"⌥ double-click an edge of an auto layout child" = "⌥ dubbelklicka på en kant av ett underordnat objekt i auto layout"
"Alt+double-click an edge of an auto layout child" = "Alt+dubbelklicka på en kant av ett underordnat objekt i auto layout"
"Default" = "Standard"
"Default content" = "Standardinnehåll"
"Default value (the top-left variant)" = "Standardvärde (varianten längst upp till vänster)"
@@ -494,6 +531,7 @@ name = "Svenska"
"Edit dev resource" = "Redigera utvecklarresurs"
"Edit link" = "Redigeringslänk"
"Edit link copied" = "Redigeringslänken har kopierats"
"Edit padding on all sides" = "Redigera padding på alla sidor"
"Edit style" = "Redigera stil"
"Edit variable" = "Redigera variabel"
"Edit with the builder" = "Redigera med byggaren"
@@ -531,6 +569,7 @@ name = "Svenska"
"Exclusion" = "Uteslutning"
"Exit focus view (Esc)" = "Lämna fokusvyn (Esc)"
"Expand" = "Expandera"
"Expand the interface" = "Expandera gränssnittet"
"Export" = "Exportera"
"Export all" = "Exportera alla"
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportera frames och lager som PNG, JPG, SVG eller PDF"
@@ -1073,6 +1112,7 @@ name = "Svenska"
"Pages: {pages}" = "Sidor: {pages}"
"Paint bucket" = "Färghink"
"Pan the canvas" = "Panorera arbetsytan"
"Show and move the placement crosshair (its tool chosen, nothing selected)" = "Visa och flytta placeringshårkorset (med dess verktyg valt och inget markerat)"
"Pan with nothing selected (Shift: farther)" = "Panorera när inget är markerat (Shift: längre)"
"Panels" = "Paneler"
"Paper" = "Papper"
@@ -1297,6 +1337,7 @@ name = "Svenska"
"See all templates" = "Se alla mallar"
"Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras"
"SHA-256: {sha}…" = "SHA-256: {sha}…"
"Same as file ({profile})" = "Samma som filen ({profile})"
"Same as layer" = "Samma som lagret"
"Saturation" = "Mättnad"
"Save" = "Spara"
@@ -1969,6 +2010,17 @@ name = "Svenska"
"⌘ click" = "⌘ klicka"
"⌘ scroll" = "⌘ rulla"
"⌥ drag" = "⌥ dra"
"Selection box: switch to pick select" = "Markeringsruta: växla till plockmarkering"
"Pick select: next or previous layer" = "Plockmarkering: nästa eller föregående lager"
"Pick select: into a layer, out to its parent" = "Plockmarkering: in i ett lager, ut till dess överordnade"
"Pick select: select the picked layer" = "Plockmarkering: markera det valda lagret"
"⇥ or ⌥ Space again" = "⇥ eller ⌥ Blanksteg igen"
"Tab or Ctrl+Space again" = "Tab eller Ctrl+Blanksteg igen"
"↵ / ⇧↵" = "↵ / ⇧↵"
"Enter / Shift+Enter" = "Enter / Shift+Enter"
"Space" = "Blanksteg"
"Nothing to pick: the page is empty" = "Inget att välja: sidan är tom"
"Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels" = "Plockmarkering: Tab och Shift+Tab flyttar mellan lager, Enter går in i ett, Shift+Enter ut, Blanksteg markerar, Esc avbryter"
[commands]
@@ -2005,6 +2057,7 @@ name = "Svenska"
"board.label" = "Kopplingsetikett"
"board.nextSticky" = "Nästa lapp"
"board.reattach" = "Koppla om koppling"
"board.reshape" = "Omforma koppling"
"board.setColor" = "Whiteboardfärg"
"board.setStyle" = "Whiteboardstil"
"board.shape" = "Form med text"
@@ -2047,6 +2100,7 @@ name = "Svenska"
"collab.openFile" = "Öppna onlinefil"
"collab.peers" = "Personer i den här filen"
"collab.publish" = "Publicera på server"
"collab.reconnect" = "Återanslut livesessionen"
"collab.recoverFile" = "Återställ onlinefil"
"collab.registerLinks" = "Öppna inbjudningslänkar i FigCraft"
"collab.removePerson" = "Ta bort från session"
@@ -2195,6 +2249,7 @@ name = "Svenska"
"image.deletePreset" = "Radera förinställning"
"image.place" = "Placera bild"
"image.presets" = "Bildförinställningar"
"image.replace" = "Ersätt bild"
"image.savePreset" = "Spara förinställning"
"inspect.assets" = "Resurser"
"inspect.code" = "Kod"
@@ -2212,6 +2267,7 @@ name = "Svenska"
"layers.collapseAll" = "Fäll ihop alla"
"layout.add" = "Lägg till auto layout"
"layout.remove" = "Ta bort auto layout"
"layout.removeAll" = "Ta bort all auto layout"
"layout.setTrack" = "Ange spår"
"layout.suggest" = "Föreslå layout"
"libraries.open" = "Öppna bibliotek"
@@ -2306,6 +2362,9 @@ name = "Svenska"
"pref.showDimensions" = "Visa dimensioner på objekt"
"pref.startOnHome" = "Börja på Start"
"pref.uiScale" = "Gränssnittsstorlek"
"pref.uiScaleDown" = "Mindre gränssnitt"
"pref.uiScaleReset" = "Standardstorlek för gränssnittet"
"pref.uiScaleUp" = "Större gränssnitt"
"pref.userName" = "Ditt namn"
"proto.action.add" = "Lägg till prototypåtgärd"
"proto.action.move" = "Ändra ordning på prototypåtgärd"
@@ -2353,6 +2412,7 @@ name = "Svenska"
"selection.applyColorStyle" = "Använd färgformatmall på markeringen"
"selection.bindColorVariable" = "Använd färgvariabel på markeringen"
"selection.colors" = "Markeringsfärger"
"selection.createColorStyle" = "Skapa färgstil från markeringen"
"selection.detachColor" = "Koppla bort markeringsfärg"
"selection.selectColor" = "Markera lager med den här färgen"
"selection.setPaint" = "Ändra markeringens färg"
@@ -2399,6 +2459,10 @@ name = "Svenska"
"text.caseUpper" = "Versaler"
"text.create" = "Text"
"text.decoration" = "Dekoration"
"text.direction" = "Textriktning"
"text.directionAuto" = "Textriktning: auto"
"text.directionLtr" = "Textriktning: vänster till höger"
"text.directionRtl" = "Textriktning: höger till vänster"
"text.edit.begin" = "Redigera text"
"text.edit.caret" = "Placera markören"
"text.edit.end" = "Sluta redigera text"
@@ -2423,6 +2487,7 @@ name = "Svenska"
"text.missingFonts" = "Saknade typsnitt"
"text.numberList" = "Numrerad lista"
"text.onPath" = "Text på bana"
"text.openLink" = "Öppna länk"
"text.paragraphIndent" = "Styckeindrag"
"text.paragraphSpacing" = "Styckeavstånd"
"text.pathOptions" = "Alternativ för textbana"
@@ -2442,9 +2507,11 @@ name = "Svenska"
"text.underline" = "Understrykning"
"text.weightDown" = "Minska teckenvikten"
"text.weightUp" = "Öka teckenvikten"
"text.wrapStyle" = "Radbrytningsstil"
"tool.arrow" = "Pil"
"tool.comment" = "Kommentar"
"tool.connector" = "Koppling"
"tool.crosshair" = "Flytta placeringshårkorset"
"tool.ellipse" = "Ellips"
"tool.eraser" = "Suddgummi"
"tool.frame" = "Frame"
@@ -2488,6 +2555,7 @@ name = "Svenska"
"variable.deleteCollection" = "Radera samling"
"variable.duplicate" = "Duplicera variabel"
"variable.export" = "Exportera variabler"
"variable.extendCollection" = "Utöka samlingen"
"variable.import" = "Importera variabler"
"variable.list" = "Lista variabler"
"variable.moveMode" = "Flytta läge"
@@ -2561,6 +2629,7 @@ name = "Svenska"
"view.layoutGrids" = "Layoutrutnät"
"view.leftSidebar" = "Visa/dölj vänster sidofält"
"view.maskOutlines" = "Konturlinjer för masker"
"view.minimize" = "Minimera gränssnittet"
"view.multiplayerCursors" = "Samarbetsmarkörer"
"view.nextFrame" = "Zooma till nästa frame"
"view.nextPage" = "Nästa sida"
-1
View File
@@ -1575,7 +1575,6 @@ status = "draft — needs native review"
"Texts" = "உரைகள்"
"Texture" = "அமைப்பு"
"The bars are hidden. {shortcut} shows them again" = "பட்டைகள் மறைக்கப்பட்டுள்ளன. {shortcut} அவற்றை மீண்டும் காட்டும்"
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "கேன்வாஸ் நிறங்களை sRGB இல் காட்டுகிறது; ஏற்றுமதி செய்த PNG, JPG, WebP கோப்புகள் Display P3 எனக் குறிக்கப்படும்."
"The destination document is unavailable. Nothing was imported." = "இலக்கு ஆவணம் கிடைக்கவில்லை. எதுவும் இறக்குமதி செய்யப்படவில்லை."
"The destination document was closed. Nothing was imported." = "இலக்கு ஆவணம் மூடப்பட்டது. எதுவும் இறக்குமதி செய்யப்படவில்லை."
"The destination page was removed. Nothing was imported." = "இலக்குப் பக்கம் அகற்றப்பட்டது. எதுவும் இறக்குமதி செய்யப்படவில்லை."
@@ -19,12 +19,19 @@ fn id(v: serde_json::Value) -> NodeId {
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let mut variants = Vec::new();
let mut icons = Vec::new();
for (k, name) in ["Size=Small, State=Default", "Size=Large, State=Default", "Size=Small, State=Hover"].iter().enumerate() {
let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap());
// A layer in every variant reads "Icon": an instance lists only the properties its variant uses.
let icon = id(s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 10, "height": 10, "parent": f})).unwrap());
icons.push(icon);
variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap()));
}
let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap());
s.execute("component.addProperty", &json!({"id": set, "name": "Icon", "type": "boolean", "default": true})).unwrap();
s.execute("component.addProperty", &json!({"id": set, "name": "Icon", "type": "boolean", "default": true, "layer": icons[0]})).unwrap();
for icon in &icons[1..] {
s.execute("component.bindProperty", &json!({"layer": icon, "property": "Icon"})).unwrap();
}
let inst = id(s.execute("instance.create", &json!({"component": set, "x": 400, "y": 0})).unwrap());
s.execute("select.set", &json!({"ids": [set]})).unwrap();
(Ui::new(s), set, inst)
@@ -123,3 +130,25 @@ fn escape_cancels_a_property_drag_and_keeps_the_selection() {
assert_eq!(undo_len(&u), undo);
assert_eq!(u.app.session.ds().unwrap().selection, [set], "the Escape only cancels the drag");
}
/// A long property name is cut to one line inside its label box instead of wrapping over the rows
/// above and below (#2022); the name itself stays whole.
#[test]
fn a_long_property_name_stays_on_one_line() {
let long = "Flag Default label with a long name";
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Chip"})).unwrap());
let t = id(s.execute("text.create", &json!({"text": "Label", "x": 4, "y": 4, "parent": f})).unwrap());
let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
s.execute("component.addProperty", &json!({"id": c, "name": long, "type": "text", "layer": t})).unwrap();
let inst = id(s.execute("instance.create", &json!({"component": c, "x": 400, "y": 0})).unwrap());
s.execute("select.set", &json!({"ids": [inst]})).unwrap();
let mut u = Ui::new(s);
let texts = crate::export_name_tests::painted(&mut u);
let panel = u.app.ui.right_panel_rect.expect("the right panel shows");
let labels: Vec<(String, egui::Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("Flag") && r.min.x >= panel.min.x).collect();
let [(text, rect)] = labels.as_slice() else { panic!("one property label: {labels:?}") };
assert!(text.ends_with('…'), "cut with an ellipsis, not {text:?}");
assert!(rect.height() <= crate::widgets::FIELD_H, "one line, not {} tall", rect.height());
assert_eq!(names(&mut u, inst), [long], "the name itself stays whole");
}
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::layers_drag_tests::Ui;
use crate::widgets::FIELD_H;
/// The text painted this frame, with each piece's box.
fn painted(u: &mut Ui) -> Vec<(String, Rect)> {
pub(crate) fn painted(u: &mut Ui) -> Vec<(String, Rect)> {
fn walk(shape: &Shape, out: &mut Vec<(String, Rect)>) {
match shape {
Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
+9 -5
View File
@@ -14,8 +14,8 @@ use egui::{Id, Sense, Ui, vec2};
use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType};
use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants};
use figcraft_engine::components::{
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, prop_list, slot_def, slot_edited, slots_of, variant_conflicts,
variant_options, variant_props, variant_set,
PROP_BINDING, default_variant, inside_instance, instance_prop_list, owning_component, owning_instance, prop_list, slot_def, slot_edited,
slots_of, variant_conflicts, variant_options, variant_props, variant_set,
};
use serde_json::{Value, json};
@@ -748,7 +748,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
}
}
// Boolean, text and instance-swap properties.
for (k, d) in prop_list(doc, *component) {
for (k, d) in instance_prop_list(doc, *component) {
let v = props.get(&k).cloned().unwrap_or_else(|| figcraft_engine::components::prop_default_value(&d));
row(ui, 32.0, |ui| {
prop_label(ui, &k, type_icon(&d));
@@ -968,8 +968,12 @@ fn comp_rows(ui: &mut Ui, found: &CompSearch, checked: &impl Fn(NodeId) -> bool)
fn prop_label(ui: &mut Ui, name: &str, icon: Icon) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover());
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover());
icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(r.min.x, r.center().y - 6.0), vec2(12.0, 12.0)), icon, t.icon_secondary);
let g = ui.painter().layout(name.to_string(), crate::theme::regular(11.0), t.text_secondary, r.width() - 18.0);
let shown = widgets::elide(ui, name, r.width() - 18.0);
let g = ui.painter().layout_no_wrap(shown.clone(), crate::theme::regular(11.0), t.text_secondary);
ui.painter().galley(egui::pos2(r.min.x + 18.0, r.center().y - g.size().y / 2.0), g, t.text_secondary);
if shown != name {
resp.on_hover_text(name);
}
}
+4 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -12,6 +12,8 @@ accounts).
| Feature | Status | Notes |
|---|---|---|
| 2026-11-11 | minor | M3, #2022: an instance lists only the properties its variant's layers use (its variant properties, slots, and the boolean, text and instance swap properties a layer of its current variant reads), as measured in Figma's web app: an instance of variant A lists `State` and `A label`, never `B label` or a property no layer reads. The set and its variants still list every definition, so unused ones can be deleted there. A long property name in the panel is cut to one line with an ellipsis, the full name on hover, instead of wrapping over the rows around it. Tests: `prop_order_tests::an_instance_lists_only_the_properties_its_variant_uses`, `component_props_reorder_tests::a_long_property_name_stays_on_one_line`, `prop_value_tests::a_set_keeps_each_named_text_definition_and_layers_read_their_own`. Grade unchanged. |
| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main |
| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components (each layer its own component in place, one undo step) and Create component set (each layer a component, wrapped in place in a set 20 px around: variants `Property 1=<layer name>` left to right, `prop=value` names kept, layers that all share a name give a set of that name with `Property 1=Default` variants; measured in Figma), one layer keeps the plain click (#2088) |
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
@@ -22,7 +24,7 @@ accounts).
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
| Properties: boolean, text, instance swap (preferred values), variant | done | listed in the order they were made, a rename keeping the place (#1536; the shared ones don't reorder by drag yet); every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet |
| Properties: boolean, text, instance swap (preferred values), variant | done | listed in the order they were made, a rename keeping the place (#1536; the shared ones don't reorder by drag yet); every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet; an instance lists the properties its variant's layers use, so a set with one text property per variant shows one text field per instance (#2022; measured in Figma's web app, 2026-11-11); long names are cut to one line, the full name on hover |
| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature; Make slot asks for the name first (a field under the frame's Slot row), and slots are renamed in the component's Properties list (#1537); no rename with only the slot's frame selected yet |
| Exposed nested instances | done | hidden ones hide their properties |
| Descriptions | done | |