mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 05:42:38 +00:00
Components: several layers selected, the component button offers Create multiple components (#2088)
With two or more layers selected, the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Headless UI tests cover both cases (three frames: menu order, names kept, all selected, one undo; one frame: plain click), and an engine test pins createMultiple's names, positions and single undo step. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
1 parent
3635c23070
commit
f5a52ea528
5 files changed
+166
-2
No files matched your search
@@ -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,35 @@ 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");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! With two or more layers selected the Design panel's component button opens a menu (Create
|
||||
//! component, Create multiple components); 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() < 60.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"], "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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -530,6 +530,19 @@ 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.
|
||||
// TODO: "Create component set" is the third entry, to add once its naming is measured.
|
||||
menu_popup(&bresp).width(220.0).show(|ui| {
|
||||
let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
|
||||
for (c, label) in [("component.create", tr("Create component")), ("component.createMultiple", &multiple)] {
|
||||
let key = crate::shortcuts::shortcut(c).unwrap_or("");
|
||||
if menu_icon_item(ui, None, None, label, key) {
|
||||
app.run(c, json!({}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if bresp.clicked() {
|
||||
app.run(cmd, json!({}));
|
||||
}
|
||||
|
||||
@@ -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 and Create multiple components (each layer its own component in place, one undo step), one layer keeps the plain click (#2088); Create component set is not in the menu yet |
|
||||
| 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 and Create multiple components, as Figma's does; one layer keeps the plain button. Create component set is left out until its naming is measured. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`. 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