mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
M3: Go to main component opens the library file of a library component
On an instance of a library component the command refused with "open
that file to edit it". The help center describes it opening the file that
holds the main ("Edit main components"), so it now does: the linked
library file opens in a tab, or its open tab comes to the front, on the
main's page with the main selected and zoomed to.
The library keeps the file's layer ids, so the copy's id map
(ImportedComponent::ids) leads from the local copy back to the exact
layer: a nested instance reaches its own main and a variant that variant.
The new tab takes the canvas size of the tab it came from. A tab still at
the default 1200x800 is refitted to the whole page on its first frame,
which would replace the zoom to the main.
A main deleted from the library file since it was published leaves the
file open with a note. A moved library file, a team library on a server
and a path off this computer are refused before anything opens; the last
uses the same check that guards library reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8eb0b87646
commit
76af4e3f43
6 files changed
+144
-26
No files matched your search
@@ -1352,6 +1352,15 @@ pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> {
|
||||
doc.libraries.import_of(root).map(|r| r.library.as_str())
|
||||
}
|
||||
|
||||
pub fn library_source(doc: &Document, id: NodeId) -> Option<(&str, NodeId)> {
|
||||
if !doc.is_remote(id) {
|
||||
return None;
|
||||
}
|
||||
let record = doc.libraries.import_of(asset_root(doc, id)?)?;
|
||||
let key = record.ids.iter().find(|(_, local)| **local == id).map_or(record.key, |(key, _)| *key);
|
||||
Some((record.library.as_str(), key))
|
||||
}
|
||||
|
||||
/// The display name of library `id` in `doc` (its link's name, else the id).
|
||||
pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str {
|
||||
doc.libraries.link(id).map_or(id, |l| l.name.as_str())
|
||||
|
||||
@@ -189,8 +189,6 @@ fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
@@ -399,6 +397,70 @@ fn add_place_and_update_keep_overrides() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
fn go_to_main(s: &mut Session, id: NodeId) -> Value {
|
||||
s.active = 0;
|
||||
ex(s, "component.goToMain", json!({"id": id}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_to_a_library_main_opens_the_library_file() {
|
||||
let d = dir("go-to-main");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
let k = kit(&mut lib);
|
||||
let mut chips = Vec::new();
|
||||
for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() {
|
||||
let f = idv(&ex(&mut lib, "frame.create", json!({"x": 400.0 + i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"})));
|
||||
ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}}));
|
||||
chips.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
ex(&mut lib, "component.combineVariants", json!({"ids": chips}));
|
||||
let kit_page = lib.doc().unwrap().page_of(k.card).unwrap();
|
||||
ex(&mut lib, "page.add", json!({"name": "Notes"}));
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let card = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0})));
|
||||
let nested_button = node(&s, card).children[0];
|
||||
let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 200})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}}));
|
||||
s.ds_mut().unwrap().view.vw = 900.0;
|
||||
s.ds_mut().unwrap().view.vh = 500.0;
|
||||
let using = s.doc().unwrap().clone();
|
||||
|
||||
let r = go_to_main(&mut s, card);
|
||||
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(k.card), Some(1)), "{r}");
|
||||
let ds = s.ds().unwrap();
|
||||
assert_eq!(ds.path.as_deref(), Some(lib_path.as_str()));
|
||||
assert_eq!((ds.selection.clone(), ds.page), (vec![k.card], kit_page));
|
||||
assert_eq!((ds.view.vw, ds.view.vh), (900.0, 500.0), "the canvas it opens on is the one shown, so the fit stands");
|
||||
let shown = ds.doc.world_bounds(k.card).unwrap().center();
|
||||
assert_eq!((ds.view.cx, ds.view.cy), (shown.x, shown.y));
|
||||
|
||||
for (layer, main) in [(nested_button, k.button), (chip, chips[1])] {
|
||||
let r = go_to_main(&mut s, layer);
|
||||
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(main), Some(1)), "{r}");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![main]);
|
||||
}
|
||||
assert_eq!(s.docs.len(), 2, "the open library file comes to the front");
|
||||
assert_eq!(s.docs[0].doc.as_ref(), &using, "the file going there is unchanged");
|
||||
|
||||
s.active = 1;
|
||||
ex(&mut s, "edit.delete", json!({"ids": [k.card]}));
|
||||
let r = go_to_main(&mut s, card);
|
||||
assert!(r["id"].is_null() && r["index"] == 1, "{r}");
|
||||
assert!(s.message.take().is_some_and(|m| m.contains("isn't in this file any more")));
|
||||
|
||||
ex(&mut s, "file.close", json!({"index": 1, "force": true}));
|
||||
std::fs::rename(&lib_path, d.join("moved.figcraft")).unwrap();
|
||||
s.active = 0;
|
||||
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
|
||||
assert!(e.to_string().contains("isn't at"), "{e}");
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_styles_apply_and_import_with_pages() {
|
||||
let d = dir("styles");
|
||||
|
||||
@@ -186,28 +186,35 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| {
|
||||
let cmd = "component.goToMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
let doc = s.doc()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
|
||||
return Err(bad(cmd, "not an instance"));
|
||||
};
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
spec!(
|
||||
"component.goToMain",
|
||||
"Go to main component",
|
||||
"{id?} selects the instance's main component; a library component's main is in its library file, which opens in a tab (or comes to the front) with the main selected. Returns {id} (the main in the file now shown), and `index` (the tab) when it went to a library file",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "component.goToMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
let doc = s.doc()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
|
||||
return Err(bad(cmd, "not an instance"));
|
||||
};
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
}
|
||||
if let Some((library, key)) = crate::library::library_source(doc, component) {
|
||||
let name = crate::library::library_name(doc, library).to_string();
|
||||
let library = library.to_string();
|
||||
return go_to_library_main(s, &library, &name, key);
|
||||
}
|
||||
super::edit::select_and_page(s, vec![component])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(component) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": component}))
|
||||
}
|
||||
if let Some(lib) = crate::library::library_of(doc, component) {
|
||||
let name = crate::library::library_name(doc, lib);
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("the main component is in the library \"{name}\": open that file to edit it")));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![component])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(component) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": component}))
|
||||
}),
|
||||
),
|
||||
spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes),
|
||||
spec!(
|
||||
"component.restoreMain",
|
||||
@@ -283,6 +290,43 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result<Value> {
|
||||
let cmd = "component.goToMain";
|
||||
let refuse = |why: String| EngineError::Disabled(cmd.into(), why);
|
||||
let path = s
|
||||
.doc()?
|
||||
.libraries
|
||||
.link(library)
|
||||
.map(|l| l.path.clone())
|
||||
.ok_or_else(|| refuse(format!("the library \"{name}\" was removed from this file")))?;
|
||||
if crate::library_server::is_server_link(&path) {
|
||||
return Err(refuse(format!("the main component is in the team library \"{name}\": open its file from the server to edit it")));
|
||||
}
|
||||
if !craft_docpath::is_local(&path) {
|
||||
return Err(refuse(format!("{path}: a library is opened only from this computer")));
|
||||
}
|
||||
if !crate::file_access::exists(&path) {
|
||||
return Err(refuse(format!("the library file of \"{name}\" isn't at {path} any more")));
|
||||
}
|
||||
let viewport = s.ds().map(|d| (d.view.vw, d.view.vh))?;
|
||||
let opened = super::file::open(s, &json!({"path": path}))?;
|
||||
let ds = s.ds_mut()?;
|
||||
(ds.view.vw, ds.view.vh) = viewport;
|
||||
let doc = &ds.doc;
|
||||
let same_library = doc.published.as_ref().is_some_and(|p| p.manifest.id == library);
|
||||
let main = doc.node(key).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }));
|
||||
if !same_library || !main || doc.is_remote(key) {
|
||||
s.message = Some(format!("{name}: the main component isn't in this file any more"));
|
||||
return Ok(json!({"id": null, "index": opened["index"]}));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![key])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(key) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": key, "index": opened["index"]}))
|
||||
}
|
||||
|
||||
/// Place an instance of `component` centred on page point (`x`, `y`), in the frame or auto layout
|
||||
/// slot under it (`instance.place`).
|
||||
pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -37,7 +37,8 @@ pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variable
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
use open::append_pages;
|
||||
use open::{import, open, open_recent, revert};
|
||||
pub(crate) use open::open;
|
||||
use open::{import, open_recent, revert};
|
||||
pub(crate) use save::save_ends_preview;
|
||||
pub use save::{FileSnapshot, file_bytes, thumbnail};
|
||||
use save::{color_profile, mark_saved, save, save_as};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"files": files}))
|
||||
}
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let variables = variables_param("file.open", p)?;
|
||||
if string(p, "path").is_none()
|
||||
&& let Some(d) = string(p, "data")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (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-06 · **Change:** minor (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
|
||||
@@ -50,6 +50,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|---|---|---|
|
||||
| Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files |
|
||||
| Enable a library in a file; read-only linked copies | done | |
|
||||
| Go to main component from an instance of a library component | done | opens the library file in a tab, or brings it to the front, on the page holding the main, selected and zoomed to; a variant goes to that variant. Team libraries on a server are refused with a note; no Return to instance yet |
|
||||
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
|
||||
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
|
||||
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart |
|
||||
@@ -78,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
|
||||
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
|
||||
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
|
||||
|
||||
Reference in new issue
Block a user