mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M3: duplicating a main component makes an instance (#892)
Duplicate and Alt-drag copied a main component as a second main. Within its file the copy is now an instance of it (components::copy_in_file, shared by edit.duplicate and the Alt-drag copy), as Copy then Paste already was. A variant duplicated in its set stays a new variant.
This commit is contained in:
@@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
|
||||
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
|
||||
@@ -18,13 +18,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
|
||||
true,
|
||||
paste
|
||||
),
|
||||
spec!(
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
@@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
let idx = doc.index_in_parent(i).map(|x| x + 1);
|
||||
let w = n.width;
|
||||
let nid = doc.duplicate_into(i, parent, idx)?;
|
||||
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
|
||||
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
|
||||
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
|
||||
(None, Some(dy)) => (0.0, dy),
|
||||
@@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
// An instance keeps the name instances get by default.
|
||||
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
|
||||
@@ -292,6 +292,21 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
|
||||
/// has one main per component, so a main component's copy is an instance of it; a variant copied
|
||||
/// into its set stays a new variant, and every other layer is deep-copied.
|
||||
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
|
||||
let n = doc.get(id)?;
|
||||
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
|
||||
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
|
||||
return doc.duplicate_into(id, parent, index);
|
||||
}
|
||||
let transform = n.transform;
|
||||
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
|
||||
doc.get_mut(instance)?.transform = transform;
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
/// Which part of the carried overrides to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Carry {
|
||||
|
||||
@@ -194,6 +194,67 @@ fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_main_component_makes_a_linked_instance() {
|
||||
for rename in [false, true] {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
|
||||
let k = kit(&mut s);
|
||||
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
|
||||
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
|
||||
assert_eq!(s.selection(), ids);
|
||||
let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
|
||||
assert_eq!(component_of(&s, copy), k.card);
|
||||
assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
|
||||
assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
|
||||
let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
|
||||
assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
|
||||
let other = *ids.iter().find(|i| **i != copy).unwrap();
|
||||
assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
|
||||
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
|
||||
assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_dragging_a_main_component_copies_an_instance() {
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
|
||||
let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
|
||||
s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
|
||||
let [copy] = s.selection()[..] else { panic!("one copy selected") };
|
||||
assert_eq!(component_of(&s, copy), main);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_variant_adds_a_variant_to_its_set() {
|
||||
let mut s = Session::new();
|
||||
let (set, _, hover) = button_set(&mut s);
|
||||
let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
|
||||
assert_eq!(node(&s, copy).parent, Some(set));
|
||||
assert_eq!(kids(&s, set).len(), 3);
|
||||
// A whole set duplicates as a new set of variants.
|
||||
let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
|
||||
assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overridable_properties_stay_through_main_edits_and_reset() {
|
||||
// (what, layer, group, override on the instance, the same property edited on the main, read,
|
||||
|
||||
@@ -1300,7 +1300,7 @@ impl Session {
|
||||
for i in &ids {
|
||||
let parent = doc.get(*i)?.parent.unwrap_or(page);
|
||||
let idx = doc.index_in_parent(*i).map(|x| x + 1);
|
||||
copies.push(doc.duplicate_into(*i, parent, idx)?);
|
||||
copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?);
|
||||
}
|
||||
moving = copies;
|
||||
*sel = moving.clone();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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-10-20 · **Change:** minor (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
|
||||
@@ -17,6 +17,7 @@ accounts).
|
||||
| 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; the instance itself resizes and its layers follow the main's constraints |
|
||||
| Component sets / variants, add variant, switch | done | |
|
||||
| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) |
|
||||
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
|
||||
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
|
||||
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
|
||||
@@ -76,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
|
||||
| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged |
|
||||
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
|
||||
| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |
|
||||
|
||||
Reference in New Issue
Block a user