Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c07f9d12 | |||
| 0904184412 | |||
| aaa2eb482a | |||
| 1679e72deb | |||
| 4844543061 | |||
| 99132c9fa7 | |||
| 944d2aa3db | |||
| 7c16a35fda | |||
| e4369cd82e | |||
| 54b9bd4354 | |||
| 365e530b5f | |||
| d46cdfb282 | |||
| 2142027f85 | |||
| 8fdf39f37e | |||
| 70c65c37d5 | |||
| e72637789c | |||
| 0a102ea1c3 | |||
| 1ab7fa9991 | |||
| 6d02a0c29f | |||
| a6c3db60fb | |||
| c921ae3b16 | |||
| 31c084fce6 | |||
| 1e7a3f17ff | |||
| 3ce2977e32 | |||
| f4e9414d63 | |||
| 2d8b51885a | |||
| 91f534ed66 | |||
| f455d3edb2 | |||
| bec225436f | |||
| ce309a3b28 | |||
| abd95a6231 | |||
| 08c3b89b67 | |||
| 3371337346 | |||
| 899a591cda | |||
| 0988c4907a | |||
| d370648867 | |||
| 4f4fb0873c | |||
| 6b8a106414 | |||
| 55a8513de4 | |||
| d986dbaeb0 | |||
| 16c6b9198e | |||
| 4df4708ba1 | |||
| 1e992bcdf8 | |||
| 8368aa262c | |||
| e9ddbf16da | |||
| 22ac1f4c0b | |||
| 9910a0eec0 | |||
| 70981b9ca9 | |||
| 8a0d5600b3 | |||
| 184d88dbb8 | |||
| 791794c0a6 | |||
| 9cfb47d945 | |||
| 27859d3e19 | |||
| 43f3780a38 | |||
| a1f9149c27 | |||
| f0bba18841 | |||
| f679cfa179 | |||
| f68ffcfecd | |||
| ec94a4fe00 | |||
| 46f8592375 | |||
| cdf2882302 | |||
| d156113e71 | |||
| a8f2ac17be | |||
| 43ac9a5761 | |||
| e992e0b35c | |||
| e389b54a3a | |||
| 84c780a6f8 | |||
| 03f900fbe5 |
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "main",
|
"identifier": "main",
|
||||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
"description": "AI 游戏创作主窗口允许读写系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||||
"windows": ["client"],
|
"windows": ["client"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"clipboard-manager:allow-read-image",
|
"clipboard-manager:allow-read-image",
|
||||||
"clipboard-manager:allow-read-text",
|
"clipboard-manager:allow-read-text",
|
||||||
|
"clipboard-manager:allow-write-text",
|
||||||
"core:image:allow-rgba",
|
"core:image:allow-rgba",
|
||||||
"core:image:allow-size",
|
"core:image:allow-size",
|
||||||
"core:resources:allow-close",
|
"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::config::load_game_creator_app_config;
|
||||||
use crate::ui_editor::commands::utils::{
|
use crate::ui_editor::commands::utils::{
|
||||||
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
|
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::text::FontSource;
|
||||||
use crate::ui_editor::component::{Component, NodeComponent};
|
use crate::ui_editor::component::{Component, NodeComponent};
|
||||||
@@ -406,12 +406,8 @@ pub(crate) async fn bind_components_impl_with_provider(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
.map_err(|error| format!("组件绑定失败:{error}"))?;
|
.map_err(|error| format!("组件绑定失败:{error}"))?;
|
||||||
let call = response
|
let arguments = required_tool_call_arguments(&response, "bind_ui_components")?;
|
||||||
.tool_calls
|
let parsed = parse_binding_response(arguments, editable_nodes.len())?;
|
||||||
.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 known_sprite_ids = state.sprite_assets.keys().cloned().collect::<HashSet<_>>();
|
let known_sprite_ids = state.sprite_assets.keys().cloned().collect::<HashSet<_>>();
|
||||||
let known_font_ids = state.font_assets.keys().cloned().collect::<HashSet<_>>();
|
let known_font_ids = state.font_assets.keys().cloned().collect::<HashSet<_>>();
|
||||||
let result = validate_and_materialize(
|
let result = validate_and_materialize(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use crate::config::build_game_creator_llm_client_from_llm_config;
|
use crate::config::build_game_creator_llm_client_from_llm_config;
|
||||||
use crate::config::load_game_creator_app_config;
|
use crate::config::load_game_creator_app_config;
|
||||||
use crate::ui_editor::commands::utils::{
|
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 crate::ui_editor::state::{State, UITree};
|
||||||
use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice};
|
use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice};
|
||||||
@@ -172,6 +173,7 @@ mod materialize {
|
|||||||
use crate::ui_editor::layout::node::{
|
use crate::ui_editor::layout::node::{
|
||||||
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
|
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
|
||||||
};
|
};
|
||||||
|
use crate::ui_editor::layout::offset::NodeOffset;
|
||||||
use crate::ui_editor::layout::transform::Transform;
|
use crate::ui_editor::layout::transform::Transform;
|
||||||
use crate::ui_editor::state::{State, UITree};
|
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, NodeId, UIDesignImageId};
|
||||||
@@ -302,6 +304,7 @@ mod materialize {
|
|||||||
component: None,
|
component: None,
|
||||||
children_display_mode: ChildrenDisplayMode::Exclusive,
|
children_display_mode: ChildrenDisplayMode::Exclusive,
|
||||||
children: members.into_iter().map(|member| member.node).collect(),
|
children: members.into_iter().map(|member| member.node).collect(),
|
||||||
|
offset: NodeOffset::default(),
|
||||||
},
|
},
|
||||||
priority,
|
priority,
|
||||||
src_ui_design,
|
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}");
|
app_log!("ui_merge.error stage=llm_request error={error}");
|
||||||
format!("UI 树合并失败:{error}")
|
format!("UI 树合并失败:{error}")
|
||||||
})?;
|
})?;
|
||||||
let call = response
|
let arguments = required_tool_call_arguments(&response, MERGE_TOOL_NAME).map_err(|error| {
|
||||||
.tool_calls
|
app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call error={error}");
|
||||||
.iter()
|
format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用")
|
||||||
.find(|call| call.name == MERGE_TOOL_NAME)
|
})?;
|
||||||
.ok_or_else(|| {
|
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
|
||||||
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| {
|
|
||||||
app_log!("ui_merge.error stage=parse_arguments error={error}");
|
app_log!("ui_merge.error stage=parse_arguments error={error}");
|
||||||
format!("UI 合并工具参数无效:{error}")
|
format!("UI 合并工具参数无效:{error}")
|
||||||
})?;
|
})?;
|
||||||
@@ -527,6 +526,7 @@ mod tests {
|
|||||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||||
use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus};
|
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::layout::transform::Transform;
|
||||||
use crate::ui_editor::state::{State, UITree};
|
use crate::ui_editor::state::{State, UITree};
|
||||||
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
||||||
@@ -548,6 +548,7 @@ mod tests {
|
|||||||
component: None,
|
component: None,
|
||||||
children_display_mode: ChildrenDisplayMode::Stack,
|
children_display_mode: ChildrenDisplayMode::Stack,
|
||||||
children,
|
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::config::load_game_creator_app_config;
|
||||||
use crate::ui_editor::commands::utils::{
|
use crate::ui_editor::commands::utils::{
|
||||||
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
|
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::component::{Component, NodeComponent};
|
||||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||||
use crate::ui_editor::layout::dimension::UIRect;
|
use crate::ui_editor::layout::dimension::UIRect;
|
||||||
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
|
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::layout::transform::Transform;
|
||||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||||
use crate::ui_editor::state::{State, UITree};
|
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 nalgebra::{Point2, Vector2};
|
||||||
use platform_llm::{
|
use platform_llm::{
|
||||||
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
|
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
|
||||||
@@ -322,6 +323,7 @@ fn convert_node(
|
|||||||
component: source.component.clone().into_option(),
|
component: source.component.clone().into_option(),
|
||||||
children_display_mode: ChildrenDisplayMode::Stack,
|
children_display_mode: ChildrenDisplayMode::Stack,
|
||||||
children,
|
children,
|
||||||
|
offset: NodeOffset::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,21 +770,18 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
|||||||
!response.text.trim().is_empty(),
|
!response.text.trim().is_empty(),
|
||||||
response.tool_calls.len()
|
response.tool_calls.len()
|
||||||
);
|
);
|
||||||
let call = response
|
let arguments = required_tool_call_arguments(&response, "recognize_ui_structure")
|
||||||
.tool_calls
|
.map_err(|error| {
|
||||||
.iter()
|
|
||||||
.find(|call| call.name == "recognize_ui_structure")
|
|
||||||
.ok_or_else(|| {
|
|
||||||
app_log!(
|
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()
|
root_id.as_str()
|
||||||
);
|
);
|
||||||
format!(
|
format!(
|
||||||
"根界面图 {} 的 LLM 未返回 recognize_ui_structure 工具调用",
|
"LLM 未返回 recognize_ui_structure 工具调用(根界面图 {})",
|
||||||
root_id.as_str()
|
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!(
|
app_log!(
|
||||||
"ui_recognition.error stage=parse_arguments root={} error={error}",
|
"ui_recognition.error stage=parse_arguments root={} error={error}",
|
||||||
root_id.as_str()
|
root_id.as_str()
|
||||||
@@ -858,6 +857,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
|||||||
component: recognition_root.component.into_option(),
|
component: recognition_root.component.into_option(),
|
||||||
children_display_mode: ChildrenDisplayMode::Stack,
|
children_display_mode: ChildrenDisplayMode::Stack,
|
||||||
children,
|
children,
|
||||||
|
offset: NodeOffset::default(),
|
||||||
};
|
};
|
||||||
// Tree identity and root identity are assigned by Rust, never chosen by the model.
|
// Tree identity and root identity are assigned by Rust, never chosen by the model.
|
||||||
ui_trees.push(UITree {
|
ui_trees.push(UITree {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod tests {
|
|||||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||||
use crate::ui_editor::layout::node::Node;
|
use crate::ui_editor::layout::node::Node;
|
||||||
use crate::ui_editor::layout::node::{NodeMetadata, NodeSource, StageStatus};
|
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::resource::ui_design_image::UIDesignImage;
|
||||||
use crate::ui_editor::state::{State, UITree};
|
use crate::ui_editor::state::{State, UITree};
|
||||||
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
||||||
@@ -46,6 +47,7 @@ mod tests {
|
|||||||
component,
|
component,
|
||||||
children_display_mode: ChildrenDisplayMode::Stack,
|
children_display_mode: ChildrenDisplayMode::Stack,
|
||||||
children,
|
children,
|
||||||
|
offset: NodeOffset::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn state(root: Node) -> State {
|
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::config::load_game_creator_app_config;
|
||||||
use crate::ui_editor::commands::utils::{
|
use crate::ui_editor::commands::utils::{
|
||||||
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
|
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::resource::ui_design_image::UIDesignImageRole;
|
||||||
use crate::ui_editor::state::State;
|
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.text.trim().is_empty(),
|
||||||
response.tool_calls.len()
|
response.tool_calls.len()
|
||||||
);
|
);
|
||||||
let call = response
|
let arguments = required_tool_call_arguments(&response, "suggest_ui_design_semantics")
|
||||||
.tool_calls
|
.map_err(|error| {
|
||||||
.iter()
|
app_log!(
|
||||||
.find(|call| call.name == "suggest_ui_design_semantics")
|
"ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call error={error}"
|
||||||
.ok_or_else(|| {
|
);
|
||||||
app_log!("ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call");
|
|
||||||
"LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string()
|
"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}");
|
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
|
||||||
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{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}"))
|
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> {
|
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))
|
tokio::task::spawn_blocking(move || read_ui_reference_image_data_url_blocking(&path))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ pub mod children_display_mode;
|
|||||||
pub mod control_layout;
|
pub mod control_layout;
|
||||||
pub mod dimension;
|
pub mod dimension;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
|
pub mod offset;
|
||||||
pub mod transform;
|
pub mod transform;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::ui_editor::component::Component;
|
use crate::ui_editor::component::Component;
|
||||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||||
|
use crate::ui_editor::layout::offset::NodeOffset;
|
||||||
use crate::ui_editor::utils::NodeId;
|
use crate::ui_editor::utils::NodeId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
@@ -14,6 +15,7 @@ pub struct Node {
|
|||||||
pub component: Option<Component>,
|
pub component: Option<Component>,
|
||||||
pub children_display_mode: ChildrenDisplayMode,
|
pub children_display_mode: ChildrenDisplayMode,
|
||||||
pub children: Vec<Node>,
|
pub children: Vec<Node>,
|
||||||
|
pub offset: NodeOffset,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
#[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_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": {
|
"ui_design_images": {
|
||||||
@@ -1309,7 +1311,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"component": null,
|
"component": null,
|
||||||
"children_display_mode": "Stack",
|
"children_display_mode": "Stack",
|
||||||
"children": []
|
"children": [],
|
||||||
|
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
"ui_design_images": {
|
"ui_design_images": {
|
||||||
@@ -1543,7 +1546,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"children_display_mode": "Stack",
|
"children_display_mode": "Stack",
|
||||||
"children": []
|
"children": [],
|
||||||
|
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
"ui_design_images": {
|
"ui_design_images": {
|
||||||
|
|||||||
+5
-1
@@ -78,7 +78,11 @@ export function ResourceCanvasGenerationPlaceholderCardView({
|
|||||||
<Sparkles size={18} aria-hidden="true" />
|
<Sparkles size={18} aria-hidden="true" />
|
||||||
<strong>{placeholder.assetName}</strong>
|
<strong>{placeholder.assetName}</strong>
|
||||||
<small>
|
<small>
|
||||||
{RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[placeholder.status]}
|
{
|
||||||
|
RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[
|
||||||
|
placeholder.status
|
||||||
|
]
|
||||||
|
}
|
||||||
</small>
|
</small>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+1
-3
@@ -155,9 +155,7 @@ export function createResourceCanvasAssetGenerationQueue(
|
|||||||
referenceAssetIds: task.referenceAssetIds,
|
referenceAssetIds: task.referenceAssetIds,
|
||||||
outputPath: task.outputPath,
|
outputPath: task.outputPath,
|
||||||
// 入口栏目:原生支持时按它登记归类;不支持时后端忽略,落点仍按正式归类走。
|
// 入口栏目:原生支持时按它登记归类;不支持时后端忽略,落点仍按正式归类走。
|
||||||
...(task.targetCategory
|
...(task.targetCategory ? { targetCategory: task.targetCategory } : {}),
|
||||||
? { targetCategory: task.targetCategory }
|
|
||||||
: {}),
|
|
||||||
})) as LocalProjectAssetGenerationTaskRecord;
|
})) as LocalProjectAssetGenerationTaskRecord;
|
||||||
let started: LocalProjectAssetGenerationTaskRecord;
|
let started: LocalProjectAssetGenerationTaskRecord;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+9
-3
@@ -175,7 +175,9 @@ export function resourceCanvasGenerationPlaceholdersForProject(
|
|||||||
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
|
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): ResourceCanvasGenerationPlaceholder[] {
|
): ResourceCanvasGenerationPlaceholder[] {
|
||||||
return placeholders.filter((placeholder) => placeholder.projectId === projectId);
|
return placeholders.filter(
|
||||||
|
(placeholder) => placeholder.projectId === projectId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按任务找回占位:成功落点与失败收口都以 taskId 为准,不按素材名猜。 */
|
/** 按任务找回占位:成功落点与失败收口都以 taskId 为准,不按素材名猜。 */
|
||||||
@@ -183,14 +185,18 @@ export function resourceCanvasGenerationPlaceholderByTaskId(
|
|||||||
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
|
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
|
||||||
taskId: string,
|
taskId: string,
|
||||||
): ResourceCanvasGenerationPlaceholder | null {
|
): ResourceCanvasGenerationPlaceholder | null {
|
||||||
return placeholders.find((placeholder) => placeholder.taskId === taskId) ?? null;
|
return (
|
||||||
|
placeholders.find((placeholder) => placeholder.taskId === taskId) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resourceCanvasGenerationPlaceholderByDraftId(
|
export function resourceCanvasGenerationPlaceholderByDraftId(
|
||||||
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
|
placeholders: readonly ResourceCanvasGenerationPlaceholder[],
|
||||||
draftId: string,
|
draftId: string,
|
||||||
): ResourceCanvasGenerationPlaceholder | null {
|
): 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<
|
export const RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS: Record<
|
||||||
|
|||||||
+6
-3
@@ -208,8 +208,10 @@ export function useResourceCanvasGenerationPlaceholders({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const nextX = drag.startX + (event.clientX - drag.startClientX) / drag.scale;
|
const nextX =
|
||||||
const nextY = drag.startY + (event.clientY - drag.startClientY) / drag.scale;
|
drag.startX + (event.clientX - drag.startClientX) / drag.scale;
|
||||||
|
const nextY =
|
||||||
|
drag.startY + (event.clientY - drag.startClientY) / drag.scale;
|
||||||
drag.changed = true;
|
drag.changed = true;
|
||||||
move(drag.draftId, nextX, nextY);
|
move(drag.draftId, nextX, nextY);
|
||||||
},
|
},
|
||||||
@@ -233,7 +235,8 @@ export function useResourceCanvasGenerationPlaceholders({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const projectPlaceholders = useMemo(
|
const projectPlaceholders = useMemo(
|
||||||
() => resourceCanvasGenerationPlaceholdersForProject(placeholders, projectId),
|
() =>
|
||||||
|
resourceCanvasGenerationPlaceholdersForProject(placeholders, projectId),
|
||||||
[placeholders, projectId],
|
[placeholders, projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Node } from './types/Node';
|
import type { Node } from './types/Node';
|
||||||
import type { NodeId } from './types/NodeId';
|
import type { NodeId } from './types/NodeId';
|
||||||
|
import type { State } from './types/State';
|
||||||
import type { Transform } from './types/Transform';
|
import type { Transform } from './types/Transform';
|
||||||
|
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||||
|
|
||||||
export type ResizeAxis = 'horizontal' | 'vertical';
|
export type ResizeAxis = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
@@ -17,6 +19,32 @@ export type NodePageContext = {
|
|||||||
parentRect: PageRect;
|
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;
|
const MIN_NODE_SIZE = 1;
|
||||||
|
|
||||||
export function pageRectFromSize(size: [number, number]): PageRect {
|
export function pageRectFromSize(size: [number, number]): PageRect {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Node as UiNode } from './types/Node';
|
import type { Node as UiNode } from './types/Node';
|
||||||
|
import type { NodeId } from './types/NodeId';
|
||||||
import type { NodeMetadata } from './types/NodeMetadata';
|
import type { NodeMetadata } from './types/NodeMetadata';
|
||||||
import type { StageStatus } from './types/StageStatus';
|
import type { StageStatus } from './types/StageStatus';
|
||||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||||
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
|
|||||||
node: UiNode;
|
node: UiNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UiTreeNodeCursor = {
|
||||||
|
treeId: UIDesignImageId;
|
||||||
|
nodeId: NodeId;
|
||||||
|
};
|
||||||
|
|
||||||
export type StageStatusOverview = {
|
export type StageStatusOverview = {
|
||||||
total: number;
|
total: number;
|
||||||
needsAttention: number;
|
needsAttention: number;
|
||||||
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
|
|||||||
|
|
||||||
export function getNextUiTreeNodeTarget(
|
export function getNextUiTreeNodeTarget(
|
||||||
targets: UiTreeNodeTarget[],
|
targets: UiTreeNodeTarget[],
|
||||||
previousNodeId: string | null,
|
previous: string | UiTreeNodeCursor | null,
|
||||||
): UiTreeNodeTarget | null {
|
): UiTreeNodeTarget | null {
|
||||||
if (targets.length === 0) return null;
|
if (targets.length === 0) return null;
|
||||||
const previousIndex = targets.findIndex(
|
let previousIndex = -1;
|
||||||
({ node }) => node.id === previousNodeId,
|
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;
|
return targets[(previousIndex + 1) % targets.length] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNextMatchingUiTreeNodeTarget(
|
export function getNextMatchingUiTreeNodeTarget(
|
||||||
uiTrees: UITree[],
|
uiTrees: UITree[],
|
||||||
previousNodeId: string | null,
|
previous: string | UiTreeNodeCursor | null,
|
||||||
matches: (target: UiTreeNodeTarget) => boolean,
|
matches: (target: UiTreeNodeTarget) => boolean,
|
||||||
): UiTreeNodeTarget | null {
|
): UiTreeNodeTarget | null {
|
||||||
return getNextUiTreeNodeTarget(
|
return getNextUiTreeNodeTarget(
|
||||||
collectUiTreeNodeTargets(uiTrees).filter(matches),
|
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 { ControlLayout } from "./ControlLayout";
|
||||||
import type { NodeId } from "./NodeId";
|
import type { NodeId } from "./NodeId";
|
||||||
import type { NodeMetadata } from "./NodeMetadata";
|
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,
|
resolveReparentTransform,
|
||||||
} from './nodeTransformGeometry';
|
} from './nodeTransformGeometry';
|
||||||
import { validateSpriteBorder } from './spriteBorder';
|
import { validateSpriteBorder } from './spriteBorder';
|
||||||
|
import { applyUiEditorCommand } from './stateTransition';
|
||||||
import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode';
|
import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode';
|
||||||
import type { Component } from './types/Component';
|
import type { Component } from './types/Component';
|
||||||
import type { FontAsset } from './types/FontAsset';
|
import type { FontAsset } from './types/FontAsset';
|
||||||
@@ -17,6 +18,7 @@ import type { FontAssetId } from './types/FontAssetId';
|
|||||||
import type { Node } from './types/Node';
|
import type { Node } from './types/Node';
|
||||||
import type { NodeId } from './types/NodeId';
|
import type { NodeId } from './types/NodeId';
|
||||||
import type { NodeMetadata } from './types/NodeMetadata';
|
import type { NodeMetadata } from './types/NodeMetadata';
|
||||||
|
import type { NodeOffset } from './types/NodeOffset';
|
||||||
import type { SpriteAsset } from './types/SpriteAsset';
|
import type { SpriteAsset } from './types/SpriteAsset';
|
||||||
import type { SpriteAssetId } from './types/SpriteAssetId';
|
import type { SpriteAssetId } from './types/SpriteAssetId';
|
||||||
import type { SpriteBorder } from './types/SpriteBorder';
|
import type { SpriteBorder } from './types/SpriteBorder';
|
||||||
@@ -34,6 +36,7 @@ export const EMPTY_UI_EDITOR_STATE: State = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_HISTORY_LENGTH = 100;
|
const MAX_HISTORY_LENGTH = 100;
|
||||||
|
export const UI_TREE_PADDING = 48;
|
||||||
|
|
||||||
export type UiEditorOperationFailureReason =
|
export type UiEditorOperationFailureReason =
|
||||||
| 'locked'
|
| 'locked'
|
||||||
@@ -104,12 +107,6 @@ function visitNodes(node: Node, visit: (node: Node) => void): void {
|
|||||||
for (const child of node.children) visitNodes(child, visit);
|
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> {
|
function existingNodeIds(state: State): Set<NodeId> {
|
||||||
const ids = new Set<NodeId>();
|
const ids = new Set<NodeId>();
|
||||||
for (const tree of state.ui_trees) {
|
for (const tree of state.ui_trees) {
|
||||||
@@ -156,6 +153,7 @@ function createPageRoot(state: State): Node {
|
|||||||
component: null,
|
component: null,
|
||||||
children_display_mode: 'Stack',
|
children_display_mode: 'Stack',
|
||||||
children: [],
|
children: [],
|
||||||
|
offset: { min: [0, 0], max: [0, 0] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,9 +185,56 @@ function createHumanNode(state: State): Node {
|
|||||||
component: null,
|
component: null,
|
||||||
children_display_mode: 'Stack',
|
children_display_mode: 'Stack',
|
||||||
children: [],
|
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 {
|
function synchronizeDesignImageTrees(state: State): void {
|
||||||
const imageIds = new Set(Object.keys(state.ui_design_images));
|
const imageIds = new Set(Object.keys(state.ui_design_images));
|
||||||
state.ui_trees = state.ui_trees.filter((tree) =>
|
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)) {
|
for (const [id] of Object.entries(state.ui_design_images)) {
|
||||||
if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) {
|
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],
|
[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(
|
const insertNodeAfter = useCallback(
|
||||||
(
|
(
|
||||||
treeId: UIDesignImageId,
|
treeId: UIDesignImageId,
|
||||||
@@ -1134,21 +1199,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
|||||||
if (component && !isValidComponent(component)) {
|
if (component && !isValidComponent(component)) {
|
||||||
return { ok: false, reason: 'invalid' };
|
return { ok: false, reason: 'invalid' };
|
||||||
}
|
}
|
||||||
const current = stateRef.current;
|
const result = applyUiEditorCommand(stateRef.current, {
|
||||||
const tree = current.ui_trees.find(
|
type: 'set-node-component',
|
||||||
(candidate) => candidate.src_ui_design === treeId,
|
treeId,
|
||||||
);
|
nodeId,
|
||||||
if (!tree) return { ok: false, reason: 'missing' };
|
component,
|
||||||
const location = findNodeLocation(tree.root, nodeId);
|
});
|
||||||
if (!location) return { ok: false, reason: 'missing' };
|
if (!result.ok) return result;
|
||||||
const next = cloneState(current);
|
commit(result.state);
|
||||||
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);
|
|
||||||
return { ok: true, value: undefined };
|
return { ok: true, value: undefined };
|
||||||
},
|
},
|
||||||
[commit, guard],
|
[commit, guard],
|
||||||
@@ -1162,38 +1220,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
|||||||
): UiEditorOperationResult => {
|
): UiEditorOperationResult => {
|
||||||
const blocked = guard();
|
const blocked = guard();
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const current = stateRef.current;
|
const result = applyUiEditorCommand(stateRef.current, {
|
||||||
const tree = current.ui_trees.find(
|
type: 'set-node-metadata',
|
||||||
(candidate) => candidate.src_ui_design === treeId,
|
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,
|
|
||||||
nodeId,
|
nodeId,
|
||||||
)!.node;
|
patch,
|
||||||
if (
|
});
|
||||||
patch.component_status !== undefined &&
|
if (!result.ok) return result;
|
||||||
node.component === null &&
|
commit(result.state);
|
||||||
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);
|
|
||||||
return { ok: true, value: undefined };
|
return { ok: true, value: undefined };
|
||||||
},
|
},
|
||||||
[commit, guard],
|
[commit, guard],
|
||||||
@@ -1464,6 +1498,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
|||||||
setSpriteBorder,
|
setSpriteBorder,
|
||||||
insertNode,
|
insertNode,
|
||||||
insertNodeAfter,
|
insertNodeAfter,
|
||||||
|
setTreeOffset,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
setNodeTransform,
|
setNodeTransform,
|
||||||
setNodeLayout,
|
setNodeLayout,
|
||||||
|
|||||||
+3
-1
@@ -421,7 +421,9 @@ function ResourceBatchClassificationPanel({
|
|||||||
setTagDraft('');
|
setTagDraft('');
|
||||||
onSaved(result);
|
onSaved(result);
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
setError(resourceClassificationErrorMessage(saveError, '批量追加标签失败'));
|
setError(
|
||||||
|
resourceClassificationErrorMessage(saveError, '批量追加标签失败'),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
inFlightRef.current = false;
|
inFlightRef.current = false;
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
|||||||
component: null,
|
component: null,
|
||||||
children_display_mode: 'Stack',
|
children_display_mode: 'Stack',
|
||||||
children: uiTrees.map((tree) => tree.root),
|
children: uiTrees.map((tree) => tree.root),
|
||||||
|
offset: { min: [0, 0], max: [0, 0] },
|
||||||
};
|
};
|
||||||
}, [uiTrees]);
|
}, [uiTrees]);
|
||||||
|
|
||||||
@@ -82,9 +83,18 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
|||||||
focusRequest={focusRequest}
|
focusRequest={focusRequest}
|
||||||
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
||||||
isNodePreviewVisible={input.isNodePreviewVisible}
|
isNodePreviewVisible={input.isNodePreviewVisible}
|
||||||
onSelectNode={(treeId, nodeId) => {
|
onSelectNode={(_treeId, nodeId) => {
|
||||||
input.selectDesignImage(treeId);
|
// react-arborist emits `onSelect` when its controlled `selection`
|
||||||
input.selectNode(nodeId);
|
// 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) =>
|
onToggleNodeVisibility={(nodeId) =>
|
||||||
input.toggleNodePreviewVisibility(nodeId)
|
input.toggleNodePreviewVisibility(nodeId)
|
||||||
|
|||||||
+26
-8
@@ -284,7 +284,7 @@ function NodeInspector({
|
|||||||
onMetadataChange,
|
onMetadataChange,
|
||||||
highlightedStatusField,
|
highlightedStatusField,
|
||||||
onTransformChange,
|
onTransformChange,
|
||||||
onLayoutChange,
|
// onLayoutChange,
|
||||||
sprites,
|
sprites,
|
||||||
previewUrls,
|
previewUrls,
|
||||||
fonts,
|
fonts,
|
||||||
@@ -483,11 +483,11 @@ function NodeInspector({
|
|||||||
readOnly={transformReadOnly || isReadOnly}
|
readOnly={transformReadOnly || isReadOnly}
|
||||||
onChange={onTransformChange}
|
onChange={onTransformChange}
|
||||||
/>
|
/>
|
||||||
<LayoutEditor
|
{/*<LayoutEditor*/}
|
||||||
node={node}
|
{/* node={node}*/}
|
||||||
readOnly={isReadOnly}
|
{/* readOnly={isReadOnly}*/}
|
||||||
onChange={onLayoutChange}
|
{/* onChange={onLayoutChange}*/}
|
||||||
/>
|
{/*/>*/}
|
||||||
<ComponentPanel
|
<ComponentPanel
|
||||||
key={node.id}
|
key={node.id}
|
||||||
component={node.component}
|
component={node.component}
|
||||||
@@ -511,6 +511,7 @@ function NodeInspector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||||
function LayoutEditor({
|
function LayoutEditor({
|
||||||
node,
|
node,
|
||||||
readOnly,
|
readOnly,
|
||||||
@@ -826,11 +827,28 @@ function NodeStageSelect({
|
|||||||
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
|
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlight) return;
|
const statusRow = statusRowRef.current;
|
||||||
statusRowRef.current?.scrollIntoView({
|
if (!highlight || !statusRow) return;
|
||||||
|
statusRow.scrollIntoView({
|
||||||
block: 'nearest',
|
block: 'nearest',
|
||||||
behavior: 'smooth',
|
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()) {
|
||||||
|
const animationName = (animation as CSSAnimation).animationName;
|
||||||
|
if (!animationName?.startsWith('ui-editor-status-attention')) continue;
|
||||||
|
animation.cancel();
|
||||||
|
animation.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [highlight]);
|
}, [highlight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user