Compare commits

..

68 Commits

Author SHA1 Message Date
k88936 47c07f9d12 迁移节点变换交互测试接口
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m39s
Project CI / Frontend tests (pull_request) Successful in 5m56s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m34s
Project CI / Repository checks (pull_request) Successful in 15m18s
Project CI / Native shell tests (pull_request) Successful in 22m28s
Project CI / Backend tests (pull_request) Successful in 27m38s
将旧单树测试夹具改为 trees 与 logicalSizes 参数

为拖拽和缩放入口补充所属 treeId 并更新树移除语义
2026-09-18 22:33:21 +08:00
k88936 0904184412 校正预览上下文菜单测试
覆盖上下文菜单回调的可选树标识参数

区分编辑器树目标与最终预览的无树标识回调
2026-09-18 22:18:58 +08:00
k88936 aaa2eb482a 更新最终预览框线测试语义
无框线场景不再传入选中节点以匹配选中态高亮规则

保留显式 showFrame 时的选中框与控制点断言
2026-09-18 22:08:53 +08:00
k88936 1679e72deb 修正删除快捷键页面测试
从仍挂载的窗口目标触发全局删除快捷键

按全部同名节点消失断言删除结果
2026-09-18 22:05:16 +08:00
k88936 4844543061 补齐检查提示动画测试名称
让动画 mock 匹配仅重启提示动画的现役筛选逻辑

保留非提示动画不被重启的生产行为
2026-09-18 22:00:58 +08:00
k88936 99132c9fa7 补齐 UI 状态持久化测试偏移字段
让当前 schema 夹具继续验证原有业务约束
2026-09-18 21:41:55 +08:00
k88936 944d2aa3db 更新 UI 编辑器返回交互测试
按现役浮动关闭按钮的可访问名称查询
2026-09-18 21:14:57 +08:00
k88936 7c16a35fda 兼容缺少偏移量的预览运行数据
校验 offset 前安全处理旧状态和测试夹具
2026-09-18 20:14:24 +08:00
k88936 e4369cd82e Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m0s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 7m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 6m32s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m21s
Project CI / Frontend tests (pull_request) Failing after 4m23s
Project CI / Repository checks (pull_request) Failing after 5m22s
Project CI / Native shell tests (pull_request) Successful in 9m10s
Project CI / Backend tests (pull_request) Successful in 9m41s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m32s
# Conflicts:
#	apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
#	docs/project-memory/shared-memory/decision-log.md
2026-09-18 18:44:11 +08:00
k88936 54b9bd4354 同步节点偏移量 TypeScript 绑定
更新 ts-rs 生成的 NodeOffset 类型文件
2026-09-18 17:30:53 +08:00
k88936 365e530b5f 修正测试偏移类型导入
从公开 offset 模块导入 NodeOffset
2026-09-18 17:23:06 +08:00
k88936 d46cdfb282 抽取保存结果弹窗操作底栏
统一关闭与重试按钮的布局和样式
2026-09-18 17:17:45 +08:00
k88936 2142027f85 简化保存结果弹窗内容分支
用显式早返回替换嵌套三元渲染
2026-09-18 17:15:50 +08:00
k88936 8fdf39f37e 清理预览初始适配无效依赖
移除仅通过 ref 读取的画布尺寸依赖
2026-09-18 17:14:16 +08:00
k88936 70c65c37d5 保证拖拽释放提交最新偏移
从指针释放坐标直接计算树位置
2026-09-18 17:12:30 +08:00
k88936 e72637789c 校验预览树偏移坐标
仅将有限偏移量纳入画布布局
2026-09-18 17:11:23 +08:00
k88936 0a102ea1c3 合并预览节点类型导入
复用单一 Node 类型导入声明
2026-09-18 17:10:04 +08:00
k88936 1ab7fa9991 校验预览画布像素比例
避免无效 pixels_per_unit 产生无限尺寸
2026-09-18 17:09:17 +08:00
k88936 6d02a0c29f 移除无读取的代码生成错误状态
删除失效的 generateError 状态及写入路径
2026-09-18 17:04:10 +08:00
k88936 a6c3db60fb 收窄编辑器操作生命周期接口
移除未被调用的 setRunning 与 reset 暴露
2026-09-18 16:57:42 +08:00
k88936 c921ae3b16 简化编辑器概览分支渲染
用显式条件替换嵌套三元表达式
2026-09-18 16:52:37 +08:00
k88936 31c084fce6 保留保存失败的底层错误信息
将实际异常传递到保存状态和重试提示
2026-09-18 16:48:53 +08:00
k88936 1e7a3f17ff 避免返回确认与失败弹窗重叠
返回路径保存失败时沿用确认弹窗错误状态
2026-09-18 16:45:43 +08:00
k88936 3ce2977e32 固定节点偏移量 TypeScript 元组导出
为二维数组字段补充 ts-rs 元组覆盖
2026-09-18 16:43:26 +08:00
k88936 f4e9414d63 限定状态提示动画重启动范围
仅重启动 UI 编辑器关注动画而不影响过渡效果
2026-09-18 16:41:12 +08:00
k88936 2d8b51885a 优化切分概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:39:39 +08:00
k88936 91f534ed66 优化识别概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:38:25 +08:00
k88936 f455d3edb2 清理未使用的界面图校验码
移除不会被发出的 missing-image 类型
2026-09-18 16:37:48 +08:00
k88936 bec225436f 简化状态节点轮询索引逻辑
用显式分支替换嵌套三元表达式
2026-09-18 16:35:25 +08:00
k88936 ce309a3b28 忽略未定义的节点元数据补丁
保留部分更新对未提供字段的原有语义
2026-09-18 16:33:37 +08:00
k88936 abd95a6231 消除复制路径未处理拒绝
复制失败仅保留界面错误状态
2026-09-18 16:31:04 +08:00
k88936 08c3b89b67 修复跨树节点选择同步界面图
选择节点时同步激活所属设计图
2026-09-18 16:28:49 +08:00
k88936 3371337346 修复树节点回调未使用参数
为跨树选择回调参数添加下划线前缀
2026-09-18 16:25:54 +08:00
k88936 899a591cda 区分语义建议工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:23:27 +08:00
k88936 0988c4907a 区分识别工具调用缺失错误
恢复识别工具缺失的独立日志阶段和提示
2026-09-18 16:20:49 +08:00
k88936 d370648867 区分合并工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:13:58 +08:00
k88936 4f4fb0873c 修复切分命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:09:03 +08:00
k88936 6b8a106414 修复合并命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:08:53 +08:00
k88936 55a8513de4 更新UI编辑器深模块架构文档
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 4m16s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 4m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 4m26s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m28s
Project CI / Frontend tests (pull_request) Failing after 3m30s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m33s
Project CI / Native shell tests (pull_request) Successful in 9m17s
记录状态迁移、几何、不变量与操作生命周期 seam

同步结构化 LLM 适配器边界与验证结果
2026-09-18 10:24:03 +08:00
k88936 d986dbaeb0 收敛UI编辑器结构化LLM动作适配器
统一必需 tool-call 定位与有界 JSON 参数解析

让语义建议、结构识别、组件绑定和多树合并复用稳定机械

保留各操作的 prompt、schema、领域校验与 materializer
2026-09-18 10:23:35 +08:00
k88936 16c6b9198e 深化UI编辑器前端状态与几何模块
新增 React-free 语义状态迁移 seam

收敛 State 级节点几何与保存前不变量校验

统一四类异步编辑器操作生命周期

补充状态迁移和不变量测试
2026-09-18 10:23:04 +08:00
k88936 4df4708ba1 Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 3m0s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 3m4s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m7s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 3m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / Repository checks (pull_request) Failing after 4m39s
Project CI / Native shell tests (pull_request) Successful in 7m14s
Project CI / Backend tests (pull_request) Successful in 8m12s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m54s
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-16 14:57:26 +08:00
k88936 1e992bcdf8 扩大未知 JavaScript 异常上报原则
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
明确所有未解决和非契约化 JS 异常必须继续抛出

说明用户 fallback 不得替代全局 error report 链路
2026-09-14 19:47:08 +08:00
k88936 8368aa262c 补充剪贴板异常上报约定
记录 UI fallback 后仍需抛出原始原生错误

明确未知异常必须进入 error report 链路
2026-09-14 19:43:08 +08:00
k88936 e9ddbf16da 重构预览网格步长计算
提取预览网格自适应步长到独立模块

补充缩放网格计算与背景样式测试

同步预览画布缩放交互文档
2026-09-14 19:40:13 +08:00
k88936 22ac1f4c0b 修复 UI 编辑器剪贴板写入权限
为主窗口补充 clipboard-manager 写文本权限

保留手动复制提示并继续抛出原始 Tauri 剪贴板错误
2026-09-14 19:39:34 +08:00
k88936 9910a0eec0 优化 UI 编辑器复制反馈布局
将复制失败提示与操作按钮分离排列
2026-09-14 19:06:15 +08:00
k88936 70981b9ca9 完善 UI 编辑器结果弹窗覆盖
增加普通保存成功与保存后生成失败的场景断言

验证路径弹窗和失败重试按钮的可见行为
2026-09-14 19:01:44 +08:00
k88936 8a0d5600b3 拆分 UI 编辑器路径复制按钮
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m27s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m5s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m15s
Project CI / Frontend tests (pull_request) Failing after 3m22s
Project CI / Repository checks (pull_request) Successful in 3m38s
Project CI / Native shell tests (pull_request) Successful in 6m42s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m12s
Project CI / Backend tests (pull_request) Successful in 8m24s
将剪贴板状态与写入逻辑提取为独立小组件

保持保存结果弹窗只负责结果布局与动作编排
2026-09-14 18:59:51 +08:00
k88936 184d88dbb8 修复多树预览受左侧选中图片限制
移除 activeImageId 对预览拖动和缩放手势的门控

按树 wrapper 提供几何上下文并允许任意树操作

保留 activeImageId 仅用于左侧图片与图片级检查
2026-09-14 18:59:51 +08:00
k88936 791794c0a6 记录 UI 编辑器保存结果弹窗约定
补充保存与代码生成结果弹窗及相对路径复制行为

记录复制失败、重试和保存并返回边界
2026-09-14 18:59:51 +08:00
k88936 9cfb47d945 补充 UI 编辑器保存结果测试
覆盖保存和生成成功弹窗与项目相对路径复制

覆盖保存失败、冲突和剪贴板失败反馈
2026-09-14 18:59:51 +08:00
k88936 27859d3e19 接入 UI 编辑器保存结果弹窗
让普通保存和保存并生成流程返回结构化结果

将成功与失败反馈接入独立弹窗并保留保存并返回直返行为
2026-09-14 18:59:51 +08:00
k88936 43f3780a38 新增 UI 编辑器保存结果弹窗
新增保存成功、代码生成成功和失败状态弹窗

提供项目相对路径复制按钮及复制失败提示
2026-09-14 18:59:51 +08:00
k88936 a1f9149c27 UI编辑器移除预览模式残留类型
完成三开关替代 render mode 的类型清理
2026-09-14 18:59:51 +08:00
k88936 f0bba18841 UI编辑器收口多树上下文菜单目标
预览菜单携带所属树 ID

避免非当前图片树的结构操作写错目标
2026-09-14 18:59:51 +08:00
k88936 f679cfa179 UI编辑器完善多树创建与文档契约
统一识别和合并结果的 createTree 流程

按选中节点所属树驱动 Inspector 编辑

补充多树预览与树级偏移权威文档
2026-09-14 18:59:50 +08:00
k88936 f68ffcfecd UI编辑器补充合并树偏移构造
为合并产出的节点补齐 NodeOffset
2026-09-14 18:59:50 +08:00
k88936 ec94a4fe00 UI编辑器重构多树预览与独立开关
预览画布同时渲染全部界面树并支持树级拖动

拆分显示框线、原图和组件开关

预览节点选择与左侧当前图片解耦
2026-09-14 18:59:50 +08:00
k88936 46f8592375 UI编辑器补充树级偏移模型
新增 Node offset min/max 数据结构

统一新树默认横向布局入口

增加树级偏移状态更新操作与实施计划
2026-09-14 18:59:50 +08:00
k88936 cdf2882302 补齐自动切分概览字体属性
向 SeparationOverview 传入 fonts 数据

修复 UI 编辑器 TS2741 类型错误
2026-09-14 18:59:50 +08:00
k88936 d156113e71 修复 UI 编辑器待办循环高亮动画
修复受控树选中回调清除 Inspector 高亮的问题

循环游标同时使用界面树与节点 ID

补充多节点循环和动画重启回归测试
2026-09-14 18:59:50 +08:00
k88936 a8f2ac17be 注释掉 InspectorSidebar 中的 LayoutEditor 组件以暂时禁用布局编辑功能 2026-09-14 18:59:50 +08:00
k88936 43ac9a5761 补充 UI 编辑器交互回归测试
从实际节点事件目标验证删除快捷键

验证 portal 右键菜单启用后会执行操作并关闭
2026-09-14 18:59:50 +08:00
k88936 e992e0b35c 压缩 UI 编辑器概览布局
将识别概览指标改为四列横向排列

将素材切分概览改为四列网格以减少纵向高度
2026-09-14 18:59:50 +08:00
k88936 e389b54a3a 修复 UI 预览画布交互
为无限视口添加跟随平移和缩放的点阵背景

恢复画布快捷缩放与初始适配比例控制

保留节点平移选择并屏蔽浏览器原生拖拽选择
2026-09-14 18:59:50 +08:00
k88936 84c780a6f8 重构 UI 编辑器全屏外壳
通过 portal 将资源编辑器挂载到 body 并覆盖标题栏以下区域

移除外层 FocusTrap,保留内部确认对话框的模态行为

统一左右浮动面板的顶部与底部留白
2026-09-14 18:59:50 +08:00
k88936 03f900fbe5 布局重构:
- 预览改为全区域背景层。
  - 左右面板、顶部流程条改为覆盖层,可遮挡预览。
  - 左右面板开合状态仅保存在当前 React 会话。
  - 增加左右面板“收起/展开”入口。
  - 编辑器关闭入口改为单独的 ×,继续通过 onBack 注入关闭逻辑。
  - 保留全局顶部栏与现有快捷键、业务状态、保存流程。
  - 底层页面仍由现有父级路由控制,不新增 URL 状态。
2026-09-14 18:59:50 +08:00
74 changed files with 2869 additions and 3724 deletions
-1
View File
@@ -3931,7 +3931,6 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
@@ -1,11 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"description": "AI 游戏创作主窗口允许读系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
@@ -2,7 +2,7 @@ use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
strict_json_schema,
required_tool_call_arguments, strict_json_schema,
};
use crate::ui_editor::component::text::FontSource;
use crate::ui_editor::component::{Component, NodeComponent};
@@ -406,12 +406,8 @@ pub(crate) async fn bind_components_impl_with_provider(
.await
}
.map_err(|error| format!("组件绑定失败:{error}"))?;
let call = response
.tool_calls
.iter()
.find(|call| call.name == "bind_ui_components")
.ok_or_else(|| "LLM 未返回 bind_ui_components 工具调用".to_string())?;
let parsed = parse_binding_response(&call.arguments, editable_nodes.len())?;
let arguments = required_tool_call_arguments(&response, "bind_ui_components")?;
let parsed = parse_binding_response(arguments, editable_nodes.len())?;
let known_sprite_ids = state.sprite_assets.keys().cloned().collect::<HashSet<_>>();
let known_font_ids = state.font_assets.keys().cloned().collect::<HashSet<_>>();
let result = validate_and_materialize(
@@ -1,7 +1,8 @@
use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, request_ui_editor_llm, strict_json_schema,
parse_limited_llm_tool_arguments, request_ui_editor_llm, required_tool_call_arguments,
strict_json_schema,
};
use crate::ui_editor::state::{State, UITree};
use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice};
@@ -172,6 +173,7 @@ mod materialize {
use crate::ui_editor::layout::node::{
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
@@ -302,6 +304,7 @@ mod materialize {
component: None,
children_display_mode: ChildrenDisplayMode::Exclusive,
children: members.into_iter().map(|member| member.node).collect(),
offset: NodeOffset::default(),
},
priority,
src_ui_design,
@@ -485,15 +488,11 @@ pub(crate) async fn merge_ui_impl_with_provider(
app_log!("ui_merge.error stage=llm_request error={error}");
format!("UI 树合并失败:{error}")
})?;
let call = response
.tool_calls
.iter()
.find(|call| call.name == MERGE_TOOL_NAME)
.ok_or_else(|| {
app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call");
format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用")
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
let arguments = required_tool_call_arguments(&response, MERGE_TOOL_NAME).map_err(|error| {
app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call error={error}");
format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用")
})?;
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
app_log!("ui_merge.error stage=parse_arguments error={error}");
format!("UI 合并工具参数无效:{error}")
})?;
@@ -527,6 +526,7 @@ mod tests {
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
@@ -548,6 +548,7 @@ mod tests {
component: None,
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
}
}
@@ -2,17 +2,18 @@ use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
strict_json_schema,
required_tool_call_arguments, strict_json_schema,
};
use crate::ui_editor::component::{Component, NodeComponent};
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::dimension::UIRect;
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
use crate::ui_editor::utils::{random_node_id, UIDesignImageId};
use nalgebra::{Point2, Vector2};
use platform_llm::{
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
@@ -322,6 +323,7 @@ fn convert_node(
component: source.component.clone().into_option(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
})
}
@@ -768,21 +770,18 @@ pub(crate) async fn recognize_ui_impl_with_provider(
!response.text.trim().is_empty(),
response.tool_calls.len()
);
let call = response
.tool_calls
.iter()
.find(|call| call.name == "recognize_ui_structure")
.ok_or_else(|| {
let arguments = required_tool_call_arguments(&response, "recognize_ui_structure")
.map_err(|error| {
app_log!(
"ui_recognition.error stage=parse_tool_call root={} reason=missing_tool_call",
"ui_recognition.error stage=parse_tool_call reason=missing_tool_call root={} error={error}",
root_id.as_str()
);
format!(
"根界面图 {}LLM 未返回 recognize_ui_structure 工具调用",
"LLM 未返回 recognize_ui_structure 工具调用(根界面图 {}",
root_id.as_str()
)
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
app_log!(
"ui_recognition.error stage=parse_arguments root={} error={error}",
root_id.as_str()
@@ -858,6 +857,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
component: recognition_root.component.into_option(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
};
// Tree identity and root identity are assigned by Rust, never chosen by the model.
ui_trees.push(UITree {
@@ -22,6 +22,7 @@ mod tests {
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::layout::node::{NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
@@ -46,6 +47,7 @@ mod tests {
component,
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
}
}
fn state(root: Node) -> State {
@@ -2,7 +2,7 @@ use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
strict_json_schema,
required_tool_call_arguments, strict_json_schema,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImageRole;
use crate::ui_editor::state::State;
@@ -214,15 +214,14 @@ pub(crate) async fn suggest_ui_design_semantic_impl(
!response.text.trim().is_empty(),
response.tool_calls.len()
);
let call = response
.tool_calls
.iter()
.find(|call| call.name == "suggest_ui_design_semantics")
.ok_or_else(|| {
app_log!("ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call");
let arguments = required_tool_call_arguments(&response, "suggest_ui_design_semantics")
.map_err(|error| {
app_log!(
"ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call error={error}"
);
"LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string()
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error}")
})?;
@@ -95,6 +95,29 @@ pub(crate) fn parse_limited_llm_tool_arguments(
serde_json::from_str(arguments).map_err(|error| format!("LLM 工具参数不是有效 JSON{error}"))
}
/// Stable structured-action adapter: locate the required tool call and parse its
/// bounded JSON arguments. Prompt, schema and materialization stay in each
/// operation-specific command.
pub(crate) fn required_tool_arguments(
response: &LlmRunResponse,
tool_name: &str,
) -> Result<serde_json::Value, String> {
let arguments = required_tool_call_arguments(response, tool_name)?;
parse_limited_llm_tool_arguments(arguments)
}
pub(crate) fn required_tool_call_arguments<'a>(
response: &'a LlmRunResponse,
tool_name: &str,
) -> Result<&'a str, String> {
response
.tool_calls
.iter()
.find(|call| call.name == tool_name)
.map(|call| call.arguments.as_str())
.ok_or_else(|| format!("LLM 未返回 {tool_name} 工具调用"))
}
pub(crate) async fn read_ui_reference_image_data_url(path: PathBuf) -> Result<String, String> {
tokio::task::spawn_blocking(move || read_ui_reference_image_data_url_blocking(&path))
.await
@@ -2,4 +2,5 @@ pub mod children_display_mode;
pub mod control_layout;
pub mod dimension;
pub mod node;
pub mod offset;
pub mod transform;
@@ -1,6 +1,7 @@
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::utils::NodeId;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -14,6 +15,7 @@ pub struct Node {
pub component: Option<Component>,
pub children_display_mode: ChildrenDisplayMode,
pub children: Vec<Node>,
pub offset: NodeOffset,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
@@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct NodeOffset {
#[ts(as = "[f32; 2]")]
pub min: [f32; 2],
#[ts(as = "[f32; 2]")]
pub max: [f32; 2],
}
impl Default for NodeOffset {
fn default() -> Self {
Self {
min: [0.0, 0.0],
max: [0.0, 0.0],
}
}
}
@@ -1126,8 +1126,10 @@ mod tests {
}
},
"children_display_mode": "Stack",
"children": []
}]
"children": [],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}
}],
"ui_design_images": {
@@ -1309,7 +1311,8 @@ mod tests {
},
"component": null,
"children_display_mode": "Stack",
"children": []
"children": [],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}
}],
"ui_design_images": {
@@ -1543,7 +1546,8 @@ mod tests {
}
},
"children_display_mode": "Stack",
"children": []
"children": [],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}
}],
"ui_design_images": {
@@ -78,7 +78,11 @@ export function ResourceCanvasGenerationPlaceholderCardView({
<Sparkles size={18} aria-hidden="true" />
<strong>{placeholder.assetName}</strong>
<small>
{RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[placeholder.status]}
{
RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[
placeholder.status
]
}
</small>
<button
type="button"
@@ -155,9 +155,7 @@ export function createResourceCanvasAssetGenerationQueue(
referenceAssetIds: task.referenceAssetIds,
outputPath: task.outputPath,
// 入口栏目:原生支持时按它登记归类;不支持时后端忽略,落点仍按正式归类走。
...(task.targetCategory
? { targetCategory: task.targetCategory }
: {}),
...(task.targetCategory ? { targetCategory: task.targetCategory } : {}),
})) as LocalProjectAssetGenerationTaskRecord;
let started: LocalProjectAssetGenerationTaskRecord;
try {
@@ -175,7 +175,9 @@ export function resourceCanvasGenerationPlaceholdersForProject(
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
projectId: string,
): ResourceCanvasGenerationPlaceholder[] {
return placeholders.filter((placeholder) => placeholder.projectId === projectId);
return placeholders.filter(
(placeholder) => placeholder.projectId === projectId,
);
}
/** 按任务找回占位:成功落点与失败收口都以 taskId 为准,不按素材名猜。 */
@@ -183,14 +185,18 @@ export function resourceCanvasGenerationPlaceholderByTaskId(
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
taskId: string,
): ResourceCanvasGenerationPlaceholder | null {
return placeholders.find((placeholder) => placeholder.taskId === taskId) ?? null;
return (
placeholders.find((placeholder) => placeholder.taskId === taskId) ?? null
);
}
export function resourceCanvasGenerationPlaceholderByDraftId(
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
draftId: string,
): ResourceCanvasGenerationPlaceholder | null {
return placeholders.find((placeholder) => placeholder.draftId === draftId) ?? null;
return (
placeholders.find((placeholder) => placeholder.draftId === draftId) ?? null
);
}
export const RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS: Record<
@@ -208,8 +208,10 @@ export function useResourceCanvasGenerationPlaceholders({
return;
}
event.stopPropagation();
const nextX = drag.startX + (event.clientX - drag.startClientX) / drag.scale;
const nextY = drag.startY + (event.clientY - drag.startClientY) / drag.scale;
const nextX =
drag.startX + (event.clientX - drag.startClientX) / drag.scale;
const nextY =
drag.startY + (event.clientY - drag.startClientY) / drag.scale;
drag.changed = true;
move(drag.draftId, nextX, nextY);
},
@@ -233,7 +235,8 @@ export function useResourceCanvasGenerationPlaceholders({
);
const projectPlaceholders = useMemo(
() => resourceCanvasGenerationPlaceholdersForProject(placeholders, projectId),
() =>
resourceCanvasGenerationPlaceholdersForProject(placeholders, projectId),
[placeholders, projectId],
);
@@ -1,6 +1,8 @@
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { State } from './types/State';
import type { Transform } from './types/Transform';
import type { UIDesignImageId } from './types/UIDesignImageId';
export type ResizeAxis = 'horizontal' | 'vertical';
@@ -17,6 +19,32 @@ export type NodePageContext = {
parentRect: PageRect;
};
/** State-level geometry seam shared by preview, inspector and transitions. */
export function findStateNodePageContext(
state: State,
treeId: UIDesignImageId,
nodeId: NodeId,
): NodePageContext | null {
const tree = state.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
const image = state.ui_design_images[treeId];
if (
!tree ||
!image ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
return null;
}
const size: [number, number] = [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
];
if (!size.every((value) => Number.isFinite(value) && value > 0)) return null;
return findNodePageContext(tree.root, nodeId, pageRectFromSize(size));
}
const MIN_NODE_SIZE = 1;
export function pageRectFromSize(size: [number, number]): PageRect {
@@ -1,4 +1,5 @@
import type { Node as UiNode } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
import type { StageStatus } from './types/StageStatus';
import type { UIDesignImageId } from './types/UIDesignImageId';
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
node: UiNode;
};
export type UiTreeNodeCursor = {
treeId: UIDesignImageId;
nodeId: NodeId;
};
export type StageStatusOverview = {
total: number;
needsAttention: number;
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
export function getNextUiTreeNodeTarget(
targets: UiTreeNodeTarget[],
previousNodeId: string | null,
previous: string | UiTreeNodeCursor | null,
): UiTreeNodeTarget | null {
if (targets.length === 0) return null;
const previousIndex = targets.findIndex(
({ node }) => node.id === previousNodeId,
);
let previousIndex = -1;
if (typeof previous === 'string') {
previousIndex = targets.findIndex(({ node }) => node.id === previous);
} else if (previous) {
previousIndex = targets.findIndex(
({ treeId, node }) =>
treeId === previous.treeId && node.id === previous.nodeId,
);
}
return targets[(previousIndex + 1) % targets.length] ?? null;
}
export function getNextMatchingUiTreeNodeTarget(
uiTrees: UITree[],
previousNodeId: string | null,
previous: string | UiTreeNodeCursor | null,
matches: (target: UiTreeNodeTarget) => boolean,
): UiTreeNodeTarget | null {
return getNextUiTreeNodeTarget(
collectUiTreeNodeTargets(uiTrees).filter(matches),
previousNodeId,
previous,
);
}
@@ -0,0 +1,53 @@
import type { Node } from './types/Node';
import type { State } from './types/State';
export type UiDesignInvariantIssue = {
code: 'duplicate-node' | 'invalid-image' | 'missing-tree-image';
message: string;
};
/** Frontend display/save projection of persistable State invariants.
* Rust remains authoritative; this seam only prevents obviously invalid saves
* and gives the view a stable, user-visible failure message.
*/
export function validateUiDesignState(state: State): UiDesignInvariantIssue[] {
const issues: UiDesignInvariantIssue[] = [];
const nodeIds = new Set<string>();
for (const [id, image] of Object.entries(state.ui_design_images)) {
if (
image.path.trim() === '' ||
!image.pixel_size.every((value) => Number.isFinite(value) && value > 0) ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
issues.push({
code: 'invalid-image',
message: `界面图 ${id} 的尺寸或路径无效`,
});
}
}
const visit = (node: Node) => {
if (nodeIds.has(node.id)) {
issues.push({
code: 'duplicate-node',
message: `节点 ID 重复:${node.id}`,
});
}
nodeIds.add(node.id);
for (const child of node.children) visit(child);
};
for (const tree of state.ui_trees) {
if (!state.ui_design_images[tree.src_ui_design]) {
issues.push({
code: 'missing-tree-image',
message: `UI 树引用了缺失界面图:${tree.src_ui_design}`,
});
}
visit(tree.root);
}
return issues;
}
export function firstUiDesignInvariantMessage(state: State): string | null {
return validateUiDesignState(state)[0]?.message ?? null;
}
@@ -0,0 +1,125 @@
import type { Component } from './types/Component';
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
import type { State } from './types/State';
import type { UIDesignImageId } from './types/UIDesignImageId';
export type StateTransitionFailure =
| 'missing'
| 'invalid'
| `invalid:${string}`;
export type StateTransitionResult =
| { ok: true; state: State }
| { ok: false; reason: StateTransitionFailure };
export type UiEditorCommand =
| {
type: 'set-tree-offset';
treeId: UIDesignImageId;
min: [number, number];
}
| {
type: 'set-node-metadata';
treeId: UIDesignImageId;
nodeId: NodeId;
patch: Partial<
Pick<
NodeMetadata,
| 'name'
| 'description'
| 'layout_status'
| 'component_status'
| 'allow_llm_edit_layout'
| 'allow_llm_edit_component'
>
>;
}
| {
type: 'set-node-component';
treeId: UIDesignImageId;
nodeId: NodeId;
component: Component | null;
};
function cloneState(state: State): State {
return structuredClone(state);
}
function findNode(node: Node, id: NodeId): Node | null {
if (node.id === id) return node;
for (const child of node.children) {
const found = findNode(child, id);
if (found) return found;
}
return null;
}
function imageLogicalSize(
state: State,
treeId: UIDesignImageId,
): [number, number] | null {
const image = state.ui_design_images[treeId];
if (
!image ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
return null;
}
const size: [number, number] = [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
];
return size.every((value) => Number.isFinite(value) && value > 0)
? size
: null;
}
/**
* React-free semantic State transition seam. The hook is an adapter that adds
* locking/history; callers provide a command and receive a complete next State
* or a typed failure, never a partially-mutated tree.
*/
export function applyUiEditorCommand(
current: State,
command: UiEditorCommand,
): StateTransitionResult {
const next = cloneState(current);
const tree = next.ui_trees.find(
(candidate) => candidate.src_ui_design === command.treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
if (command.type === 'set-tree-offset') {
if (!command.min.every(Number.isFinite))
return { ok: false, reason: 'invalid' };
const size = imageLogicalSize(next, command.treeId);
if (!size) return { ok: false, reason: 'invalid' };
tree.root.offset = {
min: [...command.min],
max: [command.min[0] + size[0], command.min[1] + size[1]],
};
return { ok: true, state: next };
}
const node = findNode(tree.root, command.nodeId);
if (!node) return { ok: false, reason: 'missing' };
if (command.type === 'set-node-component') {
node.component = structuredClone(command.component);
node.metadata.component_status = 'NoProblem';
return { ok: true, state: next };
}
if (command.patch.component_status !== undefined && node.component === null) {
const status = command.patch.component_status;
if (status !== 'NoProblem') return { ok: false, reason: 'invalid' };
}
const patch = Object.fromEntries(
Object.entries(command.patch).filter(([, value]) => value !== undefined),
) as Partial<NodeMetadata>;
node.metadata = { ...node.metadata, ...structuredClone(patch) };
return { ok: true, state: next };
}
@@ -4,5 +4,6 @@ import type { Component } from "./Component";
import type { ControlLayout } from "./ControlLayout";
import type { NodeId } from "./NodeId";
import type { NodeMetadata } from "./NodeMetadata";
import type { NodeOffset } from "./NodeOffset";
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, };
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, offset: NodeOffset };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NodeOffset = { min: [number, number], max: [number, number], };
@@ -10,6 +10,7 @@ import {
resolveReparentTransform,
} from './nodeTransformGeometry';
import { validateSpriteBorder } from './spriteBorder';
import { applyUiEditorCommand } from './stateTransition';
import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode';
import type { Component } from './types/Component';
import type { FontAsset } from './types/FontAsset';
@@ -17,6 +18,7 @@ import type { FontAssetId } from './types/FontAssetId';
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
import type { NodeOffset } from './types/NodeOffset';
import type { SpriteAsset } from './types/SpriteAsset';
import type { SpriteAssetId } from './types/SpriteAssetId';
import type { SpriteBorder } from './types/SpriteBorder';
@@ -34,6 +36,7 @@ export const EMPTY_UI_EDITOR_STATE: State = {
};
const MAX_HISTORY_LENGTH = 100;
export const UI_TREE_PADDING = 48;
export type UiEditorOperationFailureReason =
| 'locked'
@@ -104,12 +107,6 @@ function visitNodes(node: Node, visit: (node: Node) => void): void {
for (const child of node.children) visitNodes(child, visit);
}
function isProblematicComponentStatus(
status: NodeMetadata['component_status'],
): boolean {
return typeof status !== 'string';
}
function existingNodeIds(state: State): Set<NodeId> {
const ids = new Set<NodeId>();
for (const tree of state.ui_trees) {
@@ -156,6 +153,7 @@ function createPageRoot(state: State): Node {
component: null,
children_display_mode: 'Stack',
children: [],
offset: { min: [0, 0], max: [0, 0] },
};
}
@@ -187,9 +185,56 @@ function createHumanNode(state: State): Node {
component: null,
children_display_mode: 'Stack',
children: [],
offset: { min: [0, 0], max: [0, 0] },
};
}
function treeSize(state: State, treeId: UIDesignImageId): [number, number] {
const image = state.ui_design_images[treeId];
if (
!image ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
throw new Error(`界面图 ${treeId} 缺少合法尺寸`);
}
return [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
];
}
function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset {
const [width, height] = treeSize(state, treeId);
const existing = state.ui_trees.filter(
(tree) => tree.src_ui_design !== treeId,
);
if (existing.length === 0) return { min: [0, 0], max: [width, height] };
const maxX = Math.max(
...existing.map(
(tree) =>
(tree.root.offset?.min?.[0] ?? 0) +
treeSize(state, tree.src_ui_design)[0],
),
);
const minY = Math.min(
...existing.map((tree) => tree.root.offset?.min?.[1] ?? 0),
);
return {
min: [maxX + UI_TREE_PADDING, minY],
max: [maxX + UI_TREE_PADDING + width, minY + height],
};
}
export function createTree(
state: State,
treeId: UIDesignImageId,
root = createPageRoot(state),
) {
root.offset = deriveTreeOffset(state, treeId);
return { src_ui_design: treeId, root };
}
function synchronizeDesignImageTrees(state: State): void {
const imageIds = new Set(Object.keys(state.ui_design_images));
state.ui_trees = state.ui_trees.filter((tree) =>
@@ -197,7 +242,7 @@ function synchronizeDesignImageTrees(state: State): void {
);
for (const [id] of Object.entries(state.ui_design_images)) {
if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) {
state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) });
state.ui_trees.push(createTree(state, id));
}
}
}
@@ -951,6 +996,26 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
[commit, guard],
);
const setTreeOffset = useCallback(
(
treeId: UIDesignImageId,
min: [number, number],
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
if (!min.every(Number.isFinite)) return { ok: false, reason: 'invalid' };
const result = applyUiEditorCommand(stateRef.current, {
type: 'set-tree-offset',
treeId,
min,
});
if (!result.ok) return result;
commit(result.state);
return { ok: true, value: undefined };
},
[commit, guard],
);
const insertNodeAfter = useCallback(
(
treeId: UIDesignImageId,
@@ -1134,21 +1199,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
if (component && !isValidComponent(component)) {
return { ok: false, reason: 'invalid' };
}
const current = stateRef.current;
const tree = current.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
const location = findNodeLocation(tree.root, nodeId);
if (!location) return { ok: false, reason: 'missing' };
const next = cloneState(current);
const nextTree = next.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
)!;
const nextNode = findNodeLocation(nextTree.root, nodeId)!.node;
nextNode.component = structuredClone(component);
nextNode.metadata.component_status = 'NoProblem';
commit(next);
const result = applyUiEditorCommand(stateRef.current, {
type: 'set-node-component',
treeId,
nodeId,
component,
});
if (!result.ok) return result;
commit(result.state);
return { ok: true, value: undefined };
},
[commit, guard],
@@ -1162,38 +1220,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
const tree = current.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
if (!findNodeLocation(tree.root, nodeId))
return { ok: false, reason: 'missing' };
const next = cloneState(current);
const node = findNodeLocation(
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
.root,
const result = applyUiEditorCommand(stateRef.current, {
type: 'set-node-metadata',
treeId,
nodeId,
)!.node;
if (
patch.component_status !== undefined &&
node.component === null &&
isProblematicComponentStatus(patch.component_status)
) {
return { ok: false, reason: 'invalid' };
}
if (patch.name !== undefined) node.metadata.name = patch.name;
if (patch.description !== undefined)
node.metadata.description = patch.description;
if (patch.layout_status !== undefined)
node.metadata.layout_status = patch.layout_status;
if (patch.component_status !== undefined)
node.metadata.component_status = patch.component_status;
if (patch.allow_llm_edit_layout !== undefined)
node.metadata.allow_llm_edit_layout = patch.allow_llm_edit_layout;
if (patch.allow_llm_edit_component !== undefined)
node.metadata.allow_llm_edit_component = patch.allow_llm_edit_component;
commit(next);
patch,
});
if (!result.ok) return result;
commit(result.state);
return { ok: true, value: undefined };
},
[commit, guard],
@@ -1464,6 +1498,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
setSpriteBorder,
insertNode,
insertNodeAfter,
setTreeOffset,
deleteNode,
setNodeTransform,
setNodeLayout,
@@ -421,7 +421,9 @@ function ResourceBatchClassificationPanel({
setTagDraft('');
onSaved(result);
} catch (saveError) {
setError(resourceClassificationErrorMessage(saveError, '批量追加标签失败'));
setError(
resourceClassificationErrorMessage(saveError, '批量追加标签失败'),
);
} finally {
inFlightRef.current = false;
setSaving(false);
@@ -70,6 +70,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
component: null,
children_display_mode: 'Stack',
children: uiTrees.map((tree) => tree.root),
offset: { min: [0, 0], max: [0, 0] },
};
}, [uiTrees]);
@@ -82,9 +83,18 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
focusRequest={focusRequest}
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
isNodePreviewVisible={input.isNodePreviewVisible}
onSelectNode={(treeId, nodeId) => {
input.selectDesignImage(treeId);
input.selectNode(nodeId);
onSelectNode={(_treeId, nodeId) => {
// react-arborist emits `onSelect` when its controlled `selection`
// prop is updated. Overview navigation updates the selection and
// the status highlight in the same render, so treating that
// programmatic notification as a fresh user selection would clear
// the highlight before it can be painted. Only mutate selection
// state when the target actually differs from the current one.
const sameNode = input.selectedNodeId === nodeId;
if (!sameNode) {
input.selectDesignImage(_treeId);
input.selectNode(nodeId);
}
}}
onToggleNodeVisibility={(nodeId) =>
input.toggleNodePreviewVisibility(nodeId)

Some files were not shown because too many files have changed in this diff Show More