/// 人类视觉编辑(双向同步):拖拽 / 改尺寸 / 改样式 / 删除。
/// 每个操作都是中间态(场景图)上的一个可序列化补丁,与 Agent 命令
/// 共享同一份表示——编辑器拖拽与 Agent 命令最终都变成这里的调用。
///
/// 拖拽核心语义:栈布局中的节点被拖动 = 脱离自动布局,进入自由层
/// (画布级 Group,主轴 Fixed(0) 不占栈位),与 Figma 拖出 Auto Layout 一致。
///|
/// 自由层节点 id(每个画布一棵)
pub const FREE_LAYER_ID : String = "__free__"
///|
/// 收集子树全部 id(删除用)
fn collect_subtree(doc : Document, id : String, acc : Array[String]) -> Unit {
acc.push(id)
match doc.get(id) {
Some(n) =>
for c in n.children.iter() {
collect_subtree(doc, c, acc)
}
None => ()
}
}
///|
/// 解析十进制数(含小数与负号);非法输入返回 0
pub fn parse_double(s : String) -> Double {
let mut result = 0.0
let mut frac = 0.0
let mut div = 10.0
let mut seen_dot = false
let mut neg = false
let mut started = false
for c in s.iter() {
let code = c.to_int()
if code == 45 && !started {
neg = true
started = true
} else if code >= 48 && code <= 57 {
if seen_dot {
frac = frac + (code - 48).to_double() / div
div = div * 10.0
} else {
result = result * 10.0 + (code - 48).to_double()
}
started = true
} else if code == 46 && !seen_dot {
seen_dot = true
started = true
}
}
if neg {
-(result + frac)
} else {
result + frac
}
}
///|
/// 确保画布根部存在自由层 Group(主轴不占位),返回是否新建
fn ensure_free_layer(doc : Document) -> Bool {
if doc.has(FREE_LAYER_ID) {
return false
}
// 自由层尺寸:按根栈方向,主轴 Fixed(0) 零占位;根无栈则铺满
let (w, h) : (SizeSpec, SizeSpec) = match doc.get(doc.root_id) {
Some(rn) =>
match rn.layout {
Some(l) =>
match l.dir {
Horizontal => (Fixed(0.0), Fill)
Vertical => (Fill, Fixed(0.0))
}
None => (Fill, Fill)
}
None => (Fill, Fill)
}
let free = { ..Node::make(FREE_LAYER_ID, Group), width: w, height: h, }
doc.insert_node(free, doc.root_id)
}
///|
/// 人类拖拽:把节点放到画布绝对坐标 (x, y)。
/// 父容器无栈布局 → 就地改相对坐标;父容器有栈布局 → reparent 到自由层。
pub fn Project::move_node(
self : Project,
artboard~ : String,
node_id~ : String,
x~ : Double,
y~ : Double,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
if node_id == ab.doc.root_id {
return Err("cannot_move_root")
}
if node_id == FREE_LAYER_ID {
return Err("cannot_move_free_layer")
}
match ab.doc.get(node_id) {
None => Err("unknown_node: \{node_id}")
Some(_n) => {
let parent = match ab.doc.find_parent(node_id) {
Some(p) => p
None => return Err("orphan_node: \{node_id}")
}
// 宿主:父无栈 → 父本身;父有栈 → 自由层
let host : String = match ab.doc.get(parent) {
Some(pn) =>
match pn.layout {
None => parent
Some(_) => FREE_LAYER_ID
}
None => FREE_LAYER_ID
}
let detached = host == FREE_LAYER_ID && parent != FREE_LAYER_ID
if detached {
let _ = ensure_free_layer(ab.doc)
let _ = ab.doc.update_node(parent, pn => {
..pn,
children: pn.children.filter(c => c != node_id),
})
// children 是数组引用,直接追加即可
match ab.doc.get(FREE_LAYER_ID) {
Some(fl) => fl.children.push(node_id)
None => ()
}
}
// 自由层新建后需重解一次才能拿到宿主矩形
let layout = solve(ab.doc)
let (hx, hy) : (Double, Double) = match layout.rect_of(host) {
Some(r) => (r.x, r.y)
None => (0.0, 0.0)
}
// 与 place 相同的边界策略:节点完整留在画板内容盒内。
let nw : Double = match _n.width {
Fixed(v) => v
_ => 0.0
}
let nh : Double = match _n.height {
Fixed(v) => v
_ => 0.0
}
let safe_x = clamp_coord(x, 0.0, max_coord(ab.width - nw))
let safe_y = clamp_coord(y, 0.0, max_coord(ab.height - nh))
let rel_x = safe_x - hx
let rel_y = safe_y - hy
let _ = ab.doc.update_node(node_id, n => {
..n,
x: Fixed(rel_x),
y: Fixed(rel_y),
})
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"op\":\"move\",\"node\":\"\{node_id}\",\"x\":\{safe_x},\"y\":\{safe_y},\"host\":\"\{host}\",\"detached\":\{detached}}",
)
}
}
}
}
}
///|
/// update/edit k=v 的合法键词汇表(edit_node 预检的单一事实源)。
/// 此前未识别 key 被静默丢弃仍返回 ok:true/fields:0——typo(如 colr=)
/// 得到成功响应却什么都没改。新增键必须两处同步(match 臂 + 本表),
/// 漏同步会被使用新键的既有测试当场打红。
fn is_edit_key(k : String) -> Bool {
match k {
"w" | "h" | "text" | "fill" | "text_color" | "stroke" | "radius"
| "opacity" | "font_size" | "weight" | "shadow" | "rotate" | "blur"
| "blend" | "line" | "tracking" | "constraint" | "name" | "stroke_width"
| "align" | "italic" | "dash" | "visible" | "width_mode" | "height_mode"
| "x_mode" | "y_mode" | "layout" | "gap" | "justify" | "padding" => true
_ => false
}
}
///|
/// 人类编辑:key=value 批量更新节点属性。
/// 支持键:w h text fill text_color stroke radius opacity font_size weight
pub fn Project::edit_node(
self : Project,
artboard~ : String,
node_id~ : String,
kvs~ : Array[(String, String)],
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
if !ab.doc.has(node_id) {
return Err("unknown_node: \{node_id}")
}
// 未识别 key 显式拒绝(不落任何字段)——与 restyle 的 no_kv_pairs
// 防御对称:typo 必须报错,而不是成功响应 + fields:0
for kv in kvs.iter() {
let (k, _) = kv
if !is_edit_key(k) {
return Err("mbt_operation_unknown_key:" + k)
}
}
let applied : Array[String] = []
let _ = ab.doc.update_node(node_id, n => {
let mut cur = n
for kv in kvs.iter() {
let (k, v) = kv
let d = parse_double(v)
match k {
// w/h 接受数字(Fixed)或 "fill"/"hug" 简写(与 @decl.fill/@decl.hug 同义)
"w" => {
cur = {
..cur,
width: match v {
"fill" => Fill
"hug" => Hug
_ => Fixed(d)
},
}
applied.push("w")
}
"h" => {
cur = {
..cur,
height: match v {
"fill" => Fill
"hug" => Hug
_ => Fixed(d)
},
}
applied.push("h")
}
"text" => {
cur = { ..cur, text: v, }
applied.push("text")
}
"fill" => {
cur = { ..cur, style: { ..cur.style, fill: v, }, }
applied.push("fill")
}
"text_color" => {
cur = { ..cur, style: { ..cur.style, text_color: v, }, }
applied.push("text_color")
}
"stroke" => {
cur = { ..cur, style: { ..cur.style, stroke: v, }, }
applied.push("stroke")
}
"radius" => {
cur = { ..cur, style: { ..cur.style, corner_radius: d, }, }
applied.push("radius")
}
"opacity" => {
let o = if d < 0.0 { 0.0 } else if d > 1.0 { 1.0 } else { d }
cur = { ..cur, style: { ..cur.style, opacity: o, }, }
applied.push("opacity")
}
"font_size" => {
cur = { ..cur, style: { ..cur.style, font_size: d, }, }
applied.push("font_size")
}
"weight" => {
cur = { ..cur, style: { ..cur.style, font_weight: v, }, }
applied.push("weight")
}
"shadow" => {
cur = { ..cur, style: { ..cur.style, shadow: v, }, }
applied.push("shadow")
}
"rotate" => {
cur = { ..cur, style: { ..cur.style, rotate: d, }, }
applied.push("rotate")
}
"blur" => {
let b = if d < 0.0 { 0.0 } else { d }
cur = { ..cur, style: { ..cur.style, blur: b, }, }
applied.push("blur")
}
"blend" => {
cur = { ..cur, style: { ..cur.style, blend: v, }, }
applied.push("blend")
}
"line" => {
let lh = if d < 1.0 { 1.0 } else { d }
cur = { ..cur, style: { ..cur.style, line_height: lh, }, }
applied.push("line")
}
"tracking" => {
cur = { ..cur, style: { ..cur.style, tracking: d, }, }
applied.push("tracking")
}
"constraint" => {
cur = { ..cur, constraint: v, }
applied.push("constraint")
}
// 节点显示名(交互标记的载体;仅当偏离 id 时写入 canonical MBT)
"name" => {
cur = { ..cur, name: v, }
applied.push("name")
}
"stroke_width" => {
let sw = if d < 0.0 { 0.0 } else { d }
cur = { ..cur, style: { ..cur.style, stroke_width: sw, }, }
applied.push("stroke_width")
}
"align" => {
let a = if v == "center" || v == "right" { v } else { "left" }
cur = { ..cur, style: { ..cur.style, text_align: a, }, }
applied.push("align")
}
"italic" => {
let f = if v == "true" || v == "1" || v == "italic" {
"italic"
} else {
"normal"
}
cur = { ..cur, style: { ..cur.style, font_style: f, }, }
applied.push("italic")
}
"dash" => {
let ds = if v == "dashed" || v == "dotted" { v } else { "solid" }
cur = { ..cur, style: { ..cur.style, stroke_dash: ds, }, }
applied.push("dash")
}
"visible" => {
let vis = !(v == "false" ||
v == "0" ||
v == "off" ||
v == "hidden")
cur = { ..cur, style: { ..cur.style, visible: vis, }, }
applied.push("visible")
}
// 尺寸模式:hug(包裹内容)/ fill(撑满父容器)——布局引擎早已支持,
// 此前只能通过 w/h 写死 Fixed。
"width_mode" => {
match v {
"hug" => cur = { ..cur, width: Hug, }
"fill" => cur = { ..cur, width: Fill, }
_ => ()
}
applied.push("width_mode")
}
"height_mode" => {
match v {
"hug" => cur = { ..cur, height: Hug, }
"fill" => cur = { ..cur, height: Fill, }
_ => ()
}
applied.push("height_mode")
}
// 位置模式:center(父容器居中)/ end(贴 end 边,offset 由 dx/dy 语义另给)
"x_mode" =>
if v == "center" {
cur = { ..cur, x: CenterInParent, }
applied.push("x_mode")
} else if v == "start" {
cur = { ..cur, x: AlignStart(pos_offset_x(cur.x)), }
applied.push("x_mode")
}
"y_mode" =>
if v == "center" {
cur = { ..cur, y: CenterInParent, }
applied.push("y_mode")
} else if v == "start" {
cur = { ..cur, y: AlignStart(pos_offset_y(cur.y)), }
applied.push("y_mode")
}
// 容器栈布局:layout=vertical|horizontal 启用/切换;none 还原为绝对定位。
// 此前 StackLayout 只能由模板或 place 产生,update 完全无法设置。
"layout" =>
match v {
"none" => {
cur = { ..cur, layout: None, }
applied.push("layout")
}
"vertical" | "horizontal" => {
let base = match cur.layout {
Some(l) => l
None =>
{
dir: Vertical,
gap: 0.0,
justify: Start,
padding: Padding::uniform(0.0),
}
}
let dir = if v == "horizontal" {
Horizontal
} else {
Vertical
}
cur = { ..cur, layout: Some({ ..base, dir, }), }
applied.push("layout")
}
_ => ()
}
"gap" =>
match cur.layout {
Some(l) => {
let g = if d < 0.0 { 0.0 } else { d }
cur = { ..cur, layout: Some({ ..l, gap: g, }), }
applied.push("gap")
}
None => ()
}
"justify" =>
match cur.layout {
Some(l) => {
let j = match v {
"center" => Center
"end" => End
_ => Start
}
cur = { ..cur, layout: Some({ ..l, justify: j, }), }
applied.push("justify")
}
None => ()
}
// 统一内边距(四边同值);细分方向由模板/组件承担
"padding" =>
match cur.layout {
Some(l) => {
let p = if d < 0.0 { 0.0 } else { d }
cur = {
..cur,
layout: Some({ ..l, padding: Padding::uniform(p), }),
}
applied.push("padding")
}
None => ()
}
_ => ()
}
}
cur
})
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"op\":\"update\",\"node\":\"\{node_id}\",\"fields\":\{applied.length()}}",
)
}
}
}
///|
/// 取位置规格的数值分量(x_mode/y_mode 降级为 AlignStart 时保留原偏移)
pub fn pos_offset_x(p : PosSpec) -> Double {
match p {
Fixed(v) => v
AlignStart(v) => v
AlignEnd(v) => v
CenterInParent => 0.0
}
}
///|
pub fn pos_offset_y(p : PosSpec) -> Double {
match p {
Fixed(v) => v
AlignStart(v) => v
AlignEnd(v) => v
CenterInParent => 0.0
}
}
///|
/// 人类删除:摘除整个子树
pub fn Project::delete_node(
self : Project,
artboard~ : String,
node_id~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
if node_id == ab.doc.root_id {
return Err("cannot_delete_root")
}
match ab.doc.find_parent(node_id) {
None => Err("unknown_node: \{node_id}")
Some(parent) => {
let ids : Array[String] = []
collect_subtree(ab.doc, node_id, ids)
let _ = ab.doc.update_node(parent, pn => {
..pn,
children: pn.children.filter(c => c != node_id),
})
for i in ids.iter() {
ab.doc.content_seq = ab.doc.content_seq + 1
ab.doc.nodes.remove(i)
}
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"op\":\"delete\",\"node\":\"\{node_id}\",\"removed\":\{ids.length()}}",
)
}
}
}
}
}