diff --git a/contributors/people.toml b/contributors/people.toml index f7ee0420e..745bb84c3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -104,3 +104,7 @@ real_name = "Oleksii Sukholita" [people.helenkwok] real_name = "Helen Kwok" + +[people.denisg0mes] +real_name = "Denis Gomes" +display_name = "Denis Gomes" diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 01e8d3244..d9107ad63 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -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()) diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index bb65f8885..733c3ba8c 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -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,75 @@ 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 NodeKind::Instance { component: copy, .. } = node(&s, card).kind else { panic!("not an instance") }; + s.doc_mut().unwrap().libraries_mut().components.retain(|r| r.root != copy); + let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err(); + assert!(e.to_string().contains("no link to its library"), "an imported copy without its record: {e}"); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn library_styles_apply_and_import_with_pages() { let d = dir("styles"); diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 50ce61500..e0b2e02f4 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -186,28 +186,42 @@ pub fn specs() -> Vec { 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); + } + if doc.is_remote(component) { + return Err(EngineError::Disabled( + cmd.into(), + "the main component is a library copy with no link to its library (it came with an imported file): enable that library to reach it" + .into(), + )); + } + 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 +297,43 @@ pub fn specs() -> Vec { ] } +fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result { + 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 { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0cfdd1ffa..637d0d4a2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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}; diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 59afad98d..4114b773e 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result { Ok(json!({"files": files})) } -pub(super) fn open(s: &mut Session, p: &Value) -> Result { +pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let variables = variables_param("file.open", p)?; if string(p, "path").is_none() && let Some(d) = string(p, "data") diff --git a/docs/components-parity.md b/docs/components-parity.md index 94a579758..37b5ed0b5 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -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//`, `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`. |