Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b1e06b50a | |||
| 0b6efd98fe | |||
| 41b660bdc4 | |||
| da1759322c | |||
| ff1f77f88a | |||
| e73d04db00 | |||
| 8a679d7011 | |||
| 456762f569 | |||
| 48a2723527 | |||
| 1132fe5325 | |||
| ea3c34beef | |||
| 877724d7bc | |||
| ae8d51500b | |||
| 8ed2f42b52 | |||
| 300ea76780 | |||
| b136a7c346 | |||
| b1a9a296e8 | |||
| 75875a0fad | |||
| 1b12b7b98e | |||
| bb51c2716b | |||
| 9e8b7d880e | |||
| 609efe805c |
@@ -321,11 +321,11 @@ async fn suggest_ui_design_semantic(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn recognition_and_binding(
|
||||
async fn recognize_ui(
|
||||
project_path: String,
|
||||
state: ui_editor::state::State,
|
||||
) -> Result<ui_editor::commands::RecognitionAndBindingDTO, String> {
|
||||
ui_editor::commands::recognition_and_binding_impl(project_path, state).await
|
||||
) -> Result<ui_editor::commands::RecognitionDTO, String> {
|
||||
ui_editor::commands::recognize_ui_impl(project_path, state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -333,6 +333,15 @@ async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands:
|
||||
ui_editor::commands::merge_ui_impl(state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn bind_components(
|
||||
project_path: String,
|
||||
state: ui_editor::state::State,
|
||||
sprite_ids: Vec<String>,
|
||||
) -> Result<ui_editor::commands::BindingDTO, String> {
|
||||
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_ui_design_state(
|
||||
input: ui_editor::persistence::LoadUiDesignStateInput,
|
||||
@@ -2531,8 +2540,9 @@ fn main() {
|
||||
read_ui_editor_font_bytes,
|
||||
check_ui_editor_font_glyph_coverage,
|
||||
suggest_ui_design_semantic,
|
||||
recognition_and_binding,
|
||||
recognize_ui,
|
||||
merge_ui,
|
||||
bind_components,
|
||||
load_ui_design_state,
|
||||
save_ui_design_state,
|
||||
generate_ui_design_code,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,14 @@
|
||||
pub mod binding;
|
||||
pub mod merge;
|
||||
pub mod recognition_and_binding;
|
||||
pub mod recognition;
|
||||
pub mod ui_design_suggestion;
|
||||
pub mod utils;
|
||||
|
||||
pub(crate) use merge::merge_ui_impl;
|
||||
pub use binding::BindingDTO;
|
||||
pub(crate) use binding::{bind_components_impl, bind_components_impl_with_provider};
|
||||
pub use merge::MergeDTO;
|
||||
pub use recognition_and_binding::RecognitionAndBindingDTO;
|
||||
pub(crate) use recognition_and_binding::{
|
||||
recognition_and_binding_impl, recognition_and_binding_impl_with_provider,
|
||||
};
|
||||
pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider};
|
||||
pub use recognition::RecognitionDTO;
|
||||
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
|
||||
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
|
||||
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
|
||||
|
||||
+233
-192
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
use crate::ui_editor::commands::recognition_and_binding_impl_with_provider;
|
||||
use crate::ui_editor::commands::binding::BindingChange;
|
||||
use crate::ui_editor::commands::{
|
||||
bind_components_impl_with_provider, merge_ui_impl_with_provider,
|
||||
recognize_ui_impl_with_provider,
|
||||
};
|
||||
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,
|
||||
@@ -92,6 +96,7 @@ struct UiWorkflowPageDeclaration {
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum UiWorkflowPageStage {
|
||||
ReferenceReady,
|
||||
StructureReady,
|
||||
BindingReady,
|
||||
ApplicationReady,
|
||||
Completed,
|
||||
@@ -253,7 +258,7 @@ pub(crate) async fn run_ui_workflow_at_with_provider(
|
||||
// binding commands as the editor. There is intentionally no
|
||||
// deterministic fallback here: a missing provider or malformed
|
||||
// response is returned to the caller and leaves the durable stage
|
||||
// at reference-ready rather than claiming UI
|
||||
// at reference-ready/structure-ready rather than claiming UI
|
||||
// semantics were recognized.
|
||||
recognize_page_semantics(root, &manifest.project_id, page, provider_identity).await?;
|
||||
}
|
||||
@@ -684,17 +689,6 @@ fn find_page_ui_resource(
|
||||
{
|
||||
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
|
||||
}
|
||||
let generation_kind = asset
|
||||
.source
|
||||
.generation_kind
|
||||
.as_deref()
|
||||
.ok_or_else(|| format!("页面 {} 的 UI workflow 阶段缺失", page.page_id))?;
|
||||
if workflow_stage_rank(generation_kind).is_none() {
|
||||
return Err(format!(
|
||||
"页面 {} 的 UI workflow 阶段无效:{generation_kind}",
|
||||
page.page_id
|
||||
));
|
||||
}
|
||||
let mut expected_references = vec![
|
||||
canonical_resource_id(source),
|
||||
canonical_resource_id(design_asset),
|
||||
@@ -766,7 +760,7 @@ fn ensure_page_ui_resource(
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: Some("ui-workflow.reference-ready".to_string()),
|
||||
generation_kind: Some("ui-workflow".to_string()),
|
||||
reference_resource_ids: {
|
||||
let mut references = vec![
|
||||
canonical_resource_id(source),
|
||||
@@ -930,9 +924,10 @@ fn install_page_component_assets(
|
||||
|
||||
/// Runs the provider-backed editor pipeline for one workflow page.
|
||||
///
|
||||
/// `recognition_and_binding_impl` owns the single multimodal request, strict
|
||||
/// response validation, and allowlisted sprite validation. This wrapper only
|
||||
/// persists its DTO under the UI State revision gate; it never manufactures a tree when a
|
||||
/// `recognize_ui_impl` owns multimodal semantic recognition and strict tool
|
||||
/// response validation. `bind_components_impl` owns visual component binding
|
||||
/// and its allowlisted sprite validation. This wrapper only persists their
|
||||
/// DTOs under the UI State revision gate; it never manufactures a tree when a
|
||||
/// provider is unavailable or returns an invalid result.
|
||||
async fn recognize_page_semantics(
|
||||
root: &Path,
|
||||
@@ -947,44 +942,225 @@ async fn recognize_page_semantics(
|
||||
expected_project_id: project_id.to_string(),
|
||||
asset_id: page.ui_asset.id.clone(),
|
||||
})?;
|
||||
let result = recognition_and_binding_impl_with_provider(
|
||||
root.to_string_lossy().into_owned(),
|
||||
snapshot.state.clone(),
|
||||
provider_identity,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("页面 {} UI 联合识别绑定失败:{error}", page.input.page_id))?;
|
||||
if result.ui_trees.len() != 1 || result.ui_trees[0].src_ui_design != image_id {
|
||||
return Err(format!(
|
||||
"页面 {} UI 联合识别绑定返回的树与页面设计图不匹配",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
let mut state = snapshot.state;
|
||||
state.ui_trees = result.ui_trees;
|
||||
if !state_has_renderable_component(&state) {
|
||||
return Err(format!(
|
||||
"页面 {} UI 联合识别绑定未形成可渲染组件",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
match save_ui_design_state_at(SaveUiDesignStateInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
asset_id: page.ui_asset.id.clone(),
|
||||
expected_revision: snapshot.revision,
|
||||
state,
|
||||
})? {
|
||||
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
|
||||
SaveUiDesignStateResult::Conflict { .. } => {
|
||||
|
||||
let mut stage = page
|
||||
.ui_asset
|
||||
.source
|
||||
.generation_kind
|
||||
.as_deref()
|
||||
.unwrap_or("ui-workflow")
|
||||
.to_string();
|
||||
let has_page_tree = snapshot
|
||||
.state
|
||||
.ui_trees
|
||||
.iter()
|
||||
.filter(|tree| tree.src_ui_design == image_id)
|
||||
.count()
|
||||
== 1;
|
||||
|
||||
// Every provider-backed phase is persisted independently. A retry resumes
|
||||
// from the latest truthful manifest stage instead of repeating completed
|
||||
// calls or manufacturing fallback output.
|
||||
if !matches!(
|
||||
stage.as_str(),
|
||||
"ui-workflow.structure-ready" | "ui-workflow.merge-ready" | "ui-workflow.binding-ready"
|
||||
) {
|
||||
let project_path = root.to_string_lossy().into_owned();
|
||||
let recognition = recognize_ui_impl_with_provider(
|
||||
project_path,
|
||||
snapshot.state.clone(),
|
||||
provider_identity,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("页面 {} UI 语义识别失败:{error}", page.input.page_id))?;
|
||||
if recognition.ui_trees.len() != 1 || recognition.ui_trees[0].src_ui_design != image_id {
|
||||
return Err(format!(
|
||||
"页面 {} UI 联合识别绑定保存 revision 冲突,请重试",
|
||||
"页面 {} UI 语义识别返回的树与页面设计图不匹配",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
let mut state = snapshot.state;
|
||||
state.ui_trees = recognition.ui_trees;
|
||||
match save_ui_design_state_at(SaveUiDesignStateInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
asset_id: page.ui_asset.id.clone(),
|
||||
expected_revision: snapshot.revision,
|
||||
state,
|
||||
})? {
|
||||
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
|
||||
SaveUiDesignStateResult::Conflict { .. } => {
|
||||
return Err(format!(
|
||||
"页面 {} UI 语义识别保存 revision 冲突,请重试",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
}
|
||||
update_page_manifest_stage(root, &page.ui_asset.id, "structure-ready")?;
|
||||
stage = "ui-workflow.structure-ready".to_string();
|
||||
} else if !has_page_tree {
|
||||
return Err(format!(
|
||||
"页面 {} manifest 已记录语义识别阶段,但 UI State 缺少唯一结构树",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
|
||||
if stage == "ui-workflow.binding-ready" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if stage == "ui-workflow.structure-ready" {
|
||||
let merge_snapshot = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
asset_id: page.ui_asset.id.clone(),
|
||||
})?;
|
||||
let merged = merge_ui_impl_with_provider(
|
||||
root.to_string_lossy().into_owned(),
|
||||
merge_snapshot.state.clone(),
|
||||
provider_identity,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("页面 {} UI 多树合并失败:{error}", page.input.page_id))?;
|
||||
if merged.ui_tree.src_ui_design != image_id {
|
||||
return Err(format!(
|
||||
"页面 {} UI 多树合并结果未绑定主页面设计图",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
let mut state = merge_snapshot.state;
|
||||
state.ui_trees = vec![merged.ui_tree];
|
||||
match save_ui_design_state_at(SaveUiDesignStateInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
asset_id: page.ui_asset.id.clone(),
|
||||
expected_revision: merge_snapshot.revision,
|
||||
state,
|
||||
})? {
|
||||
SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {}
|
||||
SaveUiDesignStateResult::Conflict { .. } => {
|
||||
return Err(format!(
|
||||
"页面 {} UI 多树合并保存 revision 冲突,请重试",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
}
|
||||
update_page_manifest_stage(root, &page.ui_asset.id, "merge-ready")?;
|
||||
stage = "ui-workflow.merge-ready".to_string();
|
||||
}
|
||||
|
||||
if stage != "ui-workflow.merge-ready" {
|
||||
return Err(format!(
|
||||
"页面 {} UI workflow 阶段无法进入组件绑定:{stage}",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
|
||||
let mut binding_snapshot = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
asset_id: page.ui_asset.id.clone(),
|
||||
})?;
|
||||
let mut sprite_ids = binding_snapshot
|
||||
.state
|
||||
.sprite_assets
|
||||
.keys()
|
||||
.map(|id| id.as_str().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
sprite_ids.sort();
|
||||
if sprite_ids.is_empty() {
|
||||
return Err(format!(
|
||||
"页面 {} UI 语义识别已完成,但没有可用于组件绑定的页面素材",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
let mut changed_nodes = 0usize;
|
||||
for batch in sprite_ids.chunks(crate::ui_editor::commands::binding::ASSET_BATCH_SIZE) {
|
||||
let binding = bind_components_impl_with_provider(
|
||||
root.to_string_lossy().into_owned(),
|
||||
binding_snapshot.state.clone(),
|
||||
batch.to_vec(),
|
||||
provider_identity,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("页面 {} UI 组件语义绑定失败:{error}", page.input.page_id))?;
|
||||
if binding.changes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut state = binding_snapshot.state.clone();
|
||||
let changes = binding
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|change| (change.node_id.clone(), change))
|
||||
.collect::<HashMap<_, _>>();
|
||||
changed_nodes += apply_binding_changes(&mut state.ui_trees, &changes);
|
||||
binding_snapshot = match save_ui_design_state_at(SaveUiDesignStateInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
asset_id: page.ui_asset.id.clone(),
|
||||
expected_revision: binding_snapshot.revision,
|
||||
state,
|
||||
})? {
|
||||
SaveUiDesignStateResult::Saved {
|
||||
state, revision, ..
|
||||
}
|
||||
| SaveUiDesignStateResult::Unchanged {
|
||||
state, revision, ..
|
||||
} => crate::ui_editor::persistence::UiDesignStateSnapshot { state, revision },
|
||||
SaveUiDesignStateResult::Conflict { .. } => {
|
||||
return Err(format!(
|
||||
"页面 {} UI 组件绑定保存 revision 冲突,请重试",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
if changed_nodes == 0 || !state_has_renderable_component(&binding_snapshot.state) {
|
||||
return Err(format!(
|
||||
"页面 {} UI 组件语义绑定未形成可渲染组件,拒绝进入 binding-ready",
|
||||
page.input.page_id
|
||||
));
|
||||
}
|
||||
let mut binding_blockers = Vec::new();
|
||||
let mut component_count = 0usize;
|
||||
for tree in &binding_snapshot.state.ui_trees {
|
||||
collect_binding_blockers(&tree.root, &mut component_count, &mut binding_blockers);
|
||||
}
|
||||
if !binding_blockers.is_empty() {
|
||||
// Keep the provider result available for review, but do not claim the
|
||||
// binding stage. A subsequent recognize operation can retry binding
|
||||
// from the durable structure-ready state.
|
||||
return Ok(());
|
||||
}
|
||||
update_page_manifest_stage(root, &page.ui_asset.id, "binding-ready")
|
||||
}
|
||||
|
||||
fn apply_binding_changes(
|
||||
trees: &mut [crate::ui_editor::state::UITree],
|
||||
changes: &HashMap<crate::ui_editor::utils::NodeId, BindingChange>,
|
||||
) -> usize {
|
||||
fn apply_node(
|
||||
node: &mut Node,
|
||||
changes: &HashMap<crate::ui_editor::utils::NodeId, BindingChange>,
|
||||
) -> usize {
|
||||
let mut changed = 0;
|
||||
if let Some(change) = changes.get(&node.id) {
|
||||
node.components = change.components.clone();
|
||||
node.metadata.components_status = change.components_status.clone();
|
||||
changed += 1;
|
||||
}
|
||||
for child in &mut node.children {
|
||||
changed += apply_node(child, changes);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
trees
|
||||
.iter_mut()
|
||||
.map(|tree| apply_node(&mut tree.root, changes))
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn state_has_renderable_component(state: &crate::ui_editor::state::State) -> bool {
|
||||
fn has_component(node: &Node) -> bool {
|
||||
!node.components.is_empty() || node.children.iter().any(has_component)
|
||||
@@ -1007,16 +1183,13 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
|
||||
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
|
||||
}
|
||||
let next_kind = format!("ui-workflow.{stage}");
|
||||
let current_kind = asset
|
||||
let current_rank = asset
|
||||
.source
|
||||
.generation_kind
|
||||
.as_deref()
|
||||
.ok_or_else(|| format!("UI workflow 资源 {} 阶段缺失", asset_id))?;
|
||||
let current_rank = workflow_stage_rank(current_kind).ok_or_else(|| {
|
||||
format!("UI workflow 资源 {} 阶段无效:{current_kind}", asset_id)
|
||||
})?;
|
||||
let next_rank = workflow_stage_rank(&next_kind)
|
||||
.ok_or_else(|| format!("UI workflow 下一阶段无效:{next_kind}"))?;
|
||||
.and_then(workflow_stage_rank)
|
||||
.unwrap_or(0);
|
||||
let next_rank = workflow_stage_rank(&next_kind).unwrap_or(0);
|
||||
if current_rank >= next_rank {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -1034,9 +1207,11 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
|
||||
fn workflow_stage_rank(kind: &str) -> Option<u8> {
|
||||
match kind {
|
||||
"ui-workflow.reference-ready" => Some(1),
|
||||
"ui-workflow.binding-ready" => Some(2),
|
||||
"ui-workflow.application-ready" => Some(3),
|
||||
"ui-workflow.completed" => Some(4),
|
||||
"ui-workflow.structure-ready" => Some(2),
|
||||
"ui-workflow.merge-ready" => Some(3),
|
||||
"ui-workflow.binding-ready" => Some(4),
|
||||
"ui-workflow.application-ready" => Some(5),
|
||||
"ui-workflow.completed" => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1093,6 +1268,7 @@ fn derive_page_status(
|
||||
if matching_trees.len() != 1 {
|
||||
blockers.push("尚未形成唯一的页面 UI 结构树".to_string());
|
||||
} else {
|
||||
stage = UiWorkflowPageStage::StructureReady;
|
||||
let mut component_count = 0usize;
|
||||
collect_binding_blockers(&matching_trees[0].root, &mut component_count, &mut blockers);
|
||||
if component_count == 0 {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BindingDTO } from './types/BindingDTO';
|
||||
import type { Node } from './types/Node';
|
||||
import type { State } from './types/State';
|
||||
|
||||
function applyChanges(node: Node, result: BindingDTO): void {
|
||||
const change = result.changes.find(
|
||||
(candidate) => candidate.node_id === node.id,
|
||||
);
|
||||
if (change) {
|
||||
node.components = structuredClone(change.components);
|
||||
node.metadata.components_status = structuredClone(change.components_status);
|
||||
}
|
||||
for (const child of node.children) applyChanges(child, result);
|
||||
}
|
||||
|
||||
/** Applies only explicit component changes; omitted nodes remain untouched. */
|
||||
export function applyBindingResult(state: State, result: BindingDTO): State {
|
||||
const next = structuredClone(state);
|
||||
for (const tree of next.ui_trees) applyChanges(tree.root, result);
|
||||
return next;
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
export const SPRITE_CHECKERBOARD_CLASS_NAME =
|
||||
'bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]';
|
||||
|
||||
export function SpriteImagePreview({
|
||||
src,
|
||||
alt,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RecognitionDTO } from './types/RecognitionDTO';
|
||||
import type { State } from './types/State';
|
||||
|
||||
/**
|
||||
* 识别结果是整棵结构草稿树的替换结果,不与旧树逐节点合并。
|
||||
* 识别阶段有意不携带视觉组件;组件绑定由后续 visual-binding 阶段完成。
|
||||
*/
|
||||
export function applyRecognitionResult(
|
||||
state: State,
|
||||
result: RecognitionDTO,
|
||||
): State {
|
||||
return {
|
||||
...structuredClone(state),
|
||||
ui_trees: structuredClone(result.ui_trees),
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { RecognitionAndBindingDTO } from './types/RecognitionAndBindingDTO';
|
||||
import type { State } from './types/State';
|
||||
|
||||
/**
|
||||
* 联合识别绑定结果是整棵最终树的替换结果,不与旧树逐节点合并。
|
||||
*/
|
||||
export function applyRecognitionAndBindingResult(
|
||||
state: State,
|
||||
result: RecognitionAndBindingDTO,
|
||||
): State {
|
||||
return {
|
||||
...structuredClone(state),
|
||||
ui_trees: structuredClone(result.ui_trees),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Component } from "./Component";
|
||||
import type { NodeId } from "./NodeId";
|
||||
import type { StageStatus } from "./StageStatus";
|
||||
|
||||
export type BindingChange = { node_id: NodeId, components: Array<Component>, components_status: StageStatus, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BindingChange } from "./BindingChange";
|
||||
|
||||
export type BindingDTO = { changes: Array<BindingChange>, };
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UITree } from "./UITree";
|
||||
|
||||
export type RecognitionAndBindingDTO = { ui_trees: Array<UITree>, };
|
||||
export type RecognitionDTO = { ui_trees: Array<UITree>, };
|
||||
@@ -33,8 +33,6 @@ export const EMPTY_UI_EDITOR_STATE: State = {
|
||||
font_assets: {},
|
||||
};
|
||||
|
||||
const MAX_HISTORY_LENGTH = 100;
|
||||
|
||||
export type UiEditorOperationFailureReason =
|
||||
| 'locked'
|
||||
| 'duplicate'
|
||||
@@ -47,15 +45,6 @@ export type UiEditorOperationResult<T = undefined> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; reason: UiEditorOperationFailureReason };
|
||||
|
||||
export type UiEditorHistoryState = {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
};
|
||||
|
||||
export type UiEditorReplaceStateOptions = {
|
||||
history?: 'record' | 'reset' | 'skip';
|
||||
};
|
||||
|
||||
type UiEditorOperationFailure = Extract<UiEditorOperationResult, { ok: false }>;
|
||||
|
||||
export type NodeMetadataPatch = Partial<
|
||||
@@ -238,47 +227,8 @@ function cloneState(state: State): State {
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
function sameResource<T>(
|
||||
left: T,
|
||||
right: T,
|
||||
seenPairs = new WeakMap<object, WeakSet<object>>(),
|
||||
): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (
|
||||
typeof left !== 'object' ||
|
||||
left === null ||
|
||||
typeof right !== 'object' ||
|
||||
right === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const leftObject = left as object;
|
||||
const rightObject = right as object;
|
||||
let seenRightObjects = seenPairs.get(leftObject);
|
||||
if (seenRightObjects?.has(rightObject)) return true;
|
||||
if (!seenRightObjects) {
|
||||
seenRightObjects = new WeakSet<object>();
|
||||
seenPairs.set(leftObject, seenRightObjects);
|
||||
}
|
||||
seenRightObjects.add(rightObject);
|
||||
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((value, index) =>
|
||||
sameResource(value, right[index], seenPairs),
|
||||
);
|
||||
}
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const leftKeys = Object.keys(leftRecord);
|
||||
const rightKeys = Object.keys(rightRecord);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
return leftKeys.every(
|
||||
(key) =>
|
||||
Object.prototype.hasOwnProperty.call(rightRecord, key) &&
|
||||
sameResource(leftRecord[key], rightRecord[key], seenPairs),
|
||||
);
|
||||
function sameResource<T>(left: T, right: T): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function visitComponents(nodes: Node[], visit: (component: Component) => void) {
|
||||
@@ -555,82 +505,15 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
return next;
|
||||
});
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [historyState, setHistoryState] = useState<UiEditorHistoryState>({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
const stateRef = useRef(state);
|
||||
const isLockedRef = useRef(false);
|
||||
const undoStackRef = useRef<Array<{ before: State; after: State }>>([]);
|
||||
const redoStackRef = useRef<Array<{ before: State; after: State }>>([]);
|
||||
const pendingHistoryBeforeRef = useRef<State | null>(null);
|
||||
stateRef.current = state;
|
||||
|
||||
const syncHistoryState = useCallback(() => {
|
||||
setHistoryState({
|
||||
canUndo: undoStackRef.current.length > 0,
|
||||
canRedo: redoStackRef.current.length > 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyState = useCallback((nextState: State) => {
|
||||
const commit = useCallback((nextState: State) => {
|
||||
stateRef.current = nextState;
|
||||
setState(nextState);
|
||||
}, []);
|
||||
|
||||
const commit = useCallback(
|
||||
(nextState: State) => {
|
||||
const current = stateRef.current;
|
||||
const before = pendingHistoryBeforeRef.current ?? current;
|
||||
if (sameResource(before, nextState)) {
|
||||
pendingHistoryBeforeRef.current = null;
|
||||
return false;
|
||||
}
|
||||
undoStackRef.current.push({
|
||||
before: cloneState(before),
|
||||
after: nextState,
|
||||
});
|
||||
if (undoStackRef.current.length > MAX_HISTORY_LENGTH) {
|
||||
undoStackRef.current.shift();
|
||||
}
|
||||
redoStackRef.current = [];
|
||||
pendingHistoryBeforeRef.current = null;
|
||||
syncHistoryState();
|
||||
applyState(nextState);
|
||||
return true;
|
||||
},
|
||||
[applyState, syncHistoryState],
|
||||
);
|
||||
|
||||
const resetHistory = useCallback(() => {
|
||||
undoStackRef.current = [];
|
||||
redoStackRef.current = [];
|
||||
pendingHistoryBeforeRef.current = null;
|
||||
syncHistoryState();
|
||||
}, [syncHistoryState]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (isLockedRef.current) return false;
|
||||
const entry = undoStackRef.current.pop();
|
||||
if (!entry) return false;
|
||||
pendingHistoryBeforeRef.current = null;
|
||||
redoStackRef.current.push(entry);
|
||||
applyState(cloneState(entry.before));
|
||||
syncHistoryState();
|
||||
return true;
|
||||
}, [applyState, syncHistoryState]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (isLockedRef.current) return false;
|
||||
const entry = redoStackRef.current.pop();
|
||||
if (!entry) return false;
|
||||
pendingHistoryBeforeRef.current = null;
|
||||
undoStackRef.current.push(entry);
|
||||
applyState(cloneState(entry.after));
|
||||
syncHistoryState();
|
||||
return true;
|
||||
}, [applyState, syncHistoryState]);
|
||||
|
||||
const guard = useCallback((): UiEditorOperationFailure | null => {
|
||||
// Every semantic write exits before reading or committing State while locked.
|
||||
return isLockedRef.current ? { ok: false, reason: 'locked' } : null;
|
||||
@@ -651,7 +534,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
try {
|
||||
return await operation(snapshot);
|
||||
} finally {
|
||||
pendingHistoryBeforeRef.current = null;
|
||||
isLockedRef.current = false;
|
||||
setIsLocked(false);
|
||||
}
|
||||
@@ -1515,38 +1397,22 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
const clearState = useCallback((): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
resetHistory();
|
||||
applyState(cloneState(EMPTY_UI_EDITOR_STATE));
|
||||
commit(cloneState(EMPTY_UI_EDITOR_STATE));
|
||||
return { ok: true, value: undefined };
|
||||
}, [applyState, guard, resetHistory]);
|
||||
}, [commit, guard]);
|
||||
|
||||
const replaceState = useCallback(
|
||||
(nextState: State, options: UiEditorReplaceStateOptions = {}) => {
|
||||
(nextState: State) => {
|
||||
const next = cloneState(nextState);
|
||||
synchronizeDesignImageTrees(next);
|
||||
if (options.history === 'reset') {
|
||||
resetHistory();
|
||||
applyState(next);
|
||||
} else if (options.history === 'skip') {
|
||||
if (!pendingHistoryBeforeRef.current) {
|
||||
pendingHistoryBeforeRef.current = cloneState(stateRef.current);
|
||||
}
|
||||
applyState(next);
|
||||
} else {
|
||||
commit(next);
|
||||
}
|
||||
commit(next);
|
||||
},
|
||||
[applyState, commit, resetHistory],
|
||||
[commit],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
historyState,
|
||||
undo,
|
||||
redo,
|
||||
resetHistory,
|
||||
|
||||
isLocked,
|
||||
runWithStateLocked,
|
||||
setImageName,
|
||||
|
||||
+22
-69
@@ -5,117 +5,70 @@ import {
|
||||
nodeHasBlockedComponents,
|
||||
nodeNeedsComponentReview,
|
||||
} from '../../../features/ui-editor/bindingOverview';
|
||||
import type { StageStatusField } from '../../../features/ui-editor/stageStatusOverview';
|
||||
import {
|
||||
getStageStatusOverview,
|
||||
isBlocked,
|
||||
isNeedReview,
|
||||
} from '../../../features/ui-editor/stageStatusOverview';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { SpriteAsset } from '../../../features/ui-editor/types/SpriteAsset';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UITree } from '../../../features/ui-editor/types/UITree';
|
||||
import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
|
||||
|
||||
export function RecognitionAndBindingOverview({
|
||||
export function BindingOverview({
|
||||
uiTrees,
|
||||
sprites,
|
||||
onFocusStatusNode,
|
||||
}: {
|
||||
uiTrees: UITree[];
|
||||
sprites: Record<string, SpriteAsset>;
|
||||
onFocusStatusNode: (
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
field: StageStatusField,
|
||||
) => void;
|
||||
onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
|
||||
}) {
|
||||
const recognition = useMemo(
|
||||
() => getStageStatusOverview(uiTrees, 'layout_status'),
|
||||
[uiTrees],
|
||||
);
|
||||
const binding = useMemo(
|
||||
const overview = useMemo(
|
||||
() => getBindingOverview(uiTrees, sprites),
|
||||
[sprites, uiTrees],
|
||||
);
|
||||
const layoutAttentionCycle = useUiTreeNodeCycle({
|
||||
const attentionCycle = useUiTreeNodeCycle({
|
||||
uiTrees,
|
||||
onFocusNode: (treeId, nodeId) =>
|
||||
onFocusStatusNode(treeId, nodeId, 'layout_status'),
|
||||
matches: ({ node }) => {
|
||||
const status = node.metadata.layout_status;
|
||||
return isBlocked(status) || isNeedReview(status);
|
||||
},
|
||||
});
|
||||
const layoutBlockedCycle = useUiTreeNodeCycle({
|
||||
uiTrees,
|
||||
onFocusNode: (treeId, nodeId) =>
|
||||
onFocusStatusNode(treeId, nodeId, 'layout_status'),
|
||||
matches: ({ node }) => isBlocked(node.metadata.layout_status),
|
||||
});
|
||||
const componentAttentionCycle = useUiTreeNodeCycle({
|
||||
uiTrees,
|
||||
onFocusNode: (treeId, nodeId) =>
|
||||
onFocusStatusNode(treeId, nodeId, 'components_status'),
|
||||
onFocusNode: onFocusStatusNode,
|
||||
matches: nodeNeedsComponentReview,
|
||||
});
|
||||
const componentBlockedCycle = useUiTreeNodeCycle({
|
||||
const blockedCycle = useUiTreeNodeCycle({
|
||||
uiTrees,
|
||||
onFocusNode: (treeId, nodeId) =>
|
||||
onFocusStatusNode(treeId, nodeId, 'components_status'),
|
||||
onFocusNode: onFocusStatusNode,
|
||||
matches: nodeHasBlockedComponents,
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
|
||||
aria-label="识别与绑定概览"
|
||||
aria-label="绑定概览"
|
||||
>
|
||||
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
Overview
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">识别与绑定概览</h2>
|
||||
<h2 className="m-0 text-sm font-semibold">绑定概览</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<OverviewValue label="已识别 Node" value={recognition.total} />
|
||||
<OverviewValue label="无问题 Node" value={recognition.noProblem} />
|
||||
<OverviewAction
|
||||
label="结构待检查"
|
||||
value={recognition.needsAttention}
|
||||
tone="warning"
|
||||
disabled={recognition.needsAttention === 0}
|
||||
onClick={layoutAttentionCycle.focusNext}
|
||||
/>
|
||||
<OverviewAction
|
||||
label="结构必须修复"
|
||||
value={recognition.blocked}
|
||||
tone="danger"
|
||||
disabled={recognition.blocked === 0}
|
||||
onClick={layoutBlockedCycle.focusNext}
|
||||
/>
|
||||
<OverviewValue
|
||||
label="需要素材的组件"
|
||||
value={binding.componentsNeedingAssets}
|
||||
value={overview.componentsNeedingAssets}
|
||||
/>
|
||||
<OverviewValue label="素材槽位" value={binding.assetSlots} />
|
||||
<OverviewValue label="已绑定" value={binding.boundSlots} />
|
||||
<OverviewValue label="待处理" value={binding.pendingSlots} />
|
||||
<OverviewValue label="素材槽位" value={overview.assetSlots} />
|
||||
<OverviewValue label="已绑定" value={overview.boundSlots} />
|
||||
<OverviewValue label="待处理" value={overview.pendingSlots} />
|
||||
<OverviewAction
|
||||
label="组件待检查"
|
||||
value={binding.needsAttention}
|
||||
label="待用户检查"
|
||||
value={overview.needsAttention}
|
||||
tone="warning"
|
||||
disabled={binding.needsAttention === 0}
|
||||
onClick={componentAttentionCycle.focusNext}
|
||||
disabled={overview.needsAttention === 0}
|
||||
onClick={attentionCycle.focusNext}
|
||||
/>
|
||||
<OverviewAction
|
||||
label="组件必须修复"
|
||||
value={binding.blocked}
|
||||
label="必须修复"
|
||||
value={overview.blocked}
|
||||
tone="danger"
|
||||
disabled={binding.blocked === 0}
|
||||
onClick={componentBlockedCycle.focusNext}
|
||||
disabled={overview.blocked === 0}
|
||||
onClick={blockedCycle.focusNext}
|
||||
/>
|
||||
<OverviewValue
|
||||
label="独立素材"
|
||||
value={binding.independentAssets}
|
||||
value={overview.independentAssets}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Image as ImageIcon, Plus, Type } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../features/ui-editor/components/SpriteImagePreview';
|
||||
import { visitUiNodes } from '../../../features/ui-editor/treeUtils';
|
||||
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
@@ -185,7 +184,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
<button
|
||||
key={sprite.asset_id}
|
||||
type="button"
|
||||
className={`relative aspect-square overflow-hidden rounded-lg border border-(--platform-subpanel-border) ${SPRITE_CHECKERBOARD_CLASS_NAME}`}
|
||||
className="relative aspect-square overflow-hidden rounded-lg border border-(--platform-subpanel-border) bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]"
|
||||
title={`${sprite.metadata.name} · 引用 ${spriteReferenceCounts[sprite.asset_id] ?? 0}`}
|
||||
onClick={() => input.selectSprite(sprite.asset_id)}
|
||||
>
|
||||
|
||||
-1
@@ -293,7 +293,6 @@ function renderComponentEditor(
|
||||
<ImagePanel
|
||||
component={component.Image}
|
||||
sprites={props.sprites}
|
||||
previewUrls={props.previewUrls}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => updateComponent(index, { Image: next })}
|
||||
/>
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
import { Check, ImageIcon, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ThemedModal } from '../../../../../components/modal/ThemedModal';
|
||||
import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../../../features/ui-editor/components/SpriteImagePreview';
|
||||
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
|
||||
|
||||
export function ImageAssetSelector({
|
||||
value,
|
||||
sprites,
|
||||
previewUrls,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
sprites: Record<string, SpriteAsset>;
|
||||
previewUrls: Record<string, string>;
|
||||
readOnly: boolean;
|
||||
onChange: (value: string | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [failedPreviewUrls, setFailedPreviewUrls] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const selectedSprite = value ? sprites[value] : undefined;
|
||||
let selectedLabel = '未绑定';
|
||||
if (selectedSprite) {
|
||||
selectedLabel = selectedSprite.metadata.name || value || '未绑定';
|
||||
} else if (value) {
|
||||
selectedLabel = `素材不存在(${value})`;
|
||||
}
|
||||
const buttonLabel = value ? '更换素材' : '选择素材';
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function select(nextValue: string | null) {
|
||||
onChange(nextValue);
|
||||
close();
|
||||
}
|
||||
|
||||
function markPreviewFailed(id: string, url: string) {
|
||||
setFailedPreviewUrls((current) => {
|
||||
if (current[id] === url) return current;
|
||||
return { ...current, [id]: url };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="block text-[11px] font-semibold text-(--platform-text-soft)">
|
||||
目标素材
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div
|
||||
className={`min-w-0 flex-1 truncate rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs ${value && !selectedSprite ? 'text-red-600' : 'text-(--platform-text-strong)'}`}
|
||||
title={selectedLabel}
|
||||
>
|
||||
{selectedLabel}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 shrink-0 rounded-lg border border-orange-200 bg-orange-50 px-3 text-xs font-semibold text-orange-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={readOnly}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemedModal
|
||||
open={open}
|
||||
onClose={close}
|
||||
ariaLabel="选择图片素材"
|
||||
panelClassName="flex max-h-[80vh] w-[720px] max-w-[calc(100vw-2rem)] flex-col rounded-2xl p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="m-0 text-base font-semibold">选择图片素材</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-lg text-(--platform-text-soft) hover:bg-black/5"
|
||||
aria-label="关闭素材选择器"
|
||||
onClick={close}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex min-h-36 flex-col items-center justify-center rounded-xl border border-dashed p-3 text-xs ${value === null ? 'border-orange-400 bg-orange-50 text-orange-800' : 'border-(--platform-subpanel-border) bg-white/45 text-(--platform-text-soft)'}`}
|
||||
aria-pressed={value === null}
|
||||
onClick={() => select(null)}
|
||||
>
|
||||
<X size={22} aria-hidden="true" />
|
||||
<span className="mt-2 font-semibold">清除选择</span>
|
||||
<span className="mt-1 text-[10px]">不绑定图片素材</span>
|
||||
</button>
|
||||
|
||||
{value && !selectedSprite ? (
|
||||
<div className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 p-3 text-center text-xs text-red-700">
|
||||
<ImageIcon size={22} aria-hidden="true" />
|
||||
<span className="mt-2 font-semibold">素材不存在</span>
|
||||
<span className="mt-1 break-all text-[10px]">{value}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{Object.entries(sprites).map(([id, sprite]) => {
|
||||
const previewUrl = previewUrls[id];
|
||||
const hasPreview =
|
||||
Boolean(previewUrl) && failedPreviewUrls[id] !== previewUrl;
|
||||
const selected = value === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`relative overflow-hidden rounded-xl border p-2 text-left transition ${selected ? 'border-orange-400 bg-orange-50 ring-2 ring-orange-200' : 'border-(--platform-subpanel-border) bg-white/45 hover:border-orange-200'}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => select(id)}
|
||||
>
|
||||
<div
|
||||
className={`grid aspect-[4/3] place-items-center overflow-hidden rounded-lg ${SPRITE_CHECKERBOARD_CLASS_NAME}`}
|
||||
>
|
||||
{hasPreview ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={sprite.metadata.name || id}
|
||||
className="size-full object-contain"
|
||||
onError={() => {
|
||||
if (previewUrl) markPreviewFailed(id, previewUrl);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon
|
||||
size={24}
|
||||
className="text-(--platform-text-soft)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="mt-2 block truncate text-xs font-semibold text-(--platform-text-strong)">
|
||||
{sprite.metadata.name || id}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-orange-500 text-white">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{Object.keys(sprites).length === 0 ? (
|
||||
<p className="m-0 py-10 text-center text-xs text-(--platform-text-soft)">
|
||||
暂无可用素材
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ThemedModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+18
-9
@@ -4,12 +4,10 @@ import type { ImageType } from '../../../../../features/ui-editor/types/ImageTyp
|
||||
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
|
||||
import type { ImageEditorProps } from './componentEditorTypes';
|
||||
import { ComponentNumberInput, ComponentSelect } from './ComponentField';
|
||||
import { ImageAssetSelector } from './ImageAssetSelector';
|
||||
|
||||
export function ImagePanel({
|
||||
component,
|
||||
sprites,
|
||||
previewUrls,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: ImageEditorProps) {
|
||||
@@ -19,13 +17,24 @@ export function ImagePanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ImageAssetSelector
|
||||
value={component.target_graphic}
|
||||
sprites={sprites}
|
||||
previewUrls={previewUrls}
|
||||
readOnly={readOnly}
|
||||
onChange={(target_graphic) => update({ ...component, target_graphic })}
|
||||
/>
|
||||
<ComponentSelect
|
||||
label="目标素材"
|
||||
value={component.target_graphic ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
update({
|
||||
...component,
|
||||
target_graphic: event.target.value || null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">未绑定</option>
|
||||
{Object.entries(sprites).map(([id, sprite]) => (
|
||||
<option key={id} value={id}>
|
||||
{sprite.metadata.name || id}
|
||||
</option>
|
||||
))}
|
||||
</ComponentSelect>
|
||||
|
||||
<ComponentSelect
|
||||
label="图片类型"
|
||||
|
||||
+15
-56
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { type RgbaColor, RgbaColorPicker } from 'react-colorful';
|
||||
|
||||
import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing';
|
||||
@@ -21,49 +21,19 @@ export function TextPanel({
|
||||
onChange,
|
||||
}: TextEditorProps) {
|
||||
const [colorOpen, setColorOpen] = useState(false);
|
||||
const [colorDraft, setColorDraft] = useState<RgbaColor | null>(null);
|
||||
const colorDraftRef = useRef<RgbaColor | null>(null);
|
||||
const componentRef = useRef(component);
|
||||
const onChangeRef = useRef(onChange);
|
||||
componentRef.current = component;
|
||||
onChangeRef.current = onChange;
|
||||
const committedRgba: RgbaColor = {
|
||||
const rgba: RgbaColor = {
|
||||
r: component.color[0] ?? 255,
|
||||
g: component.color[1] ?? 255,
|
||||
b: component.color[2] ?? 255,
|
||||
a: (component.color[3] ?? 255) / 255,
|
||||
};
|
||||
const rgba = colorDraft ?? committedRgba;
|
||||
useEffect(() => {
|
||||
colorDraftRef.current = null;
|
||||
setColorDraft(null);
|
||||
}, [component.color]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
const draft = colorDraftRef.current;
|
||||
if (!draft) return;
|
||||
onChangeRef.current({
|
||||
...componentRef.current,
|
||||
color: [draft.r, draft.g, draft.b, Math.round(draft.a * 255)],
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const bestFit =
|
||||
'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null;
|
||||
const commitColor = (next: RgbaColor = rgba) => {
|
||||
if (!colorDraftRef.current) return;
|
||||
colorDraftRef.current = null;
|
||||
setColorDraft(null);
|
||||
const updateColor = (next: RgbaColor) =>
|
||||
onChange({
|
||||
...component,
|
||||
color: [next.r, next.g, next.b, Math.round(next.a * 255)],
|
||||
});
|
||||
};
|
||||
const toggleColorOpen = () => {
|
||||
if (colorOpen) commitColor();
|
||||
setColorOpen((open) => !open);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -189,7 +159,7 @@ export function TextPanel({
|
||||
type="button"
|
||||
className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40"
|
||||
disabled={readOnly}
|
||||
onClick={toggleColorOpen}
|
||||
onClick={() => setColorOpen((open) => !open)}
|
||||
>
|
||||
<span
|
||||
className="size-5 rounded border border-black/15"
|
||||
@@ -202,35 +172,24 @@ export function TextPanel({
|
||||
</ComponentField>
|
||||
{colorOpen && !readOnly ? (
|
||||
<div className="absolute right-0 top-full z-20 mt-2 w-64 rounded-xl border border-(--platform-subpanel-border) bg-white p-3 shadow-xl">
|
||||
<div
|
||||
onPointerCancel={() => {
|
||||
colorDraftRef.current = null;
|
||||
setColorDraft(null);
|
||||
}}
|
||||
>
|
||||
<RgbaColorPicker
|
||||
color={rgba}
|
||||
onChange={(next) => {
|
||||
colorDraftRef.current = next;
|
||||
setColorDraft(next);
|
||||
}}
|
||||
onChangeEnd={commitColor}
|
||||
/>
|
||||
</div>
|
||||
<RgbaColorPicker color={rgba} onChange={updateColor} />
|
||||
<ComponentNumberInput
|
||||
label="Alpha"
|
||||
min={0}
|
||||
max={255}
|
||||
step={1}
|
||||
value={Math.round(rgba.a * 255)}
|
||||
onChange={(event) => {
|
||||
colorDraftRef.current = null;
|
||||
setColorDraft(null);
|
||||
value={component.color[3]}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...component,
|
||||
color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)],
|
||||
});
|
||||
}}
|
||||
color: [
|
||||
component.color[0],
|
||||
component.color[1],
|
||||
component.color[2],
|
||||
Number(event.target.value),
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
-2
@@ -9,7 +9,6 @@ import type { UiEditorOperationResult } from '../../../../../features/ui-editor/
|
||||
export type ComponentPanelProps = {
|
||||
components: Component[];
|
||||
sprites: Record<string, SpriteAsset>;
|
||||
previewUrls: Record<string, string>;
|
||||
fonts: Record<string, FontAsset>;
|
||||
fontFaces: Record<string, UiEditorFontFaceState>;
|
||||
projectPath: string;
|
||||
@@ -36,7 +35,6 @@ export type ComponentEditorProps<T> = {
|
||||
|
||||
export type ImageEditorProps = ComponentEditorProps<ImageComponent> & {
|
||||
sprites: Record<string, SpriteAsset>;
|
||||
previewUrls: Record<string, string>;
|
||||
};
|
||||
|
||||
export type TextEditorProps = ComponentEditorProps<TextComponent> & {
|
||||
|
||||
-4
@@ -111,7 +111,6 @@ export function InspectorSidebar({
|
||||
onTransformChange={inspector.setNodeTransform}
|
||||
onLayoutChange={inspector.setNodeLayout}
|
||||
sprites={inspector.sprites}
|
||||
previewUrls={inspector.previewUrls}
|
||||
fonts={inspector.fonts}
|
||||
fontFaces={inspector.fontFaces}
|
||||
projectPath={inspector.projectPath}
|
||||
@@ -289,7 +288,6 @@ function NodeInspector({
|
||||
onTransformChange,
|
||||
onLayoutChange,
|
||||
sprites,
|
||||
previewUrls,
|
||||
fonts,
|
||||
fontFaces,
|
||||
projectPath,
|
||||
@@ -319,7 +317,6 @@ function NodeInspector({
|
||||
onTransformChange: UiEditorInspectorProjection['setNodeTransform'];
|
||||
onLayoutChange: UiEditorInspectorProjection['setNodeLayout'];
|
||||
sprites: UiEditorInspectorProjection['sprites'];
|
||||
previewUrls: UiEditorInspectorProjection['previewUrls'];
|
||||
fonts: UiEditorInspectorProjection['fonts'];
|
||||
fontFaces: UiEditorInspectorProjection['fontFaces'];
|
||||
projectPath: string;
|
||||
@@ -500,7 +497,6 @@ function NodeInspector({
|
||||
<ComponentPanel
|
||||
components={node.components}
|
||||
sprites={sprites}
|
||||
previewUrls={previewUrls}
|
||||
fonts={fonts}
|
||||
fontFaces={fontFaces}
|
||||
projectPath={projectPath}
|
||||
|
||||
+38
-317
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crosshair,
|
||||
Info,
|
||||
Move,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
pageRectFromSize,
|
||||
@@ -34,7 +30,6 @@ export type TransformEditorProps = {
|
||||
};
|
||||
|
||||
type Axis = 0 | 1;
|
||||
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
type AnchorPreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -58,45 +53,6 @@ const ROW_MODE_LABELS: Record<AnchorMode, string> = {
|
||||
const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
|
||||
const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
|
||||
|
||||
const CORNERS: readonly {
|
||||
id: Corner;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ id: 'top-left', label: '左上角' },
|
||||
{ id: 'top-right', label: '右上角' },
|
||||
{ id: 'bottom-left', label: '左下角' },
|
||||
{ id: 'bottom-right', label: '右下角' },
|
||||
];
|
||||
|
||||
function CornerIcon({
|
||||
corner,
|
||||
active = false,
|
||||
}: {
|
||||
corner: Corner;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const paths: Record<Corner, string> = {
|
||||
'top-left': 'M5 11V5h6',
|
||||
'top-right': 'M13 11V5H7',
|
||||
'bottom-left': 'M5 9v6h6',
|
||||
'bottom-right': 'M13 9v6H7',
|
||||
};
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 18 20"
|
||||
className={`size-5 ${active ? 'text-(--platform-accent)' : 'text-(--platform-text-soft)'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={active ? 2.2 : 1.8}
|
||||
>
|
||||
<path d={paths[corner]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) =>
|
||||
COLUMN_MODES.map((x) => ({
|
||||
id: `${y}-${x}`,
|
||||
@@ -118,6 +74,10 @@ const FIELD_HINTS = {
|
||||
'锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。',
|
||||
anchor_max:
|
||||
'锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。',
|
||||
offset_min:
|
||||
'偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。',
|
||||
offset_max:
|
||||
'偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。',
|
||||
} as const;
|
||||
|
||||
function cloneTransform(transform: Transform): Transform {
|
||||
@@ -206,44 +166,9 @@ function formatValue(value: number): string {
|
||||
return String(Number(value.toFixed(2)));
|
||||
}
|
||||
|
||||
function cornerFields(corner: Corner): {
|
||||
x: 'offset_min' | 'offset_max';
|
||||
y: 'offset_min' | 'offset_max';
|
||||
} {
|
||||
switch (corner) {
|
||||
case 'top-left':
|
||||
return { x: 'offset_min', y: 'offset_min' };
|
||||
case 'top-right':
|
||||
return { x: 'offset_max', y: 'offset_min' };
|
||||
case 'bottom-left':
|
||||
return { x: 'offset_min', y: 'offset_max' };
|
||||
case 'bottom-right':
|
||||
return { x: 'offset_max', y: 'offset_max' };
|
||||
}
|
||||
}
|
||||
|
||||
function cornerValues(transform: Transform, corner: Corner): [number, number] {
|
||||
const fields = cornerFields(corner);
|
||||
return [transform[fields.x][0], transform[fields.y][1]];
|
||||
}
|
||||
|
||||
function updateCorner(
|
||||
transform: Transform,
|
||||
corner: Corner,
|
||||
axis: Axis,
|
||||
value: number,
|
||||
): Transform {
|
||||
const fields = cornerFields(corner);
|
||||
const next = cloneTransform(transform);
|
||||
next[axis === 0 ? fields.x : fields.y][axis] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
function VectorInputRow({
|
||||
label,
|
||||
hint,
|
||||
hideLabel = false,
|
||||
stacked = false,
|
||||
values,
|
||||
step,
|
||||
readOnly,
|
||||
@@ -251,45 +176,24 @@ function VectorInputRow({
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
hideLabel?: boolean;
|
||||
stacked?: boolean;
|
||||
values: readonly [number, number];
|
||||
step: number;
|
||||
readOnly: boolean;
|
||||
onCommit: (axis: Axis, value: number) => void;
|
||||
}) {
|
||||
const fields = AXIS_LABELS.map((axisLabel, axis) => (
|
||||
<ScalarInput
|
||||
key={axisLabel}
|
||||
ariaLabel={`${label} ${axisLabel}`}
|
||||
value={values[axis as Axis]}
|
||||
step={step}
|
||||
readOnly={readOnly}
|
||||
onCommit={(value) => onCommit(axis as Axis, value)}
|
||||
/>
|
||||
));
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
{hideLabel ? (
|
||||
<span className="sr-only">{label}</span>
|
||||
) : (
|
||||
<FieldLabel label={label} hint={hint} />
|
||||
)}
|
||||
<div className="grid min-w-0 gap-2">{fields}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,5rem)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2">
|
||||
{hideLabel ? (
|
||||
<span className="sr-only">{label}</span>
|
||||
) : (
|
||||
<FieldLabel label={label} hint={hint} />
|
||||
)}
|
||||
{fields}
|
||||
<FieldLabel label={label} hint={hint} />
|
||||
{AXIS_LABELS.map((axisLabel, axis) => (
|
||||
<ScalarInput
|
||||
key={axisLabel}
|
||||
ariaLabel={`${label} ${axisLabel}`}
|
||||
value={values[axis as Axis]}
|
||||
step={step}
|
||||
readOnly={readOnly}
|
||||
onCommit={(value) => onCommit(axis as Axis, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -297,7 +201,7 @@ function VectorInputRow({
|
||||
function FieldLabel({ label, hint }: { label: string; hint: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)"
|
||||
className="group/field relative inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)"
|
||||
title={hint}
|
||||
>
|
||||
{label}
|
||||
@@ -309,6 +213,12 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) {
|
||||
>
|
||||
<Info size={11} aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 z-40 hidden w-64 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-2.5 py-2 text-[10px] font-normal leading-relaxed text-(--platform-neutral-text) shadow-lg group-hover/field:block group-focus-within/field:block"
|
||||
>
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -410,105 +320,12 @@ function ScalarInput({
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionButton({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
onAdjust,
|
||||
children,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
onAdjust: (step: number) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const repeatTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const repeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const repeated = useRef(false);
|
||||
|
||||
const stopRepeating = () => {
|
||||
if (repeatTimeout.current) {
|
||||
clearTimeout(repeatTimeout.current);
|
||||
repeatTimeout.current = null;
|
||||
}
|
||||
if (repeatInterval.current) {
|
||||
clearInterval(repeatInterval.current);
|
||||
repeatInterval.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startRepeating = (multiplier: number) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
repeated.current = false;
|
||||
repeatTimeout.current = setTimeout(() => {
|
||||
repeated.current = true;
|
||||
onAdjust(multiplier);
|
||||
repeatInterval.current = setInterval(() => onAdjust(multiplier), 70);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (repeatTimeout.current) {
|
||||
clearTimeout(repeatTimeout.current);
|
||||
repeatTimeout.current = null;
|
||||
}
|
||||
if (repeatInterval.current) {
|
||||
clearInterval(repeatInterval.current);
|
||||
repeatInterval.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
className="grid size-8 place-items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 text-(--platform-text-soft) transition hover:border-orange-300 hover:bg-orange-50 hover:text-orange-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 disabled:cursor-default disabled:opacity-40"
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
startRepeating(event.shiftKey ? 10 : 1);
|
||||
}}
|
||||
onPointerUp={stopRepeating}
|
||||
onPointerCancel={() => {
|
||||
repeated.current = false;
|
||||
stopRepeating();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
repeated.current = false;
|
||||
stopRepeating();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!repeated.current) {
|
||||
onAdjust(event.shiftKey ? 10 : 1);
|
||||
}
|
||||
repeated.current = false;
|
||||
stopRepeating();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransformEditor({
|
||||
transform,
|
||||
parentSize,
|
||||
readOnly = false,
|
||||
onChange,
|
||||
}: TransformEditorProps) {
|
||||
const [selectedCorner, setSelectedCorner] = useState<Corner>('top-left');
|
||||
const transformRef = useRef(transform);
|
||||
const selectedCornerRef = useRef(selectedCorner);
|
||||
useEffect(() => {
|
||||
transformRef.current = transform;
|
||||
selectedCornerRef.current = selectedCorner;
|
||||
}, [transform, selectedCorner]);
|
||||
const [presetOpen, setPresetOpen] = useState(false);
|
||||
const [customOpen, setCustomOpen] = useState(
|
||||
() => findPreset(transform).id === CUSTOM_PRESET.id,
|
||||
@@ -552,24 +369,6 @@ export function TransformEditor({
|
||||
transform.anchor_min[1] > transform.anchor_max[1] ||
|
||||
geometry?.invalid;
|
||||
|
||||
const selectedCornerLabel =
|
||||
CORNERS.find((corner) => corner.id === selectedCorner)?.label ?? '左上角';
|
||||
const selectedCornerValues = cornerValues(transform, selectedCorner);
|
||||
const adjustSelectedCorner = (
|
||||
axis: Axis,
|
||||
direction: -1 | 1,
|
||||
multiplier = 1,
|
||||
) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const currentTransform = transformRef.current;
|
||||
const currentCorner = selectedCornerRef.current;
|
||||
const current = cornerValues(currentTransform, currentCorner)[axis];
|
||||
const next = Number((current + direction * multiplier).toFixed(4));
|
||||
onChange(updateCorner(currentTransform, currentCorner, axis, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-readonly={readOnly}
|
||||
@@ -724,100 +523,22 @@ export function TransformEditor({
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="位置微调"
|
||||
hint="调整当前选中角的原始 offset 值。"
|
||||
<VectorInputRow
|
||||
label="偏移最小值"
|
||||
hint={FIELD_HINTS.offset_min}
|
||||
values={transform.offset_min}
|
||||
step={1}
|
||||
readOnly={readOnly}
|
||||
onCommit={(axis, value) => updateVector('offset_min', axis, value)}
|
||||
/>
|
||||
<VectorInputRow
|
||||
label="偏移最大值"
|
||||
hint={FIELD_HINTS.offset_max}
|
||||
values={transform.offset_max}
|
||||
step={1}
|
||||
readOnly={readOnly}
|
||||
onCommit={(axis, value) => updateVector('offset_max', axis, value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[minmax(7rem,8rem)_minmax(0,1fr)] items-center gap-4">
|
||||
<div className="grid aspect-square w-full grid-cols-2 grid-rows-2 gap-1.5 justify-self-center rounded-2xl border border-(--platform-subpanel-border) bg-white/45 p-1.5">
|
||||
{CORNERS.map((corner) => {
|
||||
const active = corner.id === selectedCorner;
|
||||
return (
|
||||
<button
|
||||
key={corner.id}
|
||||
type="button"
|
||||
aria-label={corner.label}
|
||||
aria-pressed={active}
|
||||
disabled={readOnly}
|
||||
title={corner.label}
|
||||
className={`grid min-h-10 min-w-10 place-items-center rounded-xl border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-300 ${active ? 'border-(--platform-accent) bg-(--platform-warm-bg)' : 'border-transparent hover:border-(--platform-accent) hover:bg-(--platform-warm-bg)'}`}
|
||||
onClick={() => {
|
||||
selectedCornerRef.current = corner.id;
|
||||
setSelectedCorner(corner.id);
|
||||
}}
|
||||
>
|
||||
<CornerIcon corner={corner.id} active={active} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-3">
|
||||
<div
|
||||
role="group"
|
||||
className="mx-auto grid grid-cols-3 grid-rows-3 gap-1.5"
|
||||
aria-label="角点方向键"
|
||||
>
|
||||
<span />
|
||||
<DirectionButton
|
||||
ariaLabel={`${selectedCornerLabel}向上`}
|
||||
disabled={readOnly}
|
||||
onAdjust={(multiplier) =>
|
||||
adjustSelectedCorner(1, -1, multiplier * 1)
|
||||
}
|
||||
>
|
||||
<ArrowUp size={17} aria-hidden="true" />
|
||||
</DirectionButton>
|
||||
<span />
|
||||
<DirectionButton
|
||||
ariaLabel={`${selectedCornerLabel}向左`}
|
||||
disabled={readOnly}
|
||||
onAdjust={(multiplier) =>
|
||||
adjustSelectedCorner(0, -1, multiplier * 1)
|
||||
}
|
||||
>
|
||||
<ArrowLeft size={17} aria-hidden="true" />
|
||||
</DirectionButton>
|
||||
<div className="grid size-8 place-items-center rounded-lg bg-slate-100 text-(--platform-text-soft)">
|
||||
<CornerIcon corner={selectedCorner} />
|
||||
</div>
|
||||
<DirectionButton
|
||||
ariaLabel={`${selectedCornerLabel}向右`}
|
||||
disabled={readOnly}
|
||||
onAdjust={(multiplier) =>
|
||||
adjustSelectedCorner(0, 1, multiplier * 1)
|
||||
}
|
||||
>
|
||||
<ArrowRight size={17} aria-hidden="true" />
|
||||
</DirectionButton>
|
||||
<span />
|
||||
<DirectionButton
|
||||
ariaLabel={`${selectedCornerLabel}向下`}
|
||||
disabled={readOnly}
|
||||
onAdjust={(multiplier) =>
|
||||
adjustSelectedCorner(1, 1, multiplier * 1)
|
||||
}
|
||||
>
|
||||
<ArrowDown size={17} aria-hidden="true" />
|
||||
</DirectionButton>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<VectorInputRow
|
||||
label="位置微调"
|
||||
hideLabel
|
||||
hint="当前选中角的原始 offset 值。切换角点后,X/Y 会映射到对应的 offset_min 或 offset_max 分量。"
|
||||
values={selectedCornerValues}
|
||||
step={1}
|
||||
readOnly={readOnly}
|
||||
stacked
|
||||
onCommit={(axis, value) =>
|
||||
onChange(updateCorner(transform, selectedCorner, axis, value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
getStageStatusOverview,
|
||||
isBlocked,
|
||||
isNeedReview,
|
||||
} from '../../../features/ui-editor/stageStatusOverview';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UITree } from '../../../features/ui-editor/types/UITree';
|
||||
import { useUiTreeNodeCycle } from '../useUiTreeNodeCycle';
|
||||
|
||||
export function RecognitionOverview({
|
||||
uiTrees,
|
||||
onFocusStatusNode,
|
||||
}: {
|
||||
uiTrees: UITree[];
|
||||
onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
|
||||
}) {
|
||||
const overview = useMemo(
|
||||
() => getStageStatusOverview(uiTrees, 'layout_status'),
|
||||
[uiTrees],
|
||||
);
|
||||
const attentionCycle = useUiTreeNodeCycle({
|
||||
uiTrees,
|
||||
onFocusNode: onFocusStatusNode,
|
||||
matches: ({ node }) => {
|
||||
const status = node.metadata.layout_status;
|
||||
return isBlocked(status) || isNeedReview(status);
|
||||
},
|
||||
});
|
||||
const blockedCycle = useUiTreeNodeCycle({
|
||||
uiTrees,
|
||||
onFocusNode: onFocusStatusNode,
|
||||
matches: ({ node }) => isBlocked(node.metadata.layout_status),
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
|
||||
aria-label="识别概览"
|
||||
>
|
||||
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
Overview
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">识别概览</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<OverviewValue label="已识别 Node" value={overview.total} />
|
||||
<OverviewAction
|
||||
label="待用户检查"
|
||||
value={overview.needsAttention}
|
||||
tone="warning"
|
||||
disabled={overview.needsAttention === 0}
|
||||
onClick={attentionCycle.focusNext}
|
||||
/>
|
||||
<OverviewValue label="无问题" value={overview.noProblem} />
|
||||
<OverviewAction
|
||||
label="必须修复"
|
||||
value={overview.blocked}
|
||||
tone="danger"
|
||||
disabled={overview.blocked === 0}
|
||||
onClick={blockedCycle.focusNext}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewValue({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-(--platform-subpanel-border) bg-white/45 p-2 text-center">
|
||||
<strong className="block text-base font-semibold text-(--platform-text-strong)">
|
||||
{value}
|
||||
</strong>
|
||||
<span className="block text-[10px] text-(--platform-text-soft)">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewAction({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: 'warning' | 'danger';
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'border-red-200 bg-red-50 text-red-800 hover:border-red-300 hover:bg-red-100'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-900 hover:border-amber-300 hover:bg-amber-100';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-lg border p-2 text-center transition disabled:cursor-default disabled:opacity-45 ${toneClass}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={`${label} ${value},定位下一项`}
|
||||
>
|
||||
<strong className="block text-base font-semibold">{value}</strong>
|
||||
<span className="block text-[10px]">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function ToolNavigation({
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
className="grid shrink-0 grid-cols-2 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
|
||||
className="grid shrink-0 grid-cols-3 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
|
||||
aria-label="UI 编辑流程"
|
||||
>
|
||||
{UI_EDITOR_STEPS.map((step, index) => {
|
||||
|
||||
@@ -6,15 +6,23 @@ export function WorkflowActionCard({
|
||||
workflow: UiEditorWorkflowProjection;
|
||||
}) {
|
||||
const action = getStepAction(workflow);
|
||||
const isReferenceStep = workflow.activeStep === 'reference-analysis';
|
||||
const isCombinedStep = workflow.activeStep === 'structure-recognition';
|
||||
const running =
|
||||
(isReferenceStep && workflow.isSuggesting) ||
|
||||
(isCombinedStep && workflow.isBinding);
|
||||
const status = isReferenceStep
|
||||
? workflow.suggestionStatus
|
||||
: workflow.bindingStatus;
|
||||
const hasRun = isReferenceStep ? workflow.hasSuggested : workflow.hasBound;
|
||||
(workflow.activeStep === 'reference-analysis' && workflow.isSuggesting) ||
|
||||
(workflow.activeStep === 'structure-recognition' &&
|
||||
workflow.isRecognizing) ||
|
||||
(workflow.activeStep === 'visual-binding' && workflow.isBinding);
|
||||
const status =
|
||||
workflow.activeStep === 'reference-analysis'
|
||||
? workflow.suggestionStatus
|
||||
: workflow.activeStep === 'structure-recognition'
|
||||
? workflow.recognitionStatus
|
||||
: workflow.bindingStatus;
|
||||
const hasRun =
|
||||
workflow.activeStep === 'reference-analysis'
|
||||
? workflow.hasSuggested
|
||||
: workflow.activeStep === 'structure-recognition'
|
||||
? workflow.hasRecognized
|
||||
: workflow.hasBound;
|
||||
|
||||
return (
|
||||
<section className="shrink-0 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-4 py-3">
|
||||
@@ -62,14 +70,14 @@ function getStepAction(workflow: UiEditorWorkflowProjection) {
|
||||
}
|
||||
if (workflow.activeStep === 'structure-recognition') {
|
||||
return {
|
||||
label: '一键识别并绑定',
|
||||
runningLabel: '计算中…',
|
||||
action: workflow.bindComponents,
|
||||
label: '识别界面结构',
|
||||
runningLabel: '识别中…',
|
||||
action: workflow.recognizeUi,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: '一键识别并绑定',
|
||||
runningLabel: '计算中…',
|
||||
label: '绑定视觉素材',
|
||||
runningLabel: '绑定中…',
|
||||
action: workflow.bindComponents,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user