Closes the architectural piece of the "MCP element toolset" P1 gap (TS pen-mcp ships ~100 add_card_*/add_toast_* element tools). - op-editor-core: new `EditorCommand::InstantiateKitComponent` variant + applier branch that calls `EditorState::instantiate_kit_component` with the requested drop point (defaults to (0, 0)). - op-mcp: `element_tools.rs` — `InsertKitComponent` per-component tool returning `OkWithCommand(_, InstantiateKitComponent)`; `insert_kit_component_tools(state)` walks every loaded kit; `element_tool_schemas(state)` emits the matching tools/list JSON. Tool names sanitize dashes: `insert_btn_primary`, `insert_card_basic`, etc. - op-host-desktop: `rebuild_registry` chains the dynamic tools in; `tools_list_response` takes EditorState and appends the dynamic schemas next to TOOL_SCHEMAS. - op-editor-core: tidies the empty-pages `ensure_pages` guard to `is_none_or`. Result: 6 starter-kit components → 6 working MCP tools. The 100-tool catalog parity is now a data fill-in (more components in op-editor- core/uikit.rs auto-register as more MCP tools).
438 lines
17 KiB
Rust
438 lines
17 KiB
Rust
//! [`EditorState::apply`] — apply one [`EditorCommand`] against the
|
|
//! editor state.
|
|
//!
|
|
//! Ported from `openpencil-shell-core::document::apply_mcp_command`.
|
|
//! Preserves the two shell-core invariants:
|
|
//!
|
|
//! - **Pre-validate-then-mutate.** Every argument (id space, target
|
|
//! existence, geometry, hex, container-children consent) is checked
|
|
//! BEFORE any tree write, so a bad arg never half-mutates the
|
|
//! document. The raw-node helpers in [`crate::command_node`] and
|
|
//! the attribute helpers in [`crate::command_node_attrs`] keep that
|
|
//! discipline internally.
|
|
//! - **`ReplaceNode` destructive-swap guard.** Replacing a node WITH
|
|
//! children requires `drop_children == true`.
|
|
//!
|
|
//! The result type is `bool` — identical to shell-core's
|
|
//! `apply_mcp_command`: `true` when the command changed something (so
|
|
//! a host can decide whether to push undo / persist), `false` on an
|
|
//! apply-time validation failure.
|
|
//!
|
|
//! ## Component-command gap
|
|
//!
|
|
//! `op-editor-core` has no component registry — that was a shell-core
|
|
//! `Document` concern and the canonical-schema component model is a
|
|
//! later task. The four `*Component` variants are part of the DTO for
|
|
//! surface fidelity, but `apply` rejects them (`false`).
|
|
|
|
use crate::align::AlignAction;
|
|
use crate::command::{EditorCommand, VariableScalarPayload};
|
|
use crate::node_id::NodeId;
|
|
use crate::state::EditorState;
|
|
use crate::tool::Tool;
|
|
use crate::viewport::Viewport;
|
|
use crate::walkers::find_node;
|
|
use jian_ops_schema::variable::{VariableKind, VariableScalar};
|
|
|
|
/// Resolve an `align` action string into an [`AlignAction`].
|
|
fn parse_align_action(s: &str) -> Option<AlignAction> {
|
|
match s {
|
|
"left" => Some(AlignAction::Left),
|
|
"center_h" => Some(AlignAction::CenterH),
|
|
"right" => Some(AlignAction::Right),
|
|
"top" => Some(AlignAction::Top),
|
|
"center_v" => Some(AlignAction::CenterV),
|
|
"bottom" => Some(AlignAction::Bottom),
|
|
"distribute_h" => Some(AlignAction::DistributeH),
|
|
"distribute_v" => Some(AlignAction::DistributeV),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Resolve a `tool` string into a [`Tool`].
|
|
fn parse_tool(s: &str) -> Option<Tool> {
|
|
match s {
|
|
"select" => Some(Tool::Select),
|
|
"rect" => Some(Tool::Rect),
|
|
"ellipse" => Some(Tool::Ellipse),
|
|
"polygon" => Some(Tool::Polygon),
|
|
"line" => Some(Tool::Line),
|
|
"pen" => Some(Tool::Pen),
|
|
"text" => Some(Tool::Text),
|
|
"frame" => Some(Tool::Frame),
|
|
"hand" => Some(Tool::Hand),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Resolve a variable `kind` string into a [`VariableKind`].
|
|
fn parse_variable_kind(s: &str) -> Option<VariableKind> {
|
|
match s {
|
|
"color" => Some(VariableKind::Color),
|
|
"number" => Some(VariableKind::Number),
|
|
"boolean" => Some(VariableKind::Boolean),
|
|
"string" => Some(VariableKind::String),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
impl EditorState {
|
|
/// Apply one [`EditorCommand`]. Returns `true` when the command
|
|
/// actually changed the document / editor state, `false` on an
|
|
/// apply-time validation failure.
|
|
pub fn apply(&mut self, cmd: EditorCommand) -> bool {
|
|
match cmd {
|
|
// --- Raw node CRUD -------------------------------------
|
|
EditorCommand::InsertNode {
|
|
kind,
|
|
name,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
fill_hex,
|
|
} => self.cmd_insert_node(&kind, &name, x, y, width, height, &fill_hex),
|
|
EditorCommand::UpdateNode {
|
|
node_id,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
name,
|
|
fill_hex,
|
|
} => self.cmd_update_node(&node_id, x, y, width, height, &name, &fill_hex),
|
|
EditorCommand::DeleteNode { node_id } => self.cmd_delete_node(&node_id),
|
|
EditorCommand::MoveNode {
|
|
node_id,
|
|
target_parent,
|
|
} => self.cmd_move_node(&node_id, &target_parent),
|
|
EditorCommand::CopyNode {
|
|
node_id,
|
|
target_parent,
|
|
} => self.cmd_copy_node(&node_id, &target_parent),
|
|
EditorCommand::ReplaceNode {
|
|
node_id,
|
|
kind,
|
|
name,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
fill_hex,
|
|
drop_children,
|
|
} => self.cmd_replace_node(
|
|
&node_id,
|
|
&kind,
|
|
&name,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
&fill_hex,
|
|
drop_children,
|
|
),
|
|
EditorCommand::BatchInsert { items } => self.cmd_batch_insert(&items),
|
|
|
|
// --- Per-node attribute writers ------------------------
|
|
EditorCommand::SetNodeRotation { node_id, degrees } => {
|
|
self.cmd_set_node_rotation(&node_id, degrees)
|
|
}
|
|
EditorCommand::SetNodeText { node_id, text } => self.cmd_set_node_text(&node_id, &text),
|
|
EditorCommand::SetNodeCornerRadius { node_id, radius } => {
|
|
self.cmd_set_node_corner_radius(&node_id, radius)
|
|
}
|
|
EditorCommand::SetNodeFontSize { node_id, font_size } => {
|
|
self.cmd_set_node_font_size(&node_id, font_size)
|
|
}
|
|
EditorCommand::SetNodeFontWeight {
|
|
node_id,
|
|
font_weight,
|
|
} => self.cmd_set_node_font_weight(&node_id, font_weight),
|
|
EditorCommand::SetNodeStrokeHex { node_id, hex } => {
|
|
self.cmd_set_node_stroke_hex(&node_id, &hex)
|
|
}
|
|
EditorCommand::SetNodeStrokeWidth { node_id, width } => {
|
|
self.cmd_set_node_stroke_width(&node_id, width)
|
|
}
|
|
EditorCommand::SetNodeFillHex { node_id, hex } => {
|
|
self.cmd_set_node_fill_hex(&node_id, &hex)
|
|
}
|
|
EditorCommand::SetNodeName { node_id, name } => self.cmd_set_node_name(&node_id, &name),
|
|
EditorCommand::SetNodeFlag {
|
|
node_id,
|
|
flag,
|
|
value,
|
|
} => self.cmd_set_node_flag(&node_id, flag, value),
|
|
EditorCommand::SetNodeFlip {
|
|
node_id,
|
|
flip_x,
|
|
flip_y,
|
|
} => self.cmd_set_node_flip(&node_id, flip_x, flip_y),
|
|
EditorCommand::SetEllipseArc {
|
|
node_id,
|
|
start_angle,
|
|
sweep_angle,
|
|
inner_radius,
|
|
} => self.cmd_set_ellipse_arc(&node_id, start_angle, sweep_angle, inner_radius),
|
|
EditorCommand::AddNodeEffect { node_id, kind } => {
|
|
self.cmd_add_node_effect(&node_id, &kind)
|
|
}
|
|
EditorCommand::RemoveNodeEffect { node_id, index } => {
|
|
self.cmd_remove_node_effect(&node_id, index)
|
|
}
|
|
EditorCommand::SetEffectParam {
|
|
node_id,
|
|
index,
|
|
field,
|
|
value,
|
|
} => self.cmd_set_effect_param(&node_id, index, field, value),
|
|
|
|
// --- Variables + themes --------------------------------
|
|
EditorCommand::SetVariableColor { name, hex } => self.set_variable_color(&name, &hex),
|
|
EditorCommand::SetVariableScalar { name, scalar } => match scalar {
|
|
VariableScalarPayload::Number(n) => self.set_variable_number(&name, n),
|
|
VariableScalarPayload::String(s) => self.set_variable_string(&name, s),
|
|
VariableScalarPayload::Boolean(b) => self.set_variable_boolean(&name, b),
|
|
},
|
|
EditorCommand::CreateVariable {
|
|
name,
|
|
kind,
|
|
default_value,
|
|
} => {
|
|
let Some(kind) = parse_variable_kind(&kind) else {
|
|
return false;
|
|
};
|
|
// The default value is parsed per kind; a bad value
|
|
// (non-numeric Number, unparseable Boolean) rejects.
|
|
let default = match kind {
|
|
VariableKind::Color | VariableKind::String => {
|
|
VariableScalar::Str(default_value)
|
|
}
|
|
VariableKind::Number => match default_value.trim().parse::<f64>() {
|
|
Ok(n) => VariableScalar::Num(n),
|
|
Err(_) => return false,
|
|
},
|
|
VariableKind::Boolean => match default_value.trim() {
|
|
"true" => VariableScalar::Bool(true),
|
|
"false" => VariableScalar::Bool(false),
|
|
_ => return false,
|
|
},
|
|
};
|
|
self.create_variable(&name, kind, default)
|
|
}
|
|
EditorCommand::DeleteVariable { name } => self.delete_variable(&name),
|
|
EditorCommand::RenameVariable { old_name, new_name } => {
|
|
self.rename_variable(&old_name, &new_name)
|
|
}
|
|
EditorCommand::SetActiveAxisValue { axis, value } => {
|
|
self.set_active_axis_value(&axis, &value)
|
|
}
|
|
EditorCommand::CycleActiveAxisValue { axis } => self.cycle_active_axis_value(&axis),
|
|
|
|
// --- Pages ---------------------------------------------
|
|
EditorCommand::SetActivePage { index } => self.set_active_page(index as usize),
|
|
EditorCommand::AddPage => self.add_page().is_some(),
|
|
EditorCommand::RenamePage { index, name } => self.rename_page(index as usize, name),
|
|
EditorCommand::DeletePage { index } => self.remove_page(index as usize),
|
|
EditorCommand::DuplicatePage { index } => self.duplicate_page(index as usize).is_some(),
|
|
EditorCommand::ReorderPage { from, to } => {
|
|
self.reorder_page(from as usize, to as usize)
|
|
}
|
|
|
|
// --- Selection -----------------------------------------
|
|
EditorCommand::ClearSelection => {
|
|
self.clear_selection();
|
|
true
|
|
}
|
|
EditorCommand::SetSelection { node_id } => {
|
|
// Scoped to the active page — parity with shell-core,
|
|
// which rejected off-page ids so later reads stay
|
|
// consistent.
|
|
if !node_id.is_real() || find_node(self.active_children(), &node_id).is_none() {
|
|
return false;
|
|
}
|
|
self.set_single_selection(node_id);
|
|
true
|
|
}
|
|
EditorCommand::SetSelectionSet { node_ids } => {
|
|
// Resolve every id against the active page; unknown /
|
|
// off-page ids are dropped silently.
|
|
let resolved: Vec<NodeId> = node_ids
|
|
.into_iter()
|
|
.filter(|id| id.is_real() && find_node(self.active_children(), id).is_some())
|
|
.collect();
|
|
if resolved.is_empty() {
|
|
self.clear_selection();
|
|
} else {
|
|
self.selection.anchor = resolved.last().cloned().unwrap();
|
|
self.selection.set = resolved;
|
|
}
|
|
true
|
|
}
|
|
EditorCommand::ToggleNodeSelection { node_id } => {
|
|
if !node_id.is_real() || find_node(self.active_children(), &node_id).is_none() {
|
|
return false;
|
|
}
|
|
self.toggle_selection(node_id);
|
|
true
|
|
}
|
|
|
|
// --- Selection-scoped tree ops -------------------------
|
|
EditorCommand::DuplicateSelected { offset_px } => {
|
|
let Some(mut next_id) = self.next_node_id_seed() else {
|
|
return false;
|
|
};
|
|
self.duplicate_selected(&mut next_id, offset_px as f64)
|
|
.is_some()
|
|
}
|
|
EditorCommand::DeleteSelected => {
|
|
if self.selection.set.is_empty() {
|
|
return false;
|
|
}
|
|
let snap = self.snapshot_for_history();
|
|
if self.delete_selected() {
|
|
self.history_push_past(snap);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
EditorCommand::NudgeSelected { dx, dy } => {
|
|
if self.selection.set.is_empty() || (dx == 0 && dy == 0) {
|
|
return false;
|
|
}
|
|
let snap = self.snapshot_for_history();
|
|
self.translate_selected(dx as f64, dy as f64);
|
|
self.history_push_past(snap);
|
|
true
|
|
}
|
|
EditorCommand::GroupSelected => {
|
|
let Some(mut next_id) = self.next_node_id_seed() else {
|
|
return false;
|
|
};
|
|
let snap = self.snapshot_for_history();
|
|
if self.group_selected(&mut next_id).is_some() {
|
|
self.history_push_past(snap);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
EditorCommand::UngroupSelected => {
|
|
let snap = self.snapshot_for_history();
|
|
if self.ungroup_selected() {
|
|
self.history_push_past(snap);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
EditorCommand::ReorderSelected { direction } => {
|
|
if !self.selection.anchor.is_real() {
|
|
return false;
|
|
}
|
|
let snap = self.snapshot_for_history();
|
|
if self.reorder_selected(direction) {
|
|
self.history_push_past(snap);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
EditorCommand::AlignSelected { action } => {
|
|
let Some(parsed) = parse_align_action(&action) else {
|
|
return false;
|
|
};
|
|
// `align_selected` pushes its own history on real
|
|
// motion.
|
|
self.align_selected(parsed)
|
|
}
|
|
|
|
// --- Clipboard -----------------------------------------
|
|
EditorCommand::CopySelected => self.copy_selected(),
|
|
EditorCommand::CutSelected => {
|
|
let snap = self.snapshot_for_history();
|
|
if self.cut_selected() {
|
|
self.history_push_past(snap);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
EditorCommand::PasteClipboard { offset_px } => {
|
|
let Some(mut next_id) = self.next_node_id_seed() else {
|
|
return false;
|
|
};
|
|
let snap = self.snapshot_for_history();
|
|
let new_ids = self.paste_clipboard(&mut next_id, offset_px as f64);
|
|
if new_ids.is_empty() {
|
|
return false;
|
|
}
|
|
self.history_push_past(snap);
|
|
true
|
|
}
|
|
EditorCommand::ImportSvg { svg, x, y } => {
|
|
let Some(mut next_id) = self.next_node_id_seed() else {
|
|
return false;
|
|
};
|
|
// `import_svg` pushes its own history snapshot when it
|
|
// inserts ≥ 1 node.
|
|
self.import_svg(&mut next_id, &svg, (x as f64, y as f64)) > 0
|
|
}
|
|
|
|
// --- Tool + viewport + history -------------------------
|
|
EditorCommand::SetActiveTool { tool } => {
|
|
let Some(new_tool) = parse_tool(&tool) else {
|
|
return false;
|
|
};
|
|
self.tool = new_tool;
|
|
true
|
|
}
|
|
EditorCommand::SetViewport {
|
|
pan_x,
|
|
pan_y,
|
|
zoom_percent,
|
|
} => {
|
|
let mut changed = false;
|
|
if let Some(x) = pan_x {
|
|
self.viewport.pan_x = x as f32;
|
|
changed = true;
|
|
}
|
|
if let Some(y) = pan_y {
|
|
self.viewport.pan_y = y as f32;
|
|
changed = true;
|
|
}
|
|
if let Some(z) = zoom_percent {
|
|
let zoom = (z as f32 / 100.0).clamp(Viewport::MIN_ZOOM, Viewport::MAX_ZOOM);
|
|
self.viewport.zoom = zoom;
|
|
changed = true;
|
|
}
|
|
changed
|
|
}
|
|
EditorCommand::Undo => self.undo(),
|
|
EditorCommand::Redo => self.redo(),
|
|
|
|
// --- Component commands — unsupported gap --------------
|
|
EditorCommand::InstantiateComponent { .. }
|
|
| EditorCommand::CreateComponent { .. }
|
|
| EditorCommand::DeleteComponent { .. }
|
|
| EditorCommand::RenameComponent { .. } => false,
|
|
|
|
// --- UIKit element insert -------------------------------
|
|
EditorCommand::InstantiateKitComponent {
|
|
kit_id,
|
|
component_id,
|
|
doc_x,
|
|
doc_y,
|
|
} => self
|
|
.instantiate_kit_component(
|
|
&kit_id,
|
|
&component_id,
|
|
doc_x.unwrap_or(0.0),
|
|
doc_y.unwrap_or(0.0),
|
|
)
|
|
.is_some(),
|
|
}
|
|
}
|
|
}
|