From 68d642197d2b49f64afcc2f3d11d90c66cf45368 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 14 May 2026 15:37:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(shell-core/components):=20instantiate=5Fco?= =?UTF-8?q?mponent=20=E2=80=94=20Insert=20Instance=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` — 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. --- .../src/document/components.rs | 95 ++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/crates/openpencil-shell-core/src/document/components.rs b/crates/openpencil-shell-core/src/document/components.rs index 76b9bba9d..82dcd610e 100644 --- a/crates/openpencil-shell-core/src/document/components.rs +++ b/crates/openpencil-shell-core/src/document/components.rs @@ -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 { + 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();