mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 13:52:34 +00:00
Merge pull request #2095 from TrevorBrown-dev/feat/create-multiple-components-dropdown-2088
Components: several layers selected, the component button offers Create multiple components (#2088)
This commit is contained in:
29 files changed
+550
-9
No files matched your search
@@ -24,8 +24,8 @@ use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, res
|
||||
pub(crate) use instance::{swap_at, swap_instance};
|
||||
use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props};
|
||||
pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props};
|
||||
use variants::{add_variant, combine};
|
||||
pub use variants::{can_add_variant, can_combine_variants};
|
||||
use variants::{add_variant, combine, create_set};
|
||||
pub use variants::{can_add_variant, can_combine_variants, can_create_set};
|
||||
|
||||
/// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and
|
||||
/// sets (those `ids` are or are in), put on or taken off.
|
||||
@@ -72,6 +72,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.set_selection(out.iter().filter_map(id_of).collect());
|
||||
Ok(json!({"ids": out}))
|
||||
}),
|
||||
spec!(
|
||||
"component.createSet",
|
||||
"Create component set",
|
||||
"{ids? (layers, not components, sets or instances)} (each becomes a component, wrapped in place in a component set: variants \"Property 1=<name>\" left to right, 20 px around)",
|
||||
true,
|
||||
create_set
|
||||
),
|
||||
spec!(
|
||||
"component.combineVariants",
|
||||
"Combine as variants",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance,
|
||||
req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
|
||||
BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, create, id_of, inside_instance, json,
|
||||
owning_instance, req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
|
||||
};
|
||||
|
||||
/// Wrap components in a new component set (named after the first) placed around them.
|
||||
@@ -94,6 +94,75 @@ pub(super) fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": set}))
|
||||
}
|
||||
|
||||
/// Why `ids` (distinct) can't each become a component of a new set (`component.createSet`), or
|
||||
/// `None` when they can: layers inside an instance, library copies, instances, components and
|
||||
/// component sets, and a layer inside another one of `ids`.
|
||||
fn create_set_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
|
||||
if ids.iter().any(|i| inside_instance(doc, *i)) {
|
||||
Some("layers inside an instance can't become components")
|
||||
} else if ids.iter().any(|i| doc.is_remote(*i)) {
|
||||
Some(crate::library::READ_ONLY)
|
||||
} else if ids.iter().any(|i| {
|
||||
doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
|
||||
}) {
|
||||
Some("select layers that aren't components, component sets or instances")
|
||||
} else if roots(doc, ids).len() != ids.len() {
|
||||
Some("a layer can't be in a set with one inside it")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether "Create component set" is on offer for `ids`: at least one layer that
|
||||
/// [`create_set_refusal`] lets through.
|
||||
pub fn can_create_set(doc: &Document, ids: &[NodeId]) -> bool {
|
||||
let distinct: Vec<NodeId> = ids.iter().copied().collect::<BTreeSet<_>>().into_iter().collect();
|
||||
!distinct.is_empty() && distinct.iter().all(|i| doc.contains(*i)) && create_set_refusal(doc, &distinct).is_none()
|
||||
}
|
||||
|
||||
/// `component.createSet`: each target becomes a component (as `component.createMultiple` makes
|
||||
/// them) and all are wrapped in place in a component set, 20 px around, named as Figma names them:
|
||||
/// variants "Property 1=<layer name>" in left-to-right order (a name holding `=` is kept); layers
|
||||
/// that all share a name give a set of that name with "Property 1=Default" variants; otherwise the
|
||||
/// set is "Component N".
|
||||
pub(super) fn create_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.createSet";
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
|
||||
let doc = s.doc()?;
|
||||
if let Some(why) = create_set_refusal(doc, &ids) {
|
||||
return Err(EngineError::Disabled(cmd.into(), why.into()));
|
||||
}
|
||||
let mut made = Vec::new();
|
||||
for i in roots(doc, &ids) {
|
||||
let r = create(s, &json!({"ids": [i]}))?;
|
||||
made.push(id_of(&r["id"]).ok_or_else(|| bad(cmd, "component creation failed"))?);
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
// Left to right by world x, ties by y: the variants' order, and the values' order.
|
||||
let mut by_x: Vec<(NodeId, f64, f64)> = made.iter().map(|i| doc.world_bounds(*i).map_or((*i, 0.0, 0.0), |b| (*i, b.x0, b.y0))).collect();
|
||||
by_x.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)));
|
||||
let names: Vec<String> = by_x.iter().map(|(i, ..)| doc.node(*i).map(|n| n.name.clone()).unwrap_or_default()).collect();
|
||||
let same = names.windows(2).all(|w| w.first() == w.get(1)) && names.first().is_some_and(|n| !n.is_empty() && !n.contains('='));
|
||||
let set_name = match names.first() {
|
||||
Some(n) if same => n.clone(),
|
||||
_ => doc.next_name("Component"),
|
||||
};
|
||||
for ((i, ..), name) in by_x.iter().zip(&names) {
|
||||
let node = doc.get_mut(*i)?;
|
||||
if same {
|
||||
node.name = "Property 1=Default".into();
|
||||
} else if !name.contains('=') {
|
||||
node.name = format!("Property 1={name}");
|
||||
}
|
||||
}
|
||||
let ordered = z_sorted(doc, &made);
|
||||
let set = wrap_in_set(cmd, doc, &ordered)?;
|
||||
doc.get_mut(set)?.name = set_name;
|
||||
s.set_selection(vec![set]);
|
||||
Ok(json!({"id": set}))
|
||||
}
|
||||
|
||||
/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom).
|
||||
pub(super) fn fit_set(doc: &mut Document, set: NodeId) {
|
||||
let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return };
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::automation::params::id_of;
|
||||
use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node};
|
||||
use figcraft_doc::*;
|
||||
|
||||
@@ -1000,3 +1001,245 @@ fn deleting_a_layer_inside_an_instance_hides_it_instead() {
|
||||
s.execute("edit.delete", &json!({"ids": [k.inst]})).unwrap();
|
||||
assert!(s.doc().unwrap().node(k.inst).is_none());
|
||||
}
|
||||
|
||||
/// Create multiple components (#2088): each selected layer becomes its own component in place,
|
||||
/// keeping its name and position; all stay selected and one undo reverts them all.
|
||||
#[test]
|
||||
fn create_multiple_components_keeps_names_and_places_and_is_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let mut frames = Vec::new();
|
||||
for (i, name) in ["Card", "Badge", "Tile"].into_iter().enumerate() {
|
||||
let x = 150.0 * i as f64;
|
||||
let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
|
||||
let f = id_of(&v["id"]).unwrap();
|
||||
s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
|
||||
frames.push(f);
|
||||
}
|
||||
let before = undo_len(&s);
|
||||
let out = s.execute("component.createMultiple", &json!({"ids": frames})).unwrap();
|
||||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||||
let made: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect();
|
||||
assert_eq!(made.len(), 3);
|
||||
assert_eq!(s.selection(), made, "all of them stay selected");
|
||||
let doc = s.doc().unwrap();
|
||||
for (i, (c, name)) in made.iter().zip(["Card", "Badge", "Tile"]).enumerate() {
|
||||
let n = doc.node(*c).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Component { .. }), "{name} is a component");
|
||||
assert_eq!(n.name, name);
|
||||
let b = doc.world_bounds(*c).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "{name} stays in place");
|
||||
}
|
||||
s.undo().unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
|
||||
}
|
||||
|
||||
/// Frames named `names` at x = 150 * i (mirrored when `reverse`), y 20, 100 x 60.
|
||||
fn named_frames(s: &mut Session, names: &[&str], reverse: bool) -> Vec<NodeId> {
|
||||
let n = names.len();
|
||||
names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let x = 150.0 * if reverse { n - 1 - i } else { i } as f64;
|
||||
let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
|
||||
let f = id_of(&v["id"]).unwrap();
|
||||
s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
|
||||
f
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The set's name and its children's names in layer order.
|
||||
fn set_names(s: &Session, set: NodeId) -> (String, Vec<String>) {
|
||||
let doc = s.doc().unwrap();
|
||||
let n = doc.node(set).unwrap();
|
||||
(n.name.clone(), n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect())
|
||||
}
|
||||
|
||||
/// The set's variant names ordered by world x.
|
||||
fn names_by_x(s: &Session, set: NodeId) -> Vec<String> {
|
||||
let doc = s.doc().unwrap();
|
||||
let mut v: Vec<(f64, String)> =
|
||||
doc.node(set).unwrap().children.iter().map(|c| (doc.world_bounds(*c).unwrap().x0, doc.node(*c).unwrap().name.clone())).collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
v.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
/// Create component set (#2088, measured in Figma): variants "Property 1=<name>" in left-to-right
|
||||
/// order, the set "Component 1", wrapped in place 20 px around, selected, one undo step.
|
||||
#[test]
|
||||
fn create_set_names_variants_by_layer_name_and_wraps_in_place() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Primary", "Secondary", "Ghost"], false);
|
||||
let before = undo_len(&s);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
assert_eq!(s.selection(), vec![set], "the set is selected");
|
||||
assert_eq!(set_names(&s, set).0, "Component 1");
|
||||
assert_eq!(names_by_x(&s, set), ["Property 1=Primary", "Property 1=Secondary", "Property 1=Ghost"]);
|
||||
let doc = s.doc().unwrap();
|
||||
for (i, c) in doc.node(set).unwrap().children.iter().enumerate() {
|
||||
assert!(matches!(doc.node(*c).unwrap().kind, NodeKind::Component { .. }));
|
||||
let b = doc.world_bounds(*c).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "frames stay put");
|
||||
}
|
||||
let sb = doc.world_bounds(set).unwrap();
|
||||
assert_eq!((sb.x0, sb.y0, sb.x1, sb.y1), (-20.0, 0.0, 420.0, 100.0), "20 px on every side");
|
||||
s.undo().unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(set).is_none());
|
||||
assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_orders_left_to_right_whatever_the_selection_order() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B", "C"], true);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
assert_eq!(names_by_x(&s, set), ["Property 1=C", "Property 1=B", "Property 1=A"]);
|
||||
let (_, kids) = set_names(&s, set);
|
||||
assert_eq!(kids.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_keeps_names_already_in_prop_value_form() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Size=Small", "Size=Large", "Size=Medium"], false);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
assert_eq!(set_names(&s, set).0, "Component 1");
|
||||
assert_eq!(names_by_x(&s, set), ["Size=Small", "Size=Large", "Size=Medium"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_with_one_shared_name_names_the_set_and_makes_default_variants() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Card", "Card", "Card"], false);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
let (name, kids) = set_names(&s, set);
|
||||
assert_eq!(name, "Card");
|
||||
assert_eq!(kids, ["Property 1=Default", "Property 1=Default", "Property 1=Default"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_refuses_instances_and_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B"], false);
|
||||
let c = s.execute("component.create", &json!({"ids": [frames[0]]})).unwrap();
|
||||
let comp = id_of(&c["id"]).unwrap();
|
||||
let inst = s.execute("instance.create", &json!({"component": comp})).unwrap();
|
||||
let inst = id_of(&inst["id"]).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let e = s.execute("component.createSet", &json!({"ids": [inst, frames[1]]})).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert!(matches!(s.doc().unwrap().node(frames[1]).unwrap().kind, NodeKind::Frame));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_refuses_a_target_inside_another_target_and_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Outer", "Inner"], false);
|
||||
s.execute("node.reparent", &json!({"ids": [frames[1]], "parent": frames[0]})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let e = s.execute("component.createSet", &json!({"ids": [frames[0], frames[1]]})).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
|
||||
assert_eq!(undo_len(&s), before);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(matches!(doc.node(frames[0]).unwrap().kind, NodeKind::Frame));
|
||||
assert!(matches!(doc.node(frames[1]).unwrap().kind, NodeKind::Frame));
|
||||
}
|
||||
|
||||
/// Every node's id, parent, name and kind, to compare a document before and after a refused command.
|
||||
fn snapshot(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().nodes().map(|n| format!("{:?}|{:?}|{}|{:?}", n.id, n.parent, n.name, n.kind)).collect()
|
||||
}
|
||||
|
||||
/// A refused `component.createSet`: `Disabled`, no undo step, the document and the selection as they were.
|
||||
fn assert_create_set_refused(s: &mut Session, ids: &[NodeId]) {
|
||||
let (before, undo, selection) = (snapshot(s), undo_len(s), s.selection());
|
||||
let e = s.execute("component.createSet", &json!({"ids": ids})).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
|
||||
assert_eq!(undo_len(s), undo, "no undo step");
|
||||
assert_eq!(snapshot(s), before, "the document is unchanged");
|
||||
assert_eq!(s.selection(), selection, "the selection is unchanged");
|
||||
}
|
||||
|
||||
/// Nothing to wrap is refused before anything is created.
|
||||
#[test]
|
||||
fn create_set_refuses_no_layers_and_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
named_frames(&mut s, &["A"], false);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
assert_create_set_refused(&mut s, &[]);
|
||||
}
|
||||
|
||||
/// One layer is allowed: a set of one variant, as many layers would make.
|
||||
#[test]
|
||||
fn create_set_with_one_layer_makes_a_one_variant_set() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Solo"], false);
|
||||
let before = undo_len(&s);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(set).unwrap().children.len(), 1);
|
||||
assert_eq!(names_by_x(&s, set), ["Property 1=Default"], "a lone layer shares its name with itself");
|
||||
assert!(matches!(doc.node(doc.node(set).unwrap().children[0]).unwrap().kind, NodeKind::Component { .. }));
|
||||
s.undo().unwrap();
|
||||
assert!(matches!(s.doc().unwrap().node(frames[0]).unwrap().kind, NodeKind::Frame));
|
||||
}
|
||||
|
||||
/// Each refusal branch of `create_set`, with `can_create_set` agreeing with it.
|
||||
#[test]
|
||||
fn create_set_refuses_every_ineligible_selection_and_leaves_the_document_alone() {
|
||||
use crate::cmd::component::can_create_set;
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B", "C"], false);
|
||||
let comp = id_of(&s.execute("component.create", &json!({"ids": [frames[0]]})).unwrap()["id"]).unwrap();
|
||||
let inst = id_of(&s.execute("instance.create", &json!({"component": comp})).unwrap()["id"]).unwrap();
|
||||
let set_frames = named_frames(&mut s, &["V1", "V2"], false);
|
||||
let set = id_of(&s.execute("component.createSet", &json!({"ids": set_frames})).unwrap()["id"]).unwrap();
|
||||
// A layer inside the instance (its copy of the card's content).
|
||||
let in_inst = s.doc().unwrap().node(inst).unwrap().children.first().copied();
|
||||
// A library copy.
|
||||
let remote = {
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let store = doc.ensure_library_store();
|
||||
let remote = doc.alloc_id();
|
||||
doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
|
||||
remote
|
||||
};
|
||||
let ok = frames[1];
|
||||
assert!(can_create_set(s.doc().unwrap(), &[ok, frames[2]]));
|
||||
assert!(!can_create_set(s.doc().unwrap(), &[]));
|
||||
let mut cases = vec![("instance", inst), ("library copy", remote), ("component", comp), ("component set", set)];
|
||||
if let Some(i) = in_inst {
|
||||
cases.push(("inside an instance", i));
|
||||
}
|
||||
for (what, bad) in cases {
|
||||
assert!(!can_create_set(s.doc().unwrap(), &[ok, bad]), "{what}: not offered");
|
||||
assert_create_set_refused(&mut s, &[ok, bad]);
|
||||
assert_create_set_refused(&mut s, &[bad]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that fails leaves no trace: the checks before the creation loop catch every
|
||||
/// refusal the loop's own commands could hit, and `Session::execute` restores the document, the
|
||||
/// selection and the undo stack on any error, so no half-made components can survive.
|
||||
#[test]
|
||||
fn a_failing_create_set_leaves_no_components_behind() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B"], false);
|
||||
let (before, undo, selection) = (snapshot(&s), undo_len(&s), s.selection());
|
||||
let e = s.execute("component.createSet", &json!({"ids": [frames[0], NodeId(u64::MAX)]}));
|
||||
assert!(e.is_err());
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
assert_eq!(snapshot(&s), before);
|
||||
assert_eq!(s.selection(), selection);
|
||||
}
|
||||
@@ -2009,6 +2009,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "جمع كتنويعات"
|
||||
"component.create" = "إنشاء مكوّن"
|
||||
"component.createMultiple" = "إنشاء مكونات متعددة"
|
||||
"component.createSet" = "إنشاء مجموعة مكونات"
|
||||
"component.deleteProperty" = "حذف خاصية المكوّن"
|
||||
"component.detach" = "فصل النسخة المثيلة"
|
||||
"component.editProperty" = "تحرير خاصية المكوّن"
|
||||
|
||||
@@ -2038,6 +2038,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Kombinuj kao varijante"
|
||||
"component.create" = "Kreiraj komponentu"
|
||||
"component.createMultiple" = "Kreiraj više komponenti"
|
||||
"component.createSet" = "Kreiraj skup komponenti"
|
||||
"component.deleteProperty" = "Izbriši svojstvo komponente"
|
||||
"component.detach" = "Odvoji instancu"
|
||||
"component.editProperty" = "Uredi svojstvo komponente"
|
||||
|
||||
@@ -1913,6 +1913,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Sloučit jako varianty"
|
||||
"component.create" = "Vytvořit komponentu"
|
||||
"component.createMultiple" = "Vytvořit více komponent"
|
||||
"component.createSet" = "Vytvořit sadu komponent"
|
||||
"component.deleteProperty" = "Smazat vlastnost komponenty"
|
||||
"component.detach" = "Odpojit instanci"
|
||||
"component.editProperty" = "Upravit vlastnost komponenty"
|
||||
|
||||
@@ -2079,6 +2079,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Als Varianten kombinieren"
|
||||
"component.create" = "Komponente erstellen"
|
||||
"component.createMultiple" = "Mehrere Komponenten erstellen"
|
||||
"component.createSet" = "Komponentensatz erstellen"
|
||||
"component.deleteProperty" = "Komponenteneigenschaft löschen"
|
||||
"component.detach" = "Instanz lösen"
|
||||
"component.editProperty" = "Komponenteneigenschaft bearbeiten"
|
||||
|
||||
@@ -2002,6 +2002,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combinar como variantes"
|
||||
"component.create" = "Crear componente"
|
||||
"component.createMultiple" = "Crear varios componentes"
|
||||
"component.createSet" = "Crear conjunto de componentes"
|
||||
"component.deleteProperty" = "Eliminar propiedad de componente"
|
||||
"component.detach" = "Separar instancia"
|
||||
"component.editProperty" = "Editar propiedad de componente"
|
||||
|
||||
@@ -2086,6 +2086,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Yhdistä varianteiksi"
|
||||
"component.create" = "Luo komponentti"
|
||||
"component.createMultiple" = "Luo useita komponentteja"
|
||||
"component.createSet" = "Luo komponenttijoukko"
|
||||
"component.deleteProperty" = "Poista komponentin ominaisuus"
|
||||
"component.detach" = "Irrota ilmentymä"
|
||||
"component.editProperty" = "Muokkaa komponentin ominaisuutta"
|
||||
|
||||
@@ -1958,6 +1958,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combiner en variantes"
|
||||
"component.create" = "Créer un composant"
|
||||
"component.createMultiple" = "Créer plusieurs composants"
|
||||
"component.createSet" = "Créer un ensemble de composants"
|
||||
"component.deleteProperty" = "Supprimer la propriété de composant"
|
||||
"component.detach" = "Détacher l’instance"
|
||||
"component.editProperty" = "Modifier la propriété de composant"
|
||||
|
||||
@@ -1917,6 +1917,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Gabungkan sebagai variant"
|
||||
"component.create" = "Buat component"
|
||||
"component.createMultiple" = "Buat beberapa component"
|
||||
"component.createSet" = "Buat set component"
|
||||
"component.deleteProperty" = "Hapus properti component"
|
||||
"component.detach" = "Lepaskan instance"
|
||||
"component.editProperty" = "Edit properti component"
|
||||
|
||||
@@ -1861,6 +1861,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combina come varianti"
|
||||
"component.create" = "Crea componente"
|
||||
"component.createMultiple" = "Crea più componenti"
|
||||
"component.createSet" = "Crea set di componenti"
|
||||
"component.deleteProperty" = "Elimina proprietà del componente"
|
||||
"component.detach" = "Scollega istanza"
|
||||
"component.editProperty" = "Modifica proprietà del componente"
|
||||
|
||||
@@ -1938,6 +1938,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "バリアントとして結合"
|
||||
"component.create" = "コンポーネントを作成"
|
||||
"component.createMultiple" = "複数のコンポーネントを作成"
|
||||
"component.createSet" = "コンポーネントセットを作成"
|
||||
"component.deleteProperty" = "コンポーネントプロパティを削除"
|
||||
"component.detach" = "インスタンスを切り離す"
|
||||
"component.editProperty" = "コンポーネントプロパティを編集"
|
||||
|
||||
@@ -2013,6 +2013,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"component.combineVariants" = "ვარიანტებად გაერთიანება"
|
||||
"component.create" = "კომპონენტის შექმნა"
|
||||
"component.createMultiple" = "რამდენიმე კომპონენტის შექმნა"
|
||||
"component.createSet" = "კომპონენტების ნაკრების შექმნა"
|
||||
"component.deleteProperty" = "კომპონენტის თვისების წაშლა"
|
||||
"component.detach" = "ინსტანსის გამოყოფა"
|
||||
"component.editProperty" = "კომპონენტის თვისების რედაქტირება"
|
||||
|
||||
@@ -1978,6 +1978,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "배리언트로 결합"
|
||||
"component.create" = "컴포넌트 만들기"
|
||||
"component.createMultiple" = "여러 컴포넌트 만들기"
|
||||
"component.createSet" = "컴포넌트 세트 만들기"
|
||||
"component.deleteProperty" = "컴포넌트 속성 삭제"
|
||||
"component.detach" = "인스턴스 분리"
|
||||
"component.editProperty" = "컴포넌트 속성 편집"
|
||||
|
||||
@@ -1956,6 +1956,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combineren als varianten"
|
||||
"component.create" = "Component maken"
|
||||
"component.createMultiple" = "Meerdere componenten maken"
|
||||
"component.createSet" = "Componentenset maken"
|
||||
"component.deleteProperty" = "Componenteigenschap verwijderen"
|
||||
"component.detach" = "Instance loskoppelen"
|
||||
"component.editProperty" = "Componenteigenschap bewerken"
|
||||
|
||||
@@ -1970,6 +1970,7 @@ name = "Polski"
|
||||
"component.combineVariants" = "Połącz jako warianty"
|
||||
"component.create" = "Utwórz komponent"
|
||||
"component.createMultiple" = "Utwórz wiele komponentów"
|
||||
"component.createSet" = "Utwórz zestaw komponentów"
|
||||
"component.deleteProperty" = "Usuń właściwość komponentu"
|
||||
"component.detach" = "Odłącz instancję"
|
||||
"component.editProperty" = "Edytuj właściwość komponentu"
|
||||
|
||||
@@ -1975,6 +1975,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combinar como variantes"
|
||||
"component.create" = "Criar componente"
|
||||
"component.createMultiple" = "Criar vários componentes"
|
||||
"component.createSet" = "Criar conjunto de componentes"
|
||||
"component.deleteProperty" = "Excluir propriedade de componente"
|
||||
"component.detach" = "Desvincular instância"
|
||||
"component.editProperty" = "Editar propriedade de componente"
|
||||
|
||||
@@ -1970,6 +1970,7 @@ name = "Русский"
|
||||
"component.combineVariants" = "Объединить в варианты"
|
||||
"component.create" = "Создать компонент"
|
||||
"component.createMultiple" = "Создать несколько компонентов"
|
||||
"component.createSet" = "Создать набор компонентов"
|
||||
"component.deleteProperty" = "Удалить свойство компонента"
|
||||
"component.detach" = "Отсоединить экземпляр"
|
||||
"component.editProperty" = "Изменить свойство компонента"
|
||||
|
||||
@@ -2077,6 +2077,7 @@ name = "Svenska"
|
||||
"component.combineVariants" = "Kombinera som varianter"
|
||||
"component.create" = "Skapa komponent"
|
||||
"component.createMultiple" = "Skapa flera komponenter"
|
||||
"component.createSet" = "Skapa komponentuppsättning"
|
||||
"component.deleteProperty" = "Radera komponentegenskap"
|
||||
"component.detach" = "Koppla bort instans"
|
||||
"component.editProperty" = "Redigera komponentegenskap"
|
||||
|
||||
@@ -1934,6 +1934,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "รวมเป็นแวเรียนต์"
|
||||
"component.create" = "สร้างคอมโพเนนต์"
|
||||
"component.createMultiple" = "สร้างหลายคอมโพเนนต์"
|
||||
"component.createSet" = "สร้างชุดคอมโพเนนต์"
|
||||
"component.deleteProperty" = "ลบพร็อพเพอร์ตีของคอมโพเนนต์"
|
||||
"component.detach" = "ถอดอินสแตนซ์"
|
||||
"component.editProperty" = "แก้ไขพร็อพเพอร์ตีของคอมโพเนนต์"
|
||||
|
||||
@@ -1918,6 +1918,7 @@ name = "Türkçe"
|
||||
"component.combineVariants" = "Varyant olarak birleştir"
|
||||
"component.create" = "Bileşen oluştur"
|
||||
"component.createMultiple" = "Birden çok bileşen oluştur"
|
||||
"component.createSet" = "Bileşen kümesi oluştur"
|
||||
"component.deleteProperty" = "Bileşen özelliğini sil"
|
||||
"component.detach" = "Örneği ayır"
|
||||
"component.editProperty" = "Bileşen özelliğini düzenle"
|
||||
|
||||
@@ -1862,6 +1862,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Kết hợp thành các biến thể"
|
||||
"component.create" = "Tạo thành phần"
|
||||
"component.createMultiple" = "Tạo nhiều thành phần"
|
||||
"component.createSet" = "Tạo bộ thành phần"
|
||||
"component.deleteProperty" = "Xóa thuộc tính thành phần"
|
||||
"component.detach" = "Tách bản thể"
|
||||
"component.editProperty" = "Chỉnh sửa thuộc tính thành phần"
|
||||
|
||||
@@ -1871,6 +1871,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "合并为变体"
|
||||
"component.create" = "创建组件"
|
||||
"component.createMultiple" = "创建多个组件"
|
||||
"component.createSet" = "创建组件集"
|
||||
"component.deleteProperty" = "删除组件属性"
|
||||
"component.detach" = "分离实例"
|
||||
"component.editProperty" = "编辑组件属性"
|
||||
|
||||
@@ -2089,6 +2089,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "合併為變體"
|
||||
"component.create" = "建立元件"
|
||||
"component.createMultiple" = "建立多個元件"
|
||||
"component.createSet" = "建立元件集"
|
||||
"component.deleteProperty" = "刪除元件屬性"
|
||||
"component.detach" = "分離實例"
|
||||
"component.editProperty" = "編輯元件屬性"
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! With two or more layers selected the Design panel's component button opens a menu (Create
|
||||
//! component, Create multiple components, Create component set); one layer keeps the plain click (#2088): drawn headless.
|
||||
|
||||
use egui::accesskit::Node;
|
||||
use egui::{Event, PointerButton, Pos2, Rect, pos2};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
const NAMES: [&str; 3] = ["Card", "Badge", "Tile"];
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
serde_json::from_value(v["id"].clone()).unwrap()
|
||||
}
|
||||
|
||||
/// Frames named `NAMES[..n]`, all selected, headless.
|
||||
fn frames(n: usize) -> (Ui, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
let mut ids = Vec::new();
|
||||
for (i, name) in NAMES.into_iter().take(n).enumerate() {
|
||||
let f = id(s.execute("frame.create", &json!({"x": 150 * i, "y": 0, "width": 100, "height": 60})).unwrap());
|
||||
s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
|
||||
ids.push(f);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
(u, ids)
|
||||
}
|
||||
|
||||
fn nodes(u: &mut Ui) -> Vec<Node> {
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
fn bounds(n: &Node) -> Rect {
|
||||
let b = n.bounds().unwrap();
|
||||
Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, at: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(u: &mut Ui) {
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The header's component button (its tooltip may carry the shortcut).
|
||||
fn click_button(u: &mut Ui) {
|
||||
let all = nodes(u);
|
||||
let button = all.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Create component"))).expect("the Design panel's component button");
|
||||
click(u, bounds(button).center());
|
||||
settle(u);
|
||||
}
|
||||
|
||||
fn undo_len(u: &Ui) -> usize {
|
||||
u.app.session.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_layers_open_a_menu_and_create_multiple_components_in_one_undo_step() {
|
||||
let (mut u, ids) = frames(3);
|
||||
let before = undo_len(&u);
|
||||
click_button(&mut u);
|
||||
assert_eq!(undo_len(&u), before, "opening the menu changes nothing");
|
||||
let all = nodes(&mut u);
|
||||
// The menu's rows share an x with Create multiple components: exactly these two, in this order.
|
||||
let multiple = all.iter().find(|n| n.label() == Some("Create multiple components")).expect("the menu's second entry");
|
||||
let mb = bounds(multiple);
|
||||
let mut rows: Vec<(f32, String)> = all
|
||||
.iter()
|
||||
.filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 90.0)
|
||||
.map(|n| (bounds(n).min.y, n.label().unwrap_or("").to_owned()))
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let labels: Vec<&str> = rows.iter().map(|(_, l)| l.as_str()).collect();
|
||||
assert_eq!(labels, ["Create component", "Create multiple components", "Create component set"], "the menu's entries");
|
||||
click(&mut u, bounds(multiple).center());
|
||||
settle(&mut u);
|
||||
let sel = u.app.session.selection();
|
||||
assert_eq!(sel.len(), 3);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let names: Vec<&str> = sel.iter().map(|i| doc.node(*i).unwrap().name.as_str()).collect();
|
||||
assert_eq!(names, NAMES);
|
||||
assert!(sel.iter().all(|i| matches!(doc.node(*i).unwrap().kind, NodeKind::Component { .. })));
|
||||
assert_eq!(undo_len(&u), before + 1);
|
||||
u.app.session.undo().unwrap();
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts all three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_layer_keeps_the_plain_button() {
|
||||
let (mut u, ids) = frames(1);
|
||||
click_button(&mut u);
|
||||
let all = nodes(&mut u);
|
||||
assert!(all.iter().all(|n| n.label() != Some("Create multiple components")), "no menu opens");
|
||||
let sel = u.app.session.selection();
|
||||
assert_eq!(sel.len(), 1);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(matches!(doc.node(sel[0]).unwrap().kind, NodeKind::Component { .. }));
|
||||
assert_eq!(doc.node(sel[0]).unwrap().name, NAMES[0]);
|
||||
let _ = ids;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_third_entry_creates_one_component_set() {
|
||||
let (mut u, ids) = frames(3);
|
||||
let before = undo_len(&u);
|
||||
click_button(&mut u);
|
||||
let all = nodes(&mut u);
|
||||
let entry = all.iter().find(|n| n.label() == Some("Create component set")).expect("the menu's third entry");
|
||||
click(&mut u, bounds(entry).center());
|
||||
settle(&mut u);
|
||||
let sel = u.app.session.selection();
|
||||
assert_eq!(sel.len(), 1, "the set is selected");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let set = doc.node(sel[0]).unwrap();
|
||||
assert!(matches!(set.kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(set.children.len(), 3);
|
||||
assert_eq!(undo_len(&u), before + 1);
|
||||
u.app.session.undo().unwrap();
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts it all");
|
||||
}
|
||||
|
||||
/// The component button's width in the Design panel: wider with a dropdown chevron beside the icon.
|
||||
fn button_width(u: &mut Ui) -> f32 {
|
||||
let all = nodes(u);
|
||||
let button = all.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Create component"))).expect("the Design panel's component button");
|
||||
bounds(button).width()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dropdown_chevron_is_there_only_for_several_layers() {
|
||||
let (mut one, _) = frames(1);
|
||||
let (mut two, _) = frames(2);
|
||||
let (single, several) = (button_width(&mut one), button_width(&mut two));
|
||||
assert_eq!(single, 24.0, "one layer: the plain icon button");
|
||||
assert_eq!(several, 34.0, "several layers: room for the chevron");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_component_set_is_offered_only_where_it_works() {
|
||||
let (mut u, ids) = frames(3);
|
||||
// Make the first a component: the set can't take it, so the entry is left out.
|
||||
u.app.session.execute("component.create", &json!({"ids": [ids[0]]})).unwrap();
|
||||
u.app.session.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
u.frame(vec![]);
|
||||
click_button(&mut u);
|
||||
let all = nodes(&mut u);
|
||||
assert!(all.iter().any(|n| n.label() == Some("Create multiple components")), "the menu is open");
|
||||
assert!(all.iter().all(|n| n.label() != Some("Create component set")), "left out for a selection with a component");
|
||||
}
|
||||
@@ -88,6 +88,8 @@ mod context_menu_tests;
|
||||
#[cfg(test)]
|
||||
mod corner_individual_tests;
|
||||
#[cfg(test)]
|
||||
mod create_multiple_menu_tests;
|
||||
#[cfg(test)]
|
||||
mod crop_image_tests;
|
||||
#[cfg(test)]
|
||||
mod cursors_tests;
|
||||
|
||||
@@ -497,7 +497,10 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
// Layers in boolean groups, or one boolean group: the operation they use (checked in the menu).
|
||||
let current = figcraft_engine::booleans::shown_op(doc, sel);
|
||||
for (icon, label, cmd) in actions.into_iter().rev() {
|
||||
let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0));
|
||||
// With several layers the component button is a dropdown: a small chevron sits beside its icon.
|
||||
let dropdown = cmd == "component.create" && sel.len() > 1;
|
||||
let bw = if dropdown { 34.0 } else { 24.0 };
|
||||
let br = Rect::from_min_size(pos2(x - bw, r.center().y - 12.0), vec2(bw, 24.0));
|
||||
let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) };
|
||||
let bresp = ui.interact(br, ui.scope_id().with(("qa", cmd)), Sense::click()).tip(tip);
|
||||
if bresp.hovered() {
|
||||
@@ -508,7 +511,11 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
("bool.union", Some(op)) => crate::layers::op_icon(op),
|
||||
_ => icon,
|
||||
};
|
||||
icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon);
|
||||
let icon_rect = Rect::from_min_size(br.min, vec2(24.0, 24.0)).shrink(5.0);
|
||||
icons::paint(ui.painter(), icon_rect, icon, t.icon);
|
||||
if dropdown {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(br.max.x - 6.0, br.center().y), vec2(8.0, 8.0)), Icon::Chevron, t.icon_secondary);
|
||||
}
|
||||
if cmd == "bool.union" {
|
||||
menu_popup(&bresp).width(180.0).show(|ui| {
|
||||
for (op, c) in [
|
||||
@@ -533,10 +540,35 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
} else if cmd == "component.create" && sel.len() > 1 {
|
||||
// Several layers: the button opens a menu, as Figma's does (#2088). One layer keeps the plain click.
|
||||
let create = crate::i18n::command("component.create", "Create component");
|
||||
let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
|
||||
let set = crate::i18n::command("component.createSet", "Create component set");
|
||||
// Like the layers menu, leave out what the selection can't use.
|
||||
let can_set = figcraft_engine::cmd::component::can_create_set(doc, sel);
|
||||
let mut entries = vec![("component.create", &create), ("component.createMultiple", &multiple)];
|
||||
if can_set {
|
||||
entries.push(("component.createSet", &set));
|
||||
}
|
||||
// Wide enough for the longest translated label (the rows grow with it, the menu never clips).
|
||||
let widest = entries
|
||||
.iter()
|
||||
.map(|(_, l)| ui.painter().layout_no_wrap(l.to_string(), regular(11.5), Color32::PLACEHOLDER).size().x)
|
||||
.fold(0.0f32, f32::max);
|
||||
menu_popup(&bresp).width(menu::menu_row_width(widest, 0.0).ceil().max(160.0)).show(|ui| {
|
||||
for (c, label) in &entries {
|
||||
let key = crate::shortcuts::shortcut(c).unwrap_or("");
|
||||
if menu_item(ui, label, key) {
|
||||
app.run(c, json!({}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if bresp.clicked() {
|
||||
app.run(cmd, json!({}));
|
||||
}
|
||||
x -= 26.0;
|
||||
x -= bw + 2.0;
|
||||
}
|
||||
crate::component_panel::instance_section(app, ui, doc, sel);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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 (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,7 +12,7 @@ accounts).
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out (Delete on a layer inside an instance hides it instead of removing it, as the help center says it does); the instance itself resizes and its layers follow the main's constraints |
|
||||
@@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
|
||||
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
|
||||
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
|
||||
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
|
||||
|
||||
Reference in new issue
Block a user