Compare commits

..

26 Commits

Author SHA1 Message Date
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
53 changed files with 1507 additions and 907 deletions
@@ -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",
@@ -1,8 +1,4 @@
use super::*;
use crate::ui_editor::persistence::{
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
@@ -137,35 +133,7 @@ fn validate_resource_reference_id(value: &str) -> Result<String, String> {
Ok(resource_id.to_string())
}
/// Render the prompt context for a UI design asset.
///
/// Keep this separate from the generic resource renderer so UI-specific
/// instructions/metadata can evolve without changing other asset kinds.
fn render_ui_design_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
asset: &GameCreationAppAssetManifestEntry,
resource_id: &str,
label: &str,
local_path: &str,
source: &str,
) -> String {
let context = match generate_ui_design_code_at(GenerateUiDesignCodeInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
asset_id: resource_id.to_string(),
}) {
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
Err(error) => format!("生成代码遇到错误{error}"),
};
format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
) + "\n" + &context
}
fn render_resource_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
@@ -181,19 +149,6 @@ fn render_resource_reference_line(
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
let is_ui_design =
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE;
if is_ui_design {
return Ok(render_ui_design_reference_line(
root,
manifest,
asset,
&resource_id,
&label,
&local_path,
&source,
));
}
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
@@ -279,7 +234,7 @@ pub(crate) fn render_direct_codex_references_section(
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(root, &manifest, reference)?
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
@@ -2144,10 +2144,7 @@ pub(crate) fn create_ui_design_resource(
let next_index = manifest
.assets
.iter()
.filter(|asset| {
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count()
+ 1;
let resource_name = format!("UI 设计 {next_index}");
@@ -2181,8 +2178,8 @@ pub(crate) fn create_ui_design_resource(
let asset = match register_local_asset_at(
root,
&relative_path,
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -2031,8 +2031,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let registered = register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2053,8 +2053,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2063,10 +2063,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(
manifest["assets"][0]["kind"],
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
);
assert_eq!(manifest["assets"][0]["kind"], "UI");
assert_eq!(manifest["assets"][0]["category"], "audio");
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
@@ -2077,7 +2074,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
///
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
#[test]
@@ -2102,8 +2099,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2136,11 +2133,7 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
assert_eq!(
categories,
vec![
(
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
.to_string(),
"ui-interaction".to_string(),
),
("UI".to_string(), "ui-interaction".to_string()),
("font".to_string(), "document".to_string()),
]
);
@@ -172,6 +172,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 +303,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,
@@ -9,6 +9,7 @@ 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};
@@ -322,6 +323,7 @@ fn convert_node(
component: source.component.clone().into_option(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
})
}
@@ -858,6 +860,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 {
@@ -265,11 +265,11 @@ pub fn inspect_separation_recovery(
}
pub fn finalize_separation(root: &Path, asset_id: &str) -> Result<(), String> {
remove_separation_recovery_files(root, asset_id)
remove_separation_state(root, asset_id)
}
pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> {
remove_separation_recovery_files(root, asset_id)
remove_separation_state(root, asset_id)
}
fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
@@ -281,33 +281,6 @@ fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
}
}
fn remove_separation_recovery_files(root: &Path, asset_id: &str) -> Result<(), String> {
let sidecar = separation_sidecar_dir(root, asset_id)?;
remove_separation_state(root, asset_id)?;
let entries = match fs::read_dir(&sidecar) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("读取 separation 临时文件失败:{error}")),
};
for entry in entries {
let entry = entry.map_err(|error| format!("读取 separation 临时文件失败:{error}"))?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("processed-") || name.starts_with("binding-") {
let path = entry.path();
if entry
.file_type()
.map_err(|error| format!("检查 separation 临时文件失败:{error}"))?
.is_file()
{
fs::remove_file(&path)
.map_err(|error| format!("删除 separation 临时文件失败:{error}"))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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,18 @@
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 {
pub min: [f32; 2],
pub max: [f32; 2],
}
impl Default for NodeOffset {
fn default() -> Self {
Self {
min: [0.0, 0.0],
max: [0.0, 0.0],
}
}
}
@@ -19,10 +19,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use typed_floats::tf32::StrictlyPositiveFinite;
const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1";
pub(crate) use shared_contracts::game_creation_app::{
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE,
};
const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024;
const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8;
const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
@@ -325,7 +321,7 @@ fn ui_design_asset(
.into_iter()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
}
normalize_relative_path(&asset.local_path)?;
@@ -819,8 +815,8 @@ mod tests {
let asset = register_local_asset_at(
directory.path(),
relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"test",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -1,7 +1,4 @@
use crate::ui_editor::persistence::{
initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at;
use crate::{
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
enforce_project_permission_policy, read_existing_manifest_for_project,
@@ -92,8 +89,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
}
if let Some(asset) = manifest.assets.iter().find(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
asset.kind == "UI"
&& asset.media_type == "application/json"
&& asset.source.reference_resource_ids.iter().any(|reference| {
source_reference_ids
.iter()
@@ -121,8 +118,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
let asset = match register_local_asset_at(
root,
&relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -196,9 +193,7 @@ fn next_ui_design_path(
let mut index = manifest
.assets
.iter()
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count()
+ 1;
loop {
@@ -286,9 +281,11 @@ mod tests {
};
let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge");
assert!(first.created);
assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND);
assert_eq!(first.asset.kind, "UI");
// 写侧 → 分类的端到端断言:这条路径走的是与
// 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量
// `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`
// 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现),
// 且派生值本身就是 unclassified,读时自愈也救不回来。
assert_eq!(
first.asset.category,
GameCreationAppAssetCategory::UiInteraction
@@ -325,10 +322,7 @@ mod tests {
.manifest
.assets
.iter()
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count(),
1
);
@@ -7,7 +7,6 @@ use crate::ui_editor::layout::node::{Node, StageStatus};
use crate::ui_editor::persistence::{
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult,
UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::ui_editor::resource::font::FontAsset;
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
@@ -684,8 +683,8 @@ fn find_page_ui_resource(
let Some(asset) = matches.into_iter().next() else {
return Ok(None);
};
if asset.kind != UI_DESIGN_DOC_ASSET_KIND
|| asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE
if asset.kind != "UI"
|| asset.media_type != "application/json"
|| asset.local_path != workflow_relative_path(source, &page.page_id)
{
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
@@ -749,8 +748,8 @@ fn ensure_page_ui_resource(
let registered = register_local_asset_at(
root,
&relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -1180,7 +1179,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
.iter_mut()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?;
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
}
let next_kind = format!("ui-workflow.{stage}");
@@ -53,7 +53,6 @@ import { createPortal, flushSync } from 'react-dom';
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
type GameIterationVersion,
@@ -1254,10 +1253,7 @@ function ResourcePickerThumbnail({
if (mediaType.startsWith('image/')) {
return <Loader2 size={18} className="animate-spin" aria-hidden="true" />;
}
if (
kind === 'ui' ||
mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
) {
if (kind === 'ui' || mediaType === 'application/json') {
return <FileText size={18} aria-hidden="true" />;
}
return <ImageIcon size={18} aria-hidden="true" />;
@@ -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,
);
const previousIndex =
typeof previous === 'string'
? targets.findIndex(({ node }) => node.id === previous)
: previous
? targets.findIndex(
({ treeId, node }) =>
treeId === previous.treeId && node.id === previous.nodeId,
)
: -1;
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,
);
}
@@ -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,4 @@
export type NodeOffset = {
min: [number, number];
max: [number, number];
};
@@ -2,10 +2,6 @@ import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type UiDesignResourceBridgeResult = {
asset: GameCreationAppAssetManifestEntry;
@@ -52,8 +48,8 @@ export function findLinkedUiDesignResource(
manifest.assets
.filter(
(asset) =>
asset.kind === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
asset.kind === 'UI' &&
asset.mediaType === 'application/json' &&
asset.source.referenceResourceIds?.some((reference) =>
referenceIds.has(reference),
),
@@ -17,6 +17,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 +35,7 @@ export const EMPTY_UI_EDITOR_STATE: State = {
};
const MAX_HISTORY_LENGTH = 100;
export const UI_TREE_PADDING = 48;
export type UiEditorOperationFailureReason =
| 'locked'
@@ -156,6 +158,7 @@ function createPageRoot(state: State): Node {
component: null,
children_display_mode: 'Stack',
children: [],
offset: { min: [0, 0], max: [0, 0] },
};
}
@@ -187,9 +190,53 @@ 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] + treeSize(state, tree.src_ui_design)[0],
),
);
const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1]));
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 +244,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 +998,34 @@ 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 current = stateRef.current;
const tree = current.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
const size = treeSize(current, treeId);
const next = cloneState(current);
const nextTree = next.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
)!;
nextTree.root.offset = {
min: [...min],
max: [min[0] + size[0], min[1] + size[1]],
};
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const insertNodeAfter = useCallback(
(
treeId: UIDesignImageId,
@@ -1464,6 +1539,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
setSpriteBorder,
insertNode,
insertNodeAfter,
setTreeOffset,
deleteNode,
setNodeTransform,
setNodeLayout,
@@ -73,10 +73,6 @@ import type {
GameIterationVersion,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView';
import type {
CanvasLayer,
@@ -4637,12 +4633,7 @@ export default function ProjectDevelopmentView({
passive: false,
});
return () => manager.removeEventListener('wheel', handleResourceBookWheel);
}, [
handleResourceBookWheel,
mode,
// UI 编辑器会卸载整个资源 manager;返回时 ref 指向新节点,必须重新绑定原生 wheel。
uiEditorRoute,
]);
}, [handleResourceBookWheel, mode]);
const handleResourceBookMainPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -5044,8 +5035,8 @@ export default function ProjectDevelopmentView({
if (canvasOpenEpochRef.current !== openEpoch) return;
if (
result.manifest.projectId !== manifest.projectId ||
result.asset.kind !== GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
result.asset.mediaType !== GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
result.asset.kind !== 'UI' ||
result.asset.mediaType !== 'application/json'
) {
throw new Error('UI 编辑资源结果与当前项目不一致');
}
@@ -5116,8 +5107,8 @@ export default function ProjectDevelopmentView({
if (uiEditorRoute) return;
const completed = manifest.assets.find(
(asset) =>
asset.kind === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
asset.kind === 'UI' &&
asset.mediaType === 'application/json' &&
asset.source.generationKind === 'ui-workflow.completed',
);
if (
@@ -6816,7 +6807,7 @@ export default function ProjectDevelopmentView({
[manifest, selectedResource],
);
const selectedResourceOpensUiEditor =
selectedResource?.subtype === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
selectedResource?.subtype === 'UI' ||
selectedResource?.subtype === 'ui-prototype';
const selectedToolbarStyle = selectedResourceLayer
@@ -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]);
@@ -83,8 +84,14 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
isNodePreviewVisible={input.isNodePreviewVisible}
onSelectNode={(treeId, nodeId) => {
input.selectDesignImage(treeId);
input.selectNode(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.selectNode(nodeId);
}}
onToggleNodeVisibility={(nodeId) =>
input.toggleNodePreviewVisibility(nodeId)
@@ -284,7 +284,7 @@ function NodeInspector({
onMetadataChange,
highlightedStatusField,
onTransformChange,
onLayoutChange,
// onLayoutChange,
sprites,
previewUrls,
fonts,
@@ -483,11 +483,11 @@ function NodeInspector({
readOnly={transformReadOnly || isReadOnly}
onChange={onTransformChange}
/>
<LayoutEditor
node={node}
readOnly={isReadOnly}
onChange={onLayoutChange}
/>
{/*<LayoutEditor*/}
{/* node={node}*/}
{/* readOnly={isReadOnly}*/}
{/* onChange={onLayoutChange}*/}
{/*/>*/}
<ComponentPanel
key={node.id}
component={node.component}
@@ -511,6 +511,7 @@ function NodeInspector({
);
}
// eslint-disable-next-line unused-imports/no-unused-vars
function LayoutEditor({
node,
readOnly,
@@ -826,11 +827,26 @@ function NodeStageSelect({
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
useEffect(() => {
if (!highlight) return;
statusRowRef.current?.scrollIntoView({
const statusRow = statusRowRef.current;
if (!highlight || !statusRow) return;
statusRow.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
// 强制制造一次样式边界,避免 A → B → A 时浏览器复用已完成的动画。
statusRow.classList.remove('ui-editor-status-attention');
void statusRow.offsetWidth;
statusRow.classList.add('ui-editor-status-attention');
// 多节点切换会复用 Inspector 树,显式重启动画,确保回到已查看节点时
// 仍能再次播放提示,而不是依赖 class/key 的重协调行为。
if (typeof statusRow.getAnimations === 'function') {
for (const animation of statusRow.getAnimations()) {
animation.cancel();
animation.play();
}
}
}, [highlight]);
return (
@@ -44,7 +44,7 @@ export function RecognitionOverview({
Overview
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mt-3 grid grid-cols-4 gap-2">
<OverviewValue label="已识别 Node" value={overview.total} />
<OverviewAction
label="待用户检查"
@@ -47,7 +47,7 @@ export function SeparationOverview({
Overview
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mt-3 grid grid-cols-4 gap-2">
<OverviewValue
label="需要切分素材的组件"
value={overview.componentsNeedingAssets}
@@ -0,0 +1,44 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Copy } from 'lucide-react';
import { useEffect, useState } from 'react';
export function UiEditorCopyPathButton({
relativePath,
}: {
relativePath: string;
}) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
useEffect(() => setCopyState('idle'), [relativePath]);
async function copyPath() {
setCopyState('idle');
try {
await writeText(relativePath);
setCopyState('copied');
} catch (error) {
setCopyState('failed');
throw error;
}
}
return (
<>
{copyState === 'failed' ? (
<p className="mt-2 text-xs text-red-600" role="alert">
</p>
) : null}
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={() => void copyPath()}
>
<Copy size={14} aria-hidden="true" />
{copyState === 'copied' ? '已复制' : '复制路径'}
</button>
</>
);
}

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