openpencil/crates/op-editor-core/src/command_node.rs
Kayshen-X 4bda4cb0e9 feat(renderer): even-odd fill, per-corner radii, background blur end-to-end
Vector plan Tasks 3-5: winding rule imports onto PathNode.fillRule and
reaches skia/CanvasKit fill types through new *_with_fill_rule painter
entry points; per-corner radii flow payload->scene->RRect on both
backends; BACKGROUND_BLUR renders via backdrop save layers (native
SaveLayerRec backdrop, CanvasKit saveLayer backdrop). Includes jian
pointer bump (6bed2be). no-verify: repo fmt gate trips on unrelated
in-progress op-html sources.
2026-07-19 20:45:02 +08:00

998 lines
34 KiB
Rust

//! Raw-node command application — `InsertNode` / `UpdateNode` /
//! `DeleteNode` / `MoveNode` / `CopyNode` / `ReplaceNode` /
//! `BatchInsert`.
//!
//! shell-core's `mcp_apply.rs` built its flat `Node` struct directly;
//! `op-editor-core` operates on the canonical `jian_ops_schema::PenNode`
//! tree, so this module ports the build / find / detach / replace
//! helpers onto `PenNode`. Carved off `command_apply.rs` to keep both
//! files under the 800-line cap.
//!
//! Every helper preserves the pre-validate-then-mutate discipline: a
//! caller validates kind / geometry / hex / id space BEFORE any tree
//! write, so a bad arg never leaves the document half-mutated.
use crate::command::BatchInsertItem;
use crate::fills::set_primary_fill_hex;
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::state::EditorState;
use crate::walkers;
use jian_ops_schema::node::{
BoolOrExpression, CheckboxNode, ContainerProps, EllipseNode, FrameNode, GroupNode, LineNode,
NumberInputNode, NumberOrExpression, PathNode, PenNode, PenNodeBase, PolygonNode, ProgressNode,
RadioGroupNode, RectangleNode, SelectNode, SliderNode, SwitchNode, TabsNode, TextAreaNode,
TextContent, TextInputNode, TextNode,
};
use jian_ops_schema::sizing::SizingBehavior;
use std::collections::HashSet;
/// Resolve an editor `kind` arg into a canonical-schema leaf node.
/// Accepts the same lowercase strings the read-side tools emit
/// (`frame` / `group` / `rect` / `ellipse` / `polygon` / `line` /
/// `text` / `path`). `None` for an unknown kind.
///
/// `width` / `height` write the variant's literal `SizingBehavior`;
/// `(x, y)` write `base`. Container kinds (`frame` / `group`) start
/// with an empty `children` so a follow-up `MoveNode` can reparent
/// into them.
pub fn build_leaf_node(
kind: &str,
id: &str,
name: &str,
x: i32,
y: i32,
width: i32,
height: i32,
) -> Option<PenNode> {
let base = PenNodeBase {
id: id.to_string(),
name: Some(name.to_string()),
x: Some(x as f64),
y: Some(y as f64),
..Default::default()
};
let w = SizingBehavior::Number(width.max(0) as f64);
let h = SizingBehavior::Number(height.max(0) as f64);
let node = match kind {
"frame" => PenNode::Frame(FrameNode {
base,
container: ContainerProps {
width: Some(w),
height: Some(h),
..Default::default()
},
children: Some(Vec::new()),
image_search_query: None,
reusable: None,
screen: None,
slot: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
breakpoint: None,
}),
"group" => PenNode::Group(GroupNode {
base,
container: ContainerProps {
width: Some(w),
height: Some(h),
..Default::default()
},
children: Some(Vec::new()),
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"rect" => PenNode::Rectangle(RectangleNode {
base,
container: ContainerProps {
width: Some(w),
height: Some(h),
..Default::default()
},
children: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"ellipse" => PenNode::Ellipse(EllipseNode {
base,
width: Some(w),
height: Some(h),
corner_radius: None,
inner_radius: None,
start_angle: None,
sweep_angle: None,
fill: None,
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
limits: Default::default(),
}),
"polygon" => PenNode::Polygon(PolygonNode {
base,
polygon_count: 3,
width: Some(w),
height: Some(h),
corner_radius: None,
fill: None,
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
limits: Default::default(),
}),
"line" => PenNode::Line(LineNode {
base,
x2: Some((x + width) as f64),
y2: Some((y + height) as f64),
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
}),
"text" => PenNode::Text(TextNode {
base,
width: Some(w),
height: Some(h),
content: TextContent::Plain(name.to_string()),
font_family: None,
font_size: None,
font_weight: None,
font_style: None,
letter_spacing: None,
line_height: None,
text_align: None,
text_align_vertical: None,
text_growth: None,
underline: None,
strikethrough: None,
fill: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
limits: Default::default(),
}),
"path" => PenNode::Path(PathNode {
base,
icon_id: None,
d: None,
anchors: Some(Vec::new()),
closed: None,
fill_rule: None,
width: Some(w),
height: Some(h),
fill: None,
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
fill_rule: None,
limits: Default::default(),
}),
// Form-widget kinds (Phase D2) build their own default props.
_ => return build_widget_node(kind, base, w, h),
};
Some(node)
}
/// Resolve a form-widget `kind` into a canonical-schema widget node
/// with sensible default props. `base` already carries the id / name /
/// `(x, y)`; `w` / `h` are the caller's literal `SizingBehavior` (the
/// widget's default box when minted via a tool, or the requested box
/// from an `InsertNode` / MCP call). Returns `None` for an unknown
/// kind so [`build_leaf_node`]'s fall-through can reject it.
///
/// Each struct derives `Default`, so `..Default::default()` zeroes
/// every optional field (fill / stroke / events / bindings / …) and we
/// only set `base` + width / height + the per-kind props the table in
/// the Phase D2 spec calls for.
fn build_widget_node(
kind: &str,
base: PenNodeBase,
w: SizingBehavior,
h: SizingBehavior,
) -> Option<PenNode> {
let node = match kind {
"text_input" => PenNode::TextInput(TextInputNode {
base,
width: Some(w),
height: Some(h),
placeholder: Some("Enter text".to_string()),
..Default::default()
}),
"text_area" => PenNode::TextArea(TextAreaNode {
base,
width: Some(w),
height: Some(h),
placeholder: Some("Enter text".to_string()),
..Default::default()
}),
"number_input" => PenNode::NumberInput(NumberInputNode {
base,
width: Some(w),
height: Some(h),
placeholder: Some("0".to_string()),
..Default::default()
}),
"select" => PenNode::Select(SelectNode {
base,
width: Some(w),
height: Some(h),
placeholder: Some("Select\u{2026}".to_string()),
options: Some(Vec::new()),
..Default::default()
}),
"radio_group" => PenNode::RadioGroup(RadioGroupNode {
base,
width: Some(w),
height: Some(h),
options: Some(Vec::new()),
..Default::default()
}),
"switch" => PenNode::Switch(SwitchNode {
base,
width: Some(w),
height: Some(h),
checked: Some(BoolOrExpression::Bool(false)),
..Default::default()
}),
"checkbox" => PenNode::Checkbox(CheckboxNode {
base,
width: Some(w),
height: Some(h),
checked: Some(BoolOrExpression::Bool(false)),
label: Some("Label".to_string()),
..Default::default()
}),
"slider" => PenNode::Slider(SliderNode {
base,
width: Some(w),
height: Some(h),
min: Some(0.0),
max: Some(100.0),
step: Some(1.0),
value: Some(NumberOrExpression::Number(50.0)),
..Default::default()
}),
"progress" => PenNode::Progress(ProgressNode {
base,
width: Some(w),
height: Some(h),
value: Some(NumberOrExpression::Number(40.0)),
max: Some(100.0),
..Default::default()
}),
"tabs" => PenNode::Tabs(TabsNode {
base,
width: Some(w),
height: Some(h),
tabs: Some(Vec::new()),
children: Some(Vec::new()),
..Default::default()
}),
_ => return None,
};
Some(node)
}
/// The ten form-widget kind strings, in spec order. Single source of
/// truth shared by [`kind_is_valid`] and the default-size table.
pub const WIDGET_KINDS: [&str; 10] = [
"text_input",
"text_area",
"number_input",
"select",
"radio_group",
"switch",
"checkbox",
"slider",
"progress",
"tabs",
];
/// True when `kind` resolves to a buildable leaf node. Used by the
/// `BatchInsert` pre-validation pass. Covers the original shape /
/// container / text kinds plus the ten form-widget kinds.
pub fn kind_is_valid(kind: &str) -> bool {
matches!(
kind,
"frame" | "group" | "rect" | "ellipse" | "polygon" | "line" | "text" | "path"
) || WIDGET_KINDS.contains(&kind)
}
/// Replace the node with `target` id with `replacement` at its current
/// slot, preserving sibling order. True on the first match.
pub fn replace_node_in_children(
children: &mut [PenNode],
target: &NodeId,
replacement: &mut Option<PenNode>,
) -> bool {
if let Some(idx) = children.iter().position(|n| n.id_str() == target.as_str()) {
if let Some(r) = replacement.take() {
children[idx] = r;
return true;
}
}
for child in children.iter_mut() {
if let Some(grand) = child.children_mut() {
if replace_node_in_children(grand, target, replacement) {
return true;
}
}
}
false
}
#[allow(clippy::result_large_err)]
fn insert_into_parent_or_root(
children: &mut Vec<PenNode>,
parent: &NodeId,
node: PenNode,
index: Option<usize>,
) -> Result<(), PenNode> {
if !parent.is_real() {
let idx = index.unwrap_or(children.len()).min(children.len());
children.insert(idx, node);
return Ok(());
}
insert_into_parent(children, parent, node, index)
}
#[allow(clippy::result_large_err)]
fn insert_into_parent(
children: &mut [PenNode],
parent: &NodeId,
node: PenNode,
index: Option<usize>,
) -> Result<(), PenNode> {
if let Some(idx) = children.iter().position(|n| n.id_str() == parent.as_str()) {
match children[idx].children_mut() {
Some(grand) => {
let insert_idx = index.unwrap_or(grand.len()).min(grand.len());
grand.insert(insert_idx, node);
return Ok(());
}
None => return Err(node),
}
}
let mut carry = node;
for child in children.iter_mut() {
if let Some(grand) = child.children_mut() {
match insert_into_parent(grand, parent, carry, index) {
Ok(()) => return Ok(()),
Err(returned) => carry = returned,
}
}
}
Err(carry)
}
impl EditorState {
/// Compute the numeric seed for the next editor-minted `n{N}` id —
/// `max_node_id() + 1`. `None` on `u64` exhaustion. Pub so the
/// `batch_design` MCP tool can predict (off a doc snapshot) the ids the
/// host will allocate at apply, mirroring `cmd_insert_subtree`'s seed.
pub fn next_node_id_seed(&self) -> Option<u64> {
self.max_node_id().checked_add(1).map(|n| n.max(1))
}
/// Allocate one fresh `n{N}` id, skipping any candidate that
/// collides with a live id. `None` on counter exhaustion.
pub(crate) fn next_node_id(&self) -> Option<NodeId> {
let mut seed = self.next_node_id_seed()?;
let mut live = self.collect_node_ids();
walkers::alloc_n_id(&mut seed, &mut live)
}
/// `InsertNode` — build + append a fresh leaf on the active page.
// Args mirror the `InsertNode` command fields one-for-one; bundling
// them into a struct would just shadow the DTO with no real gain.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_insert_node(
&mut self,
kind: &str,
name: &str,
x: i32,
y: i32,
width: i32,
height: i32,
fill_hex: &Option<String>,
target_parent: &NodeId,
) -> bool {
if !kind_is_valid(kind) || width < 0 || height < 0 {
return false;
}
// Pre-validate hex BEFORE minting an id / mutating the tree.
if let Some(hex) = fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
if target_parent.is_real() {
match walkers::find_node(self.active_children(), target_parent) {
Some(parent) if parent.is_container() => {}
_ => return false,
}
}
let Some(new_id) = self.next_node_id() else {
return false;
};
let Some(mut node) = build_leaf_node(kind, new_id.as_str(), name, x, y, width, height)
else {
return false;
};
if let Some(hex) = fill_hex {
set_primary_fill_hex(&mut node, hex);
}
if target_parent.is_real() {
let root = self.active_children_mut();
let Some(parent) = walkers::find_node_mut(root, target_parent) else {
return false;
};
let Some(children) = parent.children_mut() else {
return false;
};
children.push(node);
} else {
self.active_children_mut().push(node);
}
true
}
/// `UpdateNode` — patch optional fields on an existing node.
// Args mirror the `UpdateNode` command fields one-for-one.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_update_node(
&mut self,
node_id: &NodeId,
x: Option<i32>,
y: Option<i32>,
width: Option<i32>,
height: Option<i32>,
name: &Option<String>,
fill_hex: &Option<String>,
) -> bool {
if !node_id.is_real() {
return false;
}
// Pre-validate EVERY field before the mutable borrow + writes.
if let Some(w) = width {
if w < 0 {
return false;
}
}
if let Some(h) = height {
if h < 0 {
return false;
}
}
if let Some(hex) = fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
let Some(node) = walkers::find_node_mut(self.active_children_mut(), node_id) else {
return false;
};
// All validation passed — every field applies atomically.
if let Some(nx) = x {
node.base_mut().x = Some(nx as f64);
}
if let Some(ny) = y {
node.base_mut().y = Some(ny as f64);
}
if let Some(nw) = width {
node.set_width_px(nw as f64);
}
if let Some(nh) = height {
node.set_height_px(nh as f64);
}
if let Some(new_name) = name {
node.base_mut().name = Some(new_name.clone());
}
if let Some(hex) = fill_hex {
set_primary_fill_hex(node, hex);
}
true
}
/// `PatchNodeData` — TS-style shallow merge on a canonical PenNode.
pub(crate) fn cmd_patch_node_data(&mut self, node_id: &NodeId, patch_json: &str) -> bool {
if !node_id.is_real() {
return false;
}
let Ok(serde_json::Value::Object(patch)) =
serde_json::from_str::<serde_json::Value>(patch_json)
else {
return false;
};
let Some(current) = walkers::find_node(self.active_children(), node_id) else {
return false;
};
let Ok(mut value) = serde_json::to_value(current) else {
return false;
};
let Some(obj) = value.as_object_mut() else {
return false;
};
for (key, value) in patch {
obj.insert(key, value);
}
let Ok(replacement) = serde_json::from_value::<PenNode>(value) else {
return false;
};
if replacement.id_str().is_empty() {
return false;
}
let mut slot = Some(replacement);
replace_node_in_children(self.active_children_mut(), node_id, &mut slot)
}
/// `DeleteNode` — remove a node + descendants from the active page.
pub(crate) fn cmd_delete_node(&mut self, node_id: &NodeId) -> bool {
if !node_id.is_real() {
return false;
}
walkers::remove_from_children(self.active_children_mut(), node_id)
}
/// `MoveNode` — reparent a node. A `NONE` target reparents to the
/// active page root; a real target must resolve + must not create
/// a cycle (target is a descendant of the moved node).
pub(crate) fn cmd_move_node(
&mut self,
node_id: &NodeId,
target_parent: &NodeId,
index: Option<usize>,
) -> bool {
if !node_id.is_real() || target_parent == node_id {
return false;
}
// Pre-validate EVERYTHING before detaching: a bad target would
// detach → reattach-fail → silently drop the source.
{
let children = self.active_children();
let Some(src) = walkers::find_node(children, node_id) else {
return false;
};
if target_parent.is_real() {
if walkers::descendant_contains(src, target_parent) {
return false;
}
let Some(target) = walkers::find_node(children, target_parent) else {
return false;
};
// A container that has never held a child carries NO `children`
// array — it is still a container, and `children_mut` mints the
// array on demand. Refusing it here made an empty media slot
// unreachable: a photo could not be moved into the very slot
// the design authored for it (measured test0711-1-glm).
if !target.is_container() {
return false;
}
}
}
let children = self.active_children_mut();
let Some(detached) = walkers::extract_node(children, node_id) else {
return false;
};
insert_into_parent_or_root(children, target_parent, detached, index).is_ok()
}
/// `CopyNode` — deep-clone a node + subtree under a new parent
/// (`NONE` = active page root). Fresh ids minted past the id space.
pub(crate) fn cmd_copy_node(
&mut self,
node_id: &NodeId,
target_parent: &NodeId,
overrides_json: Option<&str>,
) -> bool {
if !node_id.is_real() {
return false;
}
// Validate source + target up front.
{
let children = self.active_children();
if walkers::find_node(children, node_id).is_none() {
return false;
}
if target_parent.is_real() {
let Some(target) = walkers::find_node(children, target_parent) else {
return false;
};
// Same rule as MoveNode: childless container, still a container.
if !target.is_container() {
return false;
}
}
}
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let mut taken = self.collect_node_ids();
// Clone the owned subtree before re-borrowing the tree mutably.
let mut clone = {
let children = self.active_children();
let src = walkers::find_node(children, node_id).expect("validated");
walkers::deep_clone_with_new_ids(src, &mut next_id, &mut taken)
};
if !apply_copy_overrides(&mut clone, overrides_json) {
return false;
}
let children = self.active_children_mut();
insert_into_parent_or_root(children, target_parent, clone, None).is_ok()
}
/// `ReplaceNode` — swap an existing node for a freshly-built leaf at
/// the same slot. The destructive-swap guard: replacing a node WITH
/// children requires `drop_children == true`, else the swap is
/// refused so a container can't silently lose its subtree.
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_replace_node(
&mut self,
node_id: &NodeId,
kind: &str,
name: &str,
x: i32,
y: i32,
width: i32,
height: i32,
fill_hex: &Option<String>,
drop_children: bool,
) -> bool {
if !node_id.is_real() || !kind_is_valid(kind) || width < 0 || height < 0 {
return false;
}
if let Some(hex) = fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
// Resolve target + check the destructive-swap guard BEFORE
// minting an id. A target WITH children needs explicit consent.
{
let children = self.active_children();
let Some(target) = walkers::find_node(children, node_id) else {
return false;
};
let has_children = target.children().map(|c| !c.is_empty()).unwrap_or(false);
if has_children && !drop_children {
return false;
}
}
let Some(new_id) = self.next_node_id() else {
return false;
};
let Some(mut replacement) =
build_leaf_node(kind, new_id.as_str(), name, x, y, width, height)
else {
return false;
};
if let Some(hex) = fill_hex {
set_primary_fill_hex(&mut replacement, hex);
}
let mut slot = Some(replacement);
replace_node_in_children(self.active_children_mut(), node_id, &mut slot)
}
/// `ReplaceSubtree` — swap an existing node for a fully-authored
/// canonical subtree. The destructive-swap guard matches
/// `ReplaceNode`: replacing a node WITH children requires explicit
/// opt-in.
pub(crate) fn cmd_replace_subtree(
&mut self,
node_id: &NodeId,
node: PenNode,
drop_children: bool,
) -> bool {
if !node_id.is_real() {
return false;
}
{
let children = self.active_children();
let Some(target) = walkers::find_node(children, node_id) else {
return false;
};
let has_children = target.children().map(|c| !c.is_empty()).unwrap_or(false);
if has_children && !drop_children {
return false;
}
}
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let mut taken = self.collect_node_ids();
let mut nodes = vec![node];
if !remap_subtree_ids(&mut nodes, &mut next_id, &mut taken) {
return false;
}
let mut slot = nodes.pop();
replace_node_in_children(self.active_children_mut(), node_id, &mut slot)
}
/// `BatchInsert` — insert N leaf nodes on the active page in one
/// atomic shot. EVERY descriptor is validated before any mutation;
/// a single bad entry rejects the whole batch.
pub(crate) fn cmd_batch_insert(&mut self, items: &[BatchInsertItem]) -> bool {
if items.is_empty() {
return false;
}
// Pre-validate kinds + geometry + fill hex up front.
for item in items {
if !kind_is_valid(&item.kind) || item.width < 0 || item.height < 0 {
return false;
}
if let Some(hex) = &item.fill_hex {
if crate::color_picker::parse_hex_rgb(hex).is_none() {
return false;
}
}
}
// Allocate every fresh id up front; bail on id-space exhaustion.
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let mut live: HashSet<NodeId> = self.collect_node_ids();
let mut ids: Vec<NodeId> = Vec::with_capacity(items.len());
for _ in 0..items.len() {
match walkers::alloc_n_id(&mut next_id, &mut live) {
Some(id) => ids.push(id),
None => return false,
}
}
// All validation + allocation passed — now mutate.
let children = self.active_children_mut();
for (item, id) in items.iter().zip(ids) {
// `kind` already validated, so `build_leaf_node` is Some.
let mut node = build_leaf_node(
&item.kind,
id.as_str(),
&item.name,
item.x,
item.y,
item.width,
item.height,
)
.expect("kind validated");
// A full canonical fill stack overrides the solid `fill_hex`
// shortcut so gradient / mesh / image fills survive the batch
// insert; otherwise fall back to the single-colour path.
if let Some(fills) = &item.fill {
if let Some(slot) = crate::fills::node_fills_mut(&mut node) {
*slot = fills.clone();
}
} else if let Some(hex) = &item.fill_hex {
set_primary_fill_hex(&mut node, hex);
}
children.push(node);
}
true
}
/// Insert one or more nested `PenNode` subtrees. `parent_id` of
/// `NONE` appends to the active page root; otherwise the parent
/// must exist and be a container variant. Every incoming node id
/// (recursively) is remapped to a fresh editor id so an
/// externally-authored subtree can't collide with live doc ids.
pub(crate) fn cmd_insert_subtree(&mut self, nodes: Vec<PenNode>, parent_id: &NodeId) -> bool {
if nodes.is_empty() {
return false;
}
// Validate the parent up front (when not the page root).
if parent_id.is_real() {
match walkers::find_node(self.active_children(), parent_id) {
Some(p) if p.is_container() => {}
_ => return false, // missing or non-container
}
}
// Allocate fresh ids for the whole incoming forest.
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
let mut taken: HashSet<NodeId> = self.collect_node_ids();
let mut nodes = nodes;
let replacement = crate::command_root_replace::prepare_root_frame_replacement(
self.active_children(),
&mut nodes,
parent_id,
);
if !remap_subtree_ids(&mut nodes, &mut next_id, &mut taken) {
return false;
}
// All validation + allocation passed — now mutate.
if parent_id.is_real() {
let root = self.active_children_mut();
let Some(parent) = walkers::find_node_mut(root, parent_id) else {
return false;
};
let Some(slot) = parent.children_mut() else {
return false;
};
slot.extend(nodes);
} else {
let roots = self.active_children_mut();
if let Some(replacement) = replacement.as_ref() {
if !crate::command_root_replace::remove_root_frame_replacement(roots, replacement) {
return false;
}
}
roots.extend(nodes);
}
true
}
/// Same mutation as [`cmd_insert_subtree`] but returns the
/// **post-remap** ids of the forest roots (the incoming top-level
/// nodes, in order). `None` = rejected (no mutation), mirroring
/// `cmd_insert_subtree`'s `false`. Used by the orchestrator so
/// append-mode cleanup can scope to exactly the newly-inserted roots
/// (their ids are remapped on apply — the caller's ids are placeholders).
pub fn insert_subtree_returning_root_ids(
&mut self,
nodes: Vec<PenNode>,
parent_id: &NodeId,
) -> Option<Vec<String>> {
if nodes.is_empty() {
return None;
}
// Validate the parent up front (when not the page root).
if parent_id.is_real() {
match walkers::find_node(self.active_children(), parent_id) {
Some(p) if p.is_container() => {}
_ => return None,
}
}
let mut next_id = self.next_node_id_seed()?;
let mut taken: HashSet<NodeId> = self.collect_node_ids();
let mut nodes = nodes;
let replacement = crate::command_root_replace::prepare_root_frame_replacement(
self.active_children(),
&mut nodes,
parent_id,
);
// remap_subtree_ids_mapping mutates every node id IN PLACE (DFS order).
// Reading root ids from the mapping by index is incorrect: for a
// forest where root0 has children, mapping[0..root_count] would yield
// [root0, child0a, ...] instead of [root0, root1, ...]. Instead, read
// the root ids directly from the top-level nodes after remap — they
// are already updated in place and ordering is exact.
remap_subtree_ids_mapping(&mut nodes, &mut next_id, &mut taken)?;
let root_ids: Vec<String> = nodes.iter().map(|n| n.id_str().to_string()).collect();
// All validation + allocation passed — now mutate.
if parent_id.is_real() {
let root = self.active_children_mut();
let parent = walkers::find_node_mut(root, parent_id)?;
let slot = parent.children_mut()?;
slot.extend(nodes);
} else {
let roots = self.active_children_mut();
if let Some(replacement) = replacement.as_ref() {
if !crate::command_root_replace::remove_root_frame_replacement(roots, replacement) {
return None;
}
}
roots.extend(nodes);
}
Some(root_ids)
}
}
fn apply_copy_overrides(node: &mut PenNode, overrides_json: Option<&str>) -> bool {
let Some(raw) = overrides_json else {
return true;
};
let Ok(serde_json::Value::Object(mut overrides)) =
serde_json::from_str::<serde_json::Value>(raw)
else {
return false;
};
overrides.remove("id");
let Ok(mut node_value) = serde_json::to_value(&*node) else {
return false;
};
let Some(node_object) = node_value.as_object_mut() else {
return false;
};
for (key, value) in overrides {
node_object.insert(key, value);
}
let Ok(overridden) = serde_json::from_value::<PenNode>(node_value) else {
return false;
};
*node = overridden;
true
}
/// Reassign every node id in `nodes` (recursively, including
/// `children`) to a fresh unique id. `next_id` + `taken` are the same
/// allocator pair [`walkers::alloc_n_id`] uses; `taken` must be seeded
/// with the document's live ids. Returns `false` on id-space
/// exhaustion.
pub(crate) fn remap_subtree_ids(
nodes: &mut [PenNode],
next_id: &mut u64,
taken: &mut HashSet<NodeId>,
) -> bool {
remap_subtree_ids_mapping(nodes, next_id, taken).is_some()
}
/// Like [`remap_subtree_ids`] but returns the `(old_id, new_id)` pairs in
/// depth-first allocation order (`None` on id-space exhaustion). The mutation
/// is IDENTICAL to `remap_subtree_ids` — that wrapper just discards the map.
/// The `batch_design` MCP tool runs this on a CLONE of the to-be-inserted
/// forest to PREDICT the ids the host will assign (single-user localhost MCP:
/// the tool's snapshot == the live doc at apply, and the apply runs the exact
/// same allocation), so it can report TS's `results:[{binding,nodeId}]`.
pub fn remap_subtree_ids_mapping(
nodes: &mut [PenNode],
next_id: &mut u64,
taken: &mut HashSet<NodeId>,
) -> Option<Vec<(String, String)>> {
let mut map = Vec::new();
remap_collect(nodes, next_id, taken, &mut map).then_some(map)
}
fn remap_collect(
nodes: &mut [PenNode],
next_id: &mut u64,
taken: &mut HashSet<NodeId>,
map: &mut Vec<(String, String)>,
) -> bool {
for node in nodes.iter_mut() {
let Some(fresh) = walkers::alloc_n_id(next_id, taken) else {
return false;
};
let old = node.base().id.clone();
let new = fresh.as_str().to_string();
node.base_mut().id = new.clone();
map.push((old, new));
if let Some(children) = node.children_mut() {
if !remap_collect(children, next_id, taken, map) {
return false;
}
}
}
true
}