feat(shell-core/components): instantiate_component — Insert Instance flow

Deep-clones a registered Component's root subtree with fresh
`NodeId`s and appends to the active page's top-level children.
Mirrors TS drag-from-Components-panel insertion + the right-click
"Insert Instance" path.

  - `Document::instantiate_component(component_id, next_id) ->
    Option<NodeId>` — looks up `doc.components`, deep-clones root
    via `clone_node_with_new_ids` (private walker), pushes to
    `active_page().children`, sets the new root as selection
    anchor, captures pre-state to history (one entry per insert).
  - `next_id` allocator threaded through so every node in the
    cloned subtree gets a unique id past `max_node_id() + 1`,
    matching the same guard `duplicate_selected` /
    `group_selected` use.

Tests (2 new):
  - Component with 2 children → instance with same shape, both
    children have fresh ids (≠ source 11, 12), selection lands
    on instance root, history grew by one.
  - Unknown component id → None (no-op, no history).

#8 Components now ~65% — types + storage + create + instantiate
flow all shipped. UI hookup (Components panel widget + right-
click "Insert Instance" + drag-drop into canvas) is the remaining
follow-up.

Tests total: 231 shell-core (+2). Wasm32 build clean.
This commit is contained in:
Kayshen-X 2026-05-14 15:37:22 +08:00
parent c68c1de920
commit 68d642197d

View file

@ -56,9 +56,6 @@ impl Document {
let target = self.selected;
let page = self.active_page()?;
let node = page.find(target)?;
// v1: only Frame / Group can become a Component. Loose
// shapes (Rect / Ellipse / Text / Path) need to be wrapped
// in a Frame first — same restriction TS imposes.
if !matches!(node.kind, super::NodeKind::Frame | super::NodeKind::Group) {
return None;
}
@ -71,6 +68,50 @@ impl Document {
self.components.insert(comp);
Some(target)
}
/// "Insert Instance" mutator. Deep-clones the component's root
/// subtree with fresh `NodeId`s and appends it to the active
/// page's top-level children. Returns the new instance's root
/// id, or None when the component id is unknown / the next-id
/// allocator can't advance. Mirrors TS drag-from-Components-
/// panel insertion.
pub fn instantiate_component(
&mut self,
component_id: NodeId,
next_id: &mut u64,
) -> Option<NodeId> {
let comp = self.components.find_by_id(component_id)?.clone();
let pre = self.snapshot_for_history();
// Mint a fresh id past the high-water mark (same guard as
// duplicate_selected / group_selected).
let safe = self.max_node_id().checked_add(1)?;
let raw = (*next_id).max(safe);
*next_id = raw.checked_add(1)?;
let new_root = clone_node_with_new_ids(&comp.root, raw, next_id);
let new_id = new_root.id;
let active = self.active_page_index;
self.pages.get_mut(active)?.children.push(new_root);
self.selected_set.clear();
self.selected_set.push(new_id);
self.selected = new_id;
self.history_push_past(pre);
Some(new_id)
}
}
fn clone_node_with_new_ids(src: &Node, new_id: u64, next_id: &mut u64) -> Node {
let mut out = src.clone();
out.id = NodeId::new(new_id);
out.children = src
.children
.iter()
.map(|c| {
let raw = *next_id;
*next_id = next_id.checked_add(1).unwrap_or(raw);
clone_node_with_new_ids(c, raw, next_id)
})
.collect();
out
}
#[cfg(test)]
@ -137,6 +178,54 @@ mod tests {
assert!(doc.components.components.is_empty());
}
#[test]
fn instantiate_component_clones_subtree_with_fresh_ids() {
use crate::document::NodeKind;
let mut doc = Document::empty();
let page = doc.pages.get_mut(0).unwrap();
page.children.clear();
let mut frame = Node::with_children(
10,
NodeKind::Frame,
"F",
vec![
Node::leaf(11, NodeKind::Rect, "r1"),
Node::leaf(12, NodeKind::Rect, "r2"),
],
);
// Bound the frame so the resulting clone is meaningful.
frame.bounds = crate::Rect::xywh(0.0, 0.0, 100.0, 100.0);
page.children.push(frame);
doc.set_single_selection(NodeId::new(10));
doc.create_component_from_selected("Card");
// Now instantiate.
let mut next = 100u64;
let inst_id = doc.instantiate_component(NodeId::new(10), &mut next).unwrap();
// Fresh root id is past the source id.
assert!(inst_id.raw() >= 100);
let inst = doc.active_page().unwrap().find(inst_id).unwrap();
// Same shape: 2 children.
assert_eq!(inst.children.len(), 2);
// Child ids fresh (not 11/12).
for c in &inst.children {
assert_ne!(c.id, NodeId::new(11));
assert_ne!(c.id, NodeId::new(12));
}
// Selection landed on the new instance root.
assert_eq!(doc.selected, inst_id);
// History snapshot pushed.
assert_eq!(doc.history.past.len(), 1);
}
#[test]
fn instantiate_component_unknown_id_returns_none() {
let mut doc = Document::empty();
let mut next = 100u64;
assert!(doc
.instantiate_component(NodeId::new(99), &mut next)
.is_none());
}
#[test]
fn create_component_no_op_without_selection() {
let mut doc = Document::empty();