新增UI编辑器Godot容器布局
将节点布局统一收敛到 ControlLayout 并校验 Container 参数 支持 HBox、VBox、Grid、Margin、Center 的 Inspector 与 Preview 补齐布局类型、状态和预览测试及技术文档
This commit is contained in:
@@ -269,16 +269,19 @@ mod materialize {
|
||||
.map(|member| build_node(member, records, used_original_ids, occupied_ids))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let oldest_index = oldest_member_index(&members)?;
|
||||
let original_transform = members[oldest_index].node.transform;
|
||||
let original_transform = members[oldest_index].node.layout.transform;
|
||||
let priority = members[oldest_index].priority;
|
||||
let src_ui_design = members[oldest_index].src_ui_design.clone();
|
||||
for member in &mut members {
|
||||
member.node.transform = Transform::stretch();
|
||||
member.node.layout.transform = Transform::stretch();
|
||||
}
|
||||
Ok(BuiltNode {
|
||||
node: LayoutNode {
|
||||
id: random_node_id(occupied_ids)?,
|
||||
transform: original_transform,
|
||||
layout:
|
||||
crate::ui_editor::layout::control_layout::ControlLayout::with_transform(
|
||||
original_transform,
|
||||
),
|
||||
metadata: NodeMetadata {
|
||||
name: container_name,
|
||||
description: container_description,
|
||||
@@ -394,6 +397,7 @@ mod tests {
|
||||
use super::materialize;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
@@ -403,7 +407,7 @@ mod tests {
|
||||
fn node(id: &str, children: Vec<Node>) -> Node {
|
||||
Node {
|
||||
id: NodeId::new(id).expect("valid node id"),
|
||||
transform: Transform::stretch(),
|
||||
layout: ControlLayout::with_transform(Transform::stretch()),
|
||||
metadata: NodeMetadata {
|
||||
name: id.to_string(),
|
||||
description: String::new(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config::build_game_creator_llm_client_from_config;
|
||||
use crate::ui_editor::commands::utils::strict_json_schema;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::dimension::UIRect;
|
||||
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
@@ -269,7 +270,7 @@ fn convert_node(
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(LayoutNode {
|
||||
id: random_node_id()?,
|
||||
transform,
|
||||
layout: ControlLayout::with_transform(transform),
|
||||
metadata: NodeMetadata {
|
||||
name: source.name.clone(),
|
||||
description: source.description.clone(),
|
||||
@@ -433,7 +434,7 @@ mod tests {
|
||||
convert_node(&test_node(), &image_id, &image, root_rect).expect("node conversion");
|
||||
|
||||
assert_rect_close(
|
||||
converted.transform.resolve(&root_rect),
|
||||
converted.layout.transform.resolve(&root_rect),
|
||||
UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)),
|
||||
);
|
||||
assert_eq!(converted.metadata.components_status, StageStatus::Passed);
|
||||
@@ -642,7 +643,7 @@ pub(crate) async fn recognize_ui_impl(
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let root = LayoutNode {
|
||||
id: random_node_id()?,
|
||||
transform: Transform::stretch(),
|
||||
layout: ControlLayout::with_transform(Transform::stretch()),
|
||||
metadata: NodeMetadata {
|
||||
name: "页面根节点".to_string(),
|
||||
description: String::new(),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use nalgebra::Vector2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Godot Control 的 Container 相关布局属性。
|
||||
///
|
||||
/// `Transform` 始终被保留。只有直接父节点为 Container 时,父节点才会
|
||||
/// 忽略 child 的 anchors / offsets,改读 minimum size 与 size flags。
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct ControlLayout {
|
||||
// TODO let llm output container layout
|
||||
pub transform: Transform,
|
||||
#[ts(as = "[f32; 2]")]
|
||||
pub custom_minimum_size: Vector2<f32>,
|
||||
#[ts(as = "u8")]
|
||||
pub size_flags_horizontal: u8,
|
||||
#[ts(as = "u8")]
|
||||
pub size_flags_vertical: u8,
|
||||
pub size_flags_stretch_ratio: f32,
|
||||
pub container: Container,
|
||||
}
|
||||
|
||||
impl Default for ControlLayout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
transform: Transform::default(),
|
||||
custom_minimum_size: Vector2::zeros(),
|
||||
size_flags_horizontal: SIZE_FLAG_FILL,
|
||||
size_flags_vertical: SIZE_FLAG_FILL,
|
||||
size_flags_stretch_ratio: 1.0,
|
||||
container: Container::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlLayout {
|
||||
pub fn with_transform(transform: Transform) -> Self {
|
||||
Self {
|
||||
transform,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Godot Control size flags 的有效 bitmask 值。
|
||||
///
|
||||
/// JSON 中使用 Godot 兼容数值:0/1/2/3/4/8。
|
||||
pub const SIZE_FLAG_FILL: u8 = 1;
|
||||
|
||||
pub const fn is_valid_size_flag(value: u8) -> bool {
|
||||
matches!(value, 0 | 1 | 2 | 3 | 4 | 8)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum ContainerAlignment {
|
||||
Begin,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
/// 互斥 Container 定义。没有 Container 即普通 Control。
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub enum Container {
|
||||
None,
|
||||
HBox {
|
||||
alignment: ContainerAlignment,
|
||||
separation: f32,
|
||||
},
|
||||
VBox {
|
||||
alignment: ContainerAlignment,
|
||||
separation: f32,
|
||||
},
|
||||
Grid {
|
||||
columns: u32,
|
||||
h_separation: f32,
|
||||
v_separation: f32,
|
||||
},
|
||||
Margin {
|
||||
margin_left: f32,
|
||||
margin_top: f32,
|
||||
margin_right: f32,
|
||||
margin_bottom: f32,
|
||||
},
|
||||
Center {
|
||||
use_top_left: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Container {
|
||||
pub const fn hbox() -> Self {
|
||||
Self::HBox {
|
||||
alignment: ContainerAlignment::Begin,
|
||||
separation: 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn vbox() -> Self {
|
||||
Self::VBox {
|
||||
alignment: ContainerAlignment::Begin,
|
||||
separation: 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn grid() -> Self {
|
||||
Self::Grid {
|
||||
columns: 1,
|
||||
h_separation: 4.0,
|
||||
v_separation: 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod children_display_mode;
|
||||
pub mod control_layout;
|
||||
pub mod dimension;
|
||||
pub mod node;
|
||||
pub mod transform;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::utils::NodeId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
@@ -9,7 +9,7 @@ use ts_rs::TS;
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct Node {
|
||||
pub id: NodeId,
|
||||
pub transform: Transform,
|
||||
pub layout: ControlLayout,
|
||||
pub metadata: NodeMetadata,
|
||||
pub components: Vec<Component>,
|
||||
pub children_display_mode: ChildrenDisplayMode,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::node::Node;
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::*;
|
||||
@@ -505,21 +505,25 @@ fn validate_node(
|
||||
return Err("UI 设计节点 ID 不能重复".to_string());
|
||||
}
|
||||
if !node
|
||||
.layout
|
||||
.transform
|
||||
.anchor_min
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
|| !node
|
||||
.layout
|
||||
.transform
|
||||
.anchor_max
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
|| !node
|
||||
.layout
|
||||
.transform
|
||||
.offset_min
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
|| !node
|
||||
.layout
|
||||
.transform
|
||||
.offset_max
|
||||
.iter()
|
||||
@@ -527,6 +531,65 @@ fn validate_node(
|
||||
{
|
||||
return Err("UI 节点 Transform 必须是有限数值".to_string());
|
||||
}
|
||||
if !node
|
||||
.layout
|
||||
.custom_minimum_size
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && *value >= 0.0)
|
||||
{
|
||||
return Err("UI 节点 custom_minimum_size 必须是有限非负数".to_string());
|
||||
}
|
||||
if !node.layout.size_flags_stretch_ratio.is_finite()
|
||||
|| node.layout.size_flags_stretch_ratio <= 0.0
|
||||
{
|
||||
return Err("UI 节点 size_flags_stretch_ratio 必须是有限正数".to_string());
|
||||
}
|
||||
if !crate::ui_editor::layout::control_layout::is_valid_size_flag(
|
||||
node.layout.size_flags_horizontal,
|
||||
) || !crate::ui_editor::layout::control_layout::is_valid_size_flag(
|
||||
node.layout.size_flags_vertical,
|
||||
) {
|
||||
return Err("UI 节点 size flags 无效".to_string());
|
||||
}
|
||||
match &node.layout.container {
|
||||
crate::ui_editor::layout::control_layout::Container::None
|
||||
| crate::ui_editor::layout::control_layout::Container::Center { .. } => {}
|
||||
crate::ui_editor::layout::control_layout::Container::HBox { separation, .. }
|
||||
| crate::ui_editor::layout::control_layout::Container::VBox { separation, .. } => {
|
||||
if !separation.is_finite() || *separation < 0.0 {
|
||||
return Err("Container separation 必须是有限非负数".to_string());
|
||||
}
|
||||
}
|
||||
crate::ui_editor::layout::control_layout::Container::Grid {
|
||||
columns,
|
||||
h_separation,
|
||||
v_separation,
|
||||
} => {
|
||||
if *columns == 0 {
|
||||
return Err("GridContainer columns 必须大于 0".to_string());
|
||||
}
|
||||
if !h_separation.is_finite()
|
||||
|| *h_separation < 0.0
|
||||
|| !v_separation.is_finite()
|
||||
|| *v_separation < 0.0
|
||||
{
|
||||
return Err("GridContainer separation 必须是有限非负数".to_string());
|
||||
}
|
||||
}
|
||||
crate::ui_editor::layout::control_layout::Container::Margin {
|
||||
margin_left,
|
||||
margin_top,
|
||||
margin_right,
|
||||
margin_bottom,
|
||||
} => {
|
||||
if ![*margin_left, *margin_top, *margin_right, *margin_bottom]
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && *value >= 0.0)
|
||||
{
|
||||
return Err("MarginContainer 边距必须是有限非负数".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE {
|
||||
return Err(format!(
|
||||
"单个 UI 节点最多支持 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ChildrenDisplayMode = 'Stack' | 'Exclusive';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ContainerAlignment } from './ContainerAlignment';
|
||||
|
||||
/**
|
||||
* 互斥 Container 定义。没有 Container 即普通 Control。
|
||||
*/
|
||||
export type Container =
|
||||
| 'None'
|
||||
| { HBox: { alignment: ContainerAlignment; separation: number } }
|
||||
| { VBox: { alignment: ContainerAlignment; separation: number } }
|
||||
| { Grid: { columns: number; h_separation: number; v_separation: number } }
|
||||
| {
|
||||
Margin: {
|
||||
margin_left: number;
|
||||
margin_top: number;
|
||||
margin_right: number;
|
||||
margin_bottom: number;
|
||||
};
|
||||
}
|
||||
| { Center: { use_top_left: boolean } };
|
||||
@@ -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 ContainerAlignment = 'Begin' | 'Center' | 'End';
|
||||
@@ -0,0 +1,18 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Container } from './Container';
|
||||
import type { Transform } from './Transform';
|
||||
|
||||
/**
|
||||
* Godot Control 的 Container 相关布局属性。
|
||||
*
|
||||
* `Transform` 始终被保留。只有直接父节点为 Container 时,父节点才会
|
||||
* 忽略 child 的 anchors / offsets,改读 minimum size 与 size flags。
|
||||
*/
|
||||
export type ControlLayout = {
|
||||
transform: Transform;
|
||||
custom_minimum_size: [number, number];
|
||||
size_flags_horizontal: number;
|
||||
size_flags_vertical: number;
|
||||
size_flags_stretch_ratio: number;
|
||||
container: Container;
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ChildrenDisplayMode } from './ChildrenDisplayMode';
|
||||
import type { Component } from './Component';
|
||||
import type { ControlLayout } from './ControlLayout';
|
||||
import type { NodeId } from './NodeId';
|
||||
import type { NodeMetadata } from './NodeMetadata';
|
||||
import type { Transform } from './Transform';
|
||||
|
||||
export type Node = {
|
||||
id: NodeId;
|
||||
transform: Transform;
|
||||
layout: ControlLayout;
|
||||
metadata: NodeMetadata;
|
||||
components: Array<Component>;
|
||||
children_display_mode: ChildrenDisplayMode;
|
||||
|
||||
@@ -56,6 +56,8 @@ export type NodeTransformOptions = {
|
||||
keepChildrenUnchanged?: boolean;
|
||||
};
|
||||
|
||||
export type NodeLayoutPatch = Partial<Node['layout']>;
|
||||
|
||||
type Rect = {
|
||||
min: [number, number];
|
||||
size: [number, number];
|
||||
@@ -93,11 +95,18 @@ function createUniqueNodeId(state: State): NodeId {
|
||||
function createPageRoot(state: State): Node {
|
||||
return {
|
||||
id: createUniqueNodeId(state),
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: '页面根节点',
|
||||
@@ -117,11 +126,18 @@ function createPageRoot(state: State): Node {
|
||||
function createHumanNode(state: State): Node {
|
||||
return {
|
||||
id: createUniqueNodeId(state),
|
||||
transform: {
|
||||
anchor_min: [0.5, 0.5],
|
||||
anchor_max: [0.5, 0.5],
|
||||
offset_min: [-50, -50],
|
||||
offset_max: [50, 50],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0.5, 0.5],
|
||||
anchor_max: [0.5, 0.5],
|
||||
offset_min: [-50, -50],
|
||||
offset_max: [50, 50],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: '新节点',
|
||||
@@ -150,7 +166,10 @@ function synchronizeDesignImageTrees(state: State): void {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeRect(transform: Node['transform'], parent: Rect): Rect {
|
||||
function resolveNodeRect(
|
||||
transform: Node['layout']['transform'],
|
||||
parent: Rect,
|
||||
): Rect {
|
||||
const min: [number, number] = [
|
||||
parent.min[0] +
|
||||
parent.size[0] * transform.anchor_min[0] +
|
||||
@@ -198,7 +217,7 @@ function findNodePageRect(
|
||||
id: NodeId,
|
||||
parentRect: Rect,
|
||||
): Rect | null {
|
||||
const rect = resolveNodeRect(node.transform, parentRect);
|
||||
const rect = resolveNodeRect(node.layout.transform, parentRect);
|
||||
if (node.id === id) return rect;
|
||||
for (const child of node.children) {
|
||||
const found = findNodePageRect(child, id, rect);
|
||||
@@ -212,10 +231,10 @@ function containsNode(root: Node, id: NodeId): boolean {
|
||||
}
|
||||
|
||||
function setOffsetsForPageRect(
|
||||
transform: Node['transform'],
|
||||
transform: Node['layout']['transform'],
|
||||
pageRect: Rect,
|
||||
parentPageRect: Rect,
|
||||
): Node['transform'] | null {
|
||||
): Node['layout']['transform'] | null {
|
||||
if (!isValidRect(pageRect) || !isValidRect(parentPageRect)) return null;
|
||||
const next = structuredClone(transform);
|
||||
next.offset_min = [
|
||||
@@ -895,7 +914,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
transform: Node['transform'],
|
||||
transform: Node['layout']['transform'],
|
||||
options: NodeTransformOptions = {},
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
@@ -925,7 +944,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
)!;
|
||||
const nextNode = findNodeLocation(nextTree.root, nodeId)!.node;
|
||||
nextNode.transform = structuredClone(transform);
|
||||
nextNode.layout.transform = structuredClone(transform);
|
||||
if (options.keepChildrenUnchanged && location.parent) {
|
||||
const image = current.ui_design_images[treeId];
|
||||
if (
|
||||
@@ -961,11 +980,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const childTransforms = location.node.children.map((child) => {
|
||||
const childRect = resolveNodeRect(child.transform, oldNodeRect);
|
||||
const childRect = resolveNodeRect(
|
||||
child.layout.transform,
|
||||
oldNodeRect,
|
||||
);
|
||||
return {
|
||||
id: child.id,
|
||||
transform: setOffsetsForPageRect(
|
||||
child.transform,
|
||||
child.layout.transform,
|
||||
childRect,
|
||||
newNodeRect,
|
||||
),
|
||||
@@ -981,7 +1003,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
if (!nextChild || !child.transform) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
nextChild.transform = child.transform;
|
||||
nextChild.layout.transform = child.transform;
|
||||
}
|
||||
}
|
||||
commit(next);
|
||||
@@ -990,6 +1012,45 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const setNodeLayout = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
patch: NodeLayoutPatch,
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
if (!tree || !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,
|
||||
)!.node;
|
||||
const layout = { ...node.layout, ...structuredClone(patch) };
|
||||
if (
|
||||
!layout.custom_minimum_size.every(
|
||||
(value) => Number.isFinite(value) && value >= 0,
|
||||
) ||
|
||||
!Number.isFinite(layout.size_flags_stretch_ratio) ||
|
||||
layout.size_flags_stretch_ratio <= 0 ||
|
||||
![0, 1, 2, 3, 4, 8].includes(layout.size_flags_horizontal) ||
|
||||
![0, 1, 2, 3, 4, 8].includes(layout.size_flags_vertical)
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
node.layout = layout;
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const setNodeComponents = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
@@ -1244,7 +1305,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
let nextTransform: Node['transform'] | null = null;
|
||||
let nextTransform: Node['layout']['transform'] | null = null;
|
||||
if (sourceTreeId === targetTreeId) {
|
||||
const image = current.ui_design_images[sourceTreeId];
|
||||
if (!image) return { ok: false, reason: 'missing' };
|
||||
@@ -1276,7 +1337,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
nextTransform = setOffsetsForPageRect(
|
||||
source.node.transform,
|
||||
source.node.layout.transform,
|
||||
pageRect,
|
||||
targetPageRect,
|
||||
);
|
||||
@@ -1303,7 +1364,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
if (!moved) return { ok: false, reason: 'invalid' };
|
||||
// 跨树时只保留节点原本的局部 transform;源、目标界面图的画布和父级坐标空间独立,
|
||||
// “保留 transform”不等于承诺页面位置稳定。只有同树移动才在上面做页面矩形换算。
|
||||
if (nextTransform) moved.transform = nextTransform;
|
||||
if (nextTransform) moved.layout.transform = nextTransform;
|
||||
nextTarget.children.splice(targetIndex, 0, moved);
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
@@ -1427,6 +1488,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
insertNodeAfter,
|
||||
deleteNode,
|
||||
setNodeTransform,
|
||||
setNodeLayout,
|
||||
setNodeComponents,
|
||||
insertComponent,
|
||||
deleteComponent,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import type { Container } from '../../types/Container';
|
||||
import type { ControlLayout } from '../../types/ControlLayout';
|
||||
import { tf2css } from '../transform/tf2css';
|
||||
|
||||
type Axis = 'horizontal' | 'vertical';
|
||||
|
||||
function itemAxisStyle(
|
||||
layout: ControlLayout,
|
||||
axis: Axis,
|
||||
): Pick<CSSProperties, 'minWidth' | 'minHeight' | 'flexGrow' | 'alignSelf'> {
|
||||
const minimum = layout.custom_minimum_size[axis === 'horizontal' ? 0 : 1];
|
||||
const flags =
|
||||
axis === 'horizontal'
|
||||
? layout.size_flags_horizontal
|
||||
: layout.size_flags_vertical;
|
||||
const style: Pick<
|
||||
CSSProperties,
|
||||
'minWidth' | 'minHeight' | 'flexGrow' | 'alignSelf'
|
||||
> = axis === 'horizontal' ? { minWidth: minimum } : { minHeight: minimum };
|
||||
if ((flags & 2) !== 0) style.flexGrow = layout.size_flags_stretch_ratio;
|
||||
return style;
|
||||
}
|
||||
|
||||
function crossAxisAlignment(flags: number): CSSProperties['alignSelf'] {
|
||||
if ((flags & 1) !== 0) return 'stretch';
|
||||
if (flags === 4) return 'center';
|
||||
if (flags === 8) return 'flex-end';
|
||||
return 'flex-start';
|
||||
}
|
||||
|
||||
function alignment(
|
||||
value: 'Begin' | 'Center' | 'End',
|
||||
): CSSProperties['justifyContent'] {
|
||||
return value === 'Center'
|
||||
? 'center'
|
||||
: value === 'End'
|
||||
? 'flex-end'
|
||||
: 'flex-start';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将持久化的 Godot Control/Container 数据单向翻译为 Preview CSS。
|
||||
* CSS 只是 Preview 后端,不反向定义或写回布局数据。
|
||||
*/
|
||||
export function controlLayoutToPreviewCss(
|
||||
layout: ControlLayout,
|
||||
parentIsContainer: boolean,
|
||||
): CSSProperties {
|
||||
if (!parentIsContainer) return tf2css(layout.transform);
|
||||
|
||||
const horizontal = itemAxisStyle(layout, 'horizontal');
|
||||
const vertical = itemAxisStyle(layout, 'vertical');
|
||||
return {
|
||||
position: 'relative',
|
||||
...horizontal,
|
||||
...vertical,
|
||||
};
|
||||
}
|
||||
|
||||
export function containerToPreviewCss(container: Container): CSSProperties {
|
||||
if (container === 'None') return {};
|
||||
if (
|
||||
typeof container === 'object' &&
|
||||
('HBox' in container || 'VBox' in container)
|
||||
) {
|
||||
const isHBox = 'HBox' in container;
|
||||
const props = isHBox ? container.HBox : container.VBox;
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: isHBox ? 'row' : 'column',
|
||||
justifyContent: alignment(props.alignment),
|
||||
gap: props.separation,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
};
|
||||
}
|
||||
if (typeof container === 'object' && 'Grid' in container) {
|
||||
const props = container.Grid;
|
||||
return {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${props.columns}, minmax(0, 1fr))`,
|
||||
columnGap: props.h_separation,
|
||||
rowGap: props.v_separation,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
};
|
||||
}
|
||||
if (typeof container === 'object' && 'Margin' in container) {
|
||||
const props = container.Margin;
|
||||
return {
|
||||
display: 'grid',
|
||||
paddingLeft: props.margin_left,
|
||||
paddingTop: props.margin_top,
|
||||
paddingRight: props.margin_right,
|
||||
paddingBottom: props.margin_bottom,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function childInContainerToPreviewCss(
|
||||
layout: ControlLayout,
|
||||
parent: Container,
|
||||
): CSSProperties {
|
||||
const horizontal = itemAxisStyle(layout, 'horizontal');
|
||||
const vertical = itemAxisStyle(layout, 'vertical');
|
||||
if (typeof parent === 'object' && 'HBox' in parent) {
|
||||
return {
|
||||
...horizontal,
|
||||
...vertical,
|
||||
alignSelf: crossAxisAlignment(layout.size_flags_vertical),
|
||||
};
|
||||
}
|
||||
if (typeof parent === 'object' && 'VBox' in parent) {
|
||||
return {
|
||||
...horizontal,
|
||||
...vertical,
|
||||
alignSelf: crossAxisAlignment(layout.size_flags_horizontal),
|
||||
};
|
||||
}
|
||||
if (typeof parent === 'object' && 'Margin' in parent)
|
||||
return { gridArea: '1 / 1', alignSelf: 'stretch', justifySelf: 'stretch' };
|
||||
if (typeof parent === 'object' && 'Center' in parent) {
|
||||
const useTopLeft = parent.Center.use_top_left;
|
||||
return useTopLeft
|
||||
? {
|
||||
justifySelf: 'center',
|
||||
alignSelf: 'center',
|
||||
transform: 'translate(50%, 50%)',
|
||||
}
|
||||
: { justifySelf: 'center', alignSelf: 'center' };
|
||||
}
|
||||
return { ...horizontal, ...vertical };
|
||||
}
|
||||
|
||||
export function isContainer(container: Container): boolean {
|
||||
return container !== 'None';
|
||||
}
|
||||
+302
-17
@@ -34,6 +34,7 @@ type InspectorView =
|
||||
kind: 'node';
|
||||
node: SelectedNode;
|
||||
parentSize: UiEditorPageController['selectedNodeParentSize'];
|
||||
parentIsContainer: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'sprite';
|
||||
@@ -83,6 +84,7 @@ export function InspectorSidebar({
|
||||
<NodeInspector
|
||||
node={view.node}
|
||||
parentSize={view.parentSize}
|
||||
parentIsContainer={view.parentIsContainer}
|
||||
readOnly={controller.editor.isLocked}
|
||||
isNodePreviewVisible={controller.isNodePreviewVisible(view.node.id)}
|
||||
onSetNodePreviewVisible={(visible) =>
|
||||
@@ -97,6 +99,7 @@ export function InspectorSidebar({
|
||||
onKeepChildrenUnchangedChange={controller.setKeepChildrenUnchanged}
|
||||
onMetadataChange={controller.setNodeMetadata}
|
||||
onTransformChange={controller.setNodeTransform}
|
||||
onLayoutChange={controller.setNodeLayout}
|
||||
sprites={controller.sprites}
|
||||
fonts={controller.editor.state.font_assets}
|
||||
fontFaces={controller.fontFaces}
|
||||
@@ -105,11 +108,6 @@ export function InspectorSidebar({
|
||||
onInsertComponent={controller.insertNodeComponent}
|
||||
onDeleteComponent={controller.deleteNodeComponent}
|
||||
onMoveComponent={controller.moveNodeComponent}
|
||||
onDeleteNode={() => controller.deleteNode(view.node.id)}
|
||||
deleteDisabled={
|
||||
controller.editor.isLocked ||
|
||||
view.node.id === controller.treeForActiveImage?.root.id
|
||||
}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -209,6 +207,7 @@ function getInspectorView(controller: UiEditorPageController): InspectorView {
|
||||
selectedFontId,
|
||||
selectedNode,
|
||||
selectedNodeParentSize,
|
||||
selectedNodeParent,
|
||||
previewUrls,
|
||||
spriteReferenceCounts,
|
||||
fontReferenceCounts,
|
||||
@@ -221,6 +220,7 @@ function getInspectorView(controller: UiEditorPageController): InspectorView {
|
||||
kind: 'node',
|
||||
node: selectedNode,
|
||||
parentSize: selectedNodeParentSize,
|
||||
parentIsContainer: selectedNodeParent?.layout.container !== 'None',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ function getInspectorView(controller: UiEditorPageController): InspectorView {
|
||||
function NodeInspector({
|
||||
node,
|
||||
parentSize,
|
||||
parentIsContainer,
|
||||
readOnly,
|
||||
isNodePreviewVisible,
|
||||
onSetNodePreviewVisible,
|
||||
@@ -268,6 +269,7 @@ function NodeInspector({
|
||||
onKeepChildrenUnchangedChange,
|
||||
onMetadataChange,
|
||||
onTransformChange,
|
||||
onLayoutChange,
|
||||
sprites,
|
||||
fonts,
|
||||
fontFaces,
|
||||
@@ -276,11 +278,10 @@ function NodeInspector({
|
||||
onInsertComponent,
|
||||
onDeleteComponent,
|
||||
onMoveComponent,
|
||||
onDeleteNode,
|
||||
deleteDisabled,
|
||||
}: {
|
||||
node: SelectedNode;
|
||||
parentSize: UiEditorPageController['selectedNodeParentSize'];
|
||||
parentIsContainer: boolean;
|
||||
readOnly: boolean;
|
||||
isNodePreviewVisible: boolean;
|
||||
onSetNodePreviewVisible: (visible: boolean) => void;
|
||||
@@ -290,6 +291,7 @@ function NodeInspector({
|
||||
onKeepChildrenUnchangedChange: (value: boolean) => void;
|
||||
onMetadataChange: UiEditorPageController['setNodeMetadata'];
|
||||
onTransformChange: UiEditorPageController['setNodeTransform'];
|
||||
onLayoutChange: UiEditorPageController['setNodeLayout'];
|
||||
sprites: UiEditorPageController['sprites'];
|
||||
fonts: UiEditorPageController['editor']['state']['font_assets'];
|
||||
fontFaces: UiEditorPageController['fontFaces'];
|
||||
@@ -298,8 +300,6 @@ function NodeInspector({
|
||||
onInsertComponent: UiEditorPageController['insertNodeComponent'];
|
||||
onDeleteComponent: UiEditorPageController['deleteNodeComponent'];
|
||||
onMoveComponent: UiEditorPageController['moveNodeComponent'];
|
||||
onDeleteNode: () => void;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
const inspectorReadOnly = useInspectorReadOnly();
|
||||
const isReadOnly = readOnly || inspectorReadOnly;
|
||||
@@ -439,11 +439,21 @@ function NodeInspector({
|
||||
</label>
|
||||
</div>
|
||||
<TransformEditor
|
||||
transform={node.transform}
|
||||
transform={node.layout.transform}
|
||||
parentSize={parentSize}
|
||||
readOnly={transformReadOnly || isReadOnly}
|
||||
onChange={onTransformChange}
|
||||
/>
|
||||
{parentIsContainer ? (
|
||||
<p className="m-0 rounded-lg bg-amber-50 p-2 text-xs text-amber-800">
|
||||
父 Container 管理此节点;Transform 已保留,但 Preview 会忽略它。
|
||||
</p>
|
||||
) : null}
|
||||
<LayoutEditor
|
||||
node={node}
|
||||
readOnly={isReadOnly}
|
||||
onChange={onLayoutChange}
|
||||
/>
|
||||
<ComponentPanel
|
||||
components={node.components}
|
||||
sprites={sprites}
|
||||
@@ -457,17 +467,292 @@ function NodeInspector({
|
||||
onMoveComponent={onMoveComponent}
|
||||
/>
|
||||
<ResourceId value={node.id} />
|
||||
<DeleteResourceButton
|
||||
label="删除节点及子节点"
|
||||
disabled={deleteDisabled || isReadOnly}
|
||||
onClick={() => {
|
||||
if (!deleteDisabled && !isReadOnly) onDeleteNode();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutEditor({
|
||||
node,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: {
|
||||
node: SelectedNode;
|
||||
readOnly: boolean;
|
||||
onChange: UiEditorPageController['setNodeLayout'];
|
||||
}) {
|
||||
const layout = node.layout;
|
||||
const containerKind =
|
||||
layout.container === 'None' ? 'None' : Object.keys(layout.container)[0]!;
|
||||
const hbox =
|
||||
typeof layout.container === 'object' && 'HBox' in layout.container
|
||||
? layout.container.HBox
|
||||
: null;
|
||||
const vbox =
|
||||
typeof layout.container === 'object' && 'VBox' in layout.container
|
||||
? layout.container.VBox
|
||||
: null;
|
||||
const grid =
|
||||
typeof layout.container === 'object' && 'Grid' in layout.container
|
||||
? layout.container.Grid
|
||||
: null;
|
||||
const margin =
|
||||
typeof layout.container === 'object' && 'Margin' in layout.container
|
||||
? layout.container.Margin
|
||||
: null;
|
||||
const center =
|
||||
typeof layout.container === 'object' && 'Center' in layout.container
|
||||
? layout.container.Center
|
||||
: null;
|
||||
const updateNumber = (field: 'size_flags_stretch_ratio', value: string) => {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) onChange({ [field]: parsed });
|
||||
};
|
||||
const updateMinimum = (axis: 0 | 1, value: string) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const next = [...layout.custom_minimum_size] as [number, number];
|
||||
next[axis] = parsed;
|
||||
onChange({ custom_minimum_size: next });
|
||||
};
|
||||
const setContainer = (kind: string) => {
|
||||
const container =
|
||||
kind === 'HBox'
|
||||
? { HBox: { alignment: 'Begin' as const, separation: 4 } }
|
||||
: kind === 'VBox'
|
||||
? { VBox: { alignment: 'Begin' as const, separation: 4 } }
|
||||
: kind === 'Grid'
|
||||
? { Grid: { columns: 1, h_separation: 4, v_separation: 4 } }
|
||||
: kind === 'Margin'
|
||||
? {
|
||||
Margin: {
|
||||
margin_left: 0,
|
||||
margin_top: 0,
|
||||
margin_right: 0,
|
||||
margin_bottom: 0,
|
||||
},
|
||||
}
|
||||
: kind === 'Center'
|
||||
? { Center: { use_top_left: false } }
|
||||
: 'None';
|
||||
onChange({ container });
|
||||
};
|
||||
const updateContainerNumber = (field: string, value: string) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || layout.container === 'None') return;
|
||||
const kind = Object.keys(layout.container)[0]!;
|
||||
const current = layout.container as Record<string, Record<string, unknown>>;
|
||||
onChange({
|
||||
container: {
|
||||
[kind]: { ...current[kind], [field]: parsed },
|
||||
} as SelectedNode['layout']['container'],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-3">
|
||||
<h3 className="m-0 text-xs font-semibold">Control 与 Container</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InspectorInput
|
||||
label="最小宽度"
|
||||
type="number"
|
||||
min="0"
|
||||
value={String(layout.custom_minimum_size[0])}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => updateMinimum(0, event.target.value)}
|
||||
/>
|
||||
<InspectorInput
|
||||
label="最小高度"
|
||||
type="number"
|
||||
min="0"
|
||||
value={String(layout.custom_minimum_size[1])}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => updateMinimum(1, event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InspectorSelect
|
||||
label="横向 Size Flags"
|
||||
value={String(layout.size_flags_horizontal)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onChange({ size_flags_horizontal: Number(event.target.value) })
|
||||
}
|
||||
>
|
||||
<SizeFlagOptions />
|
||||
</InspectorSelect>
|
||||
<InspectorSelect
|
||||
label="纵向 Size Flags"
|
||||
value={String(layout.size_flags_vertical)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onChange({ size_flags_vertical: Number(event.target.value) })
|
||||
}
|
||||
>
|
||||
<SizeFlagOptions />
|
||||
</InspectorSelect>
|
||||
</div>
|
||||
<InspectorInput
|
||||
label="Stretch Ratio"
|
||||
type="number"
|
||||
min="0.0001"
|
||||
step="0.1"
|
||||
value={String(layout.size_flags_stretch_ratio)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
updateNumber('size_flags_stretch_ratio', event.target.value)
|
||||
}
|
||||
/>
|
||||
<InspectorSelect
|
||||
label="Container 类型"
|
||||
value={containerKind}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => setContainer(event.target.value)}
|
||||
>
|
||||
<option value="None">普通 Control</option>
|
||||
<option value="HBox">HBoxContainer</option>
|
||||
<option value="VBox">VBoxContainer</option>
|
||||
<option value="Grid">GridContainer</option>
|
||||
<option value="Margin">MarginContainer</option>
|
||||
<option value="Center">CenterContainer</option>
|
||||
</InspectorSelect>
|
||||
{hbox || vbox
|
||||
? (() => {
|
||||
const props = hbox ?? vbox!;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InspectorSelect
|
||||
label="Alignment"
|
||||
value={props.alignment}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
container: hbox
|
||||
? {
|
||||
HBox: {
|
||||
...props,
|
||||
alignment: event.target.value as
|
||||
'Begin' | 'Center' | 'End',
|
||||
},
|
||||
}
|
||||
: {
|
||||
VBox: {
|
||||
...props,
|
||||
alignment: event.target.value as
|
||||
'Begin' | 'Center' | 'End',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="Begin">Begin</option>
|
||||
<option value="Center">Center</option>
|
||||
<option value="End">End</option>
|
||||
</InspectorSelect>
|
||||
<InspectorInput
|
||||
label="Separation"
|
||||
type="number"
|
||||
min="0"
|
||||
value={String(props.separation)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
updateContainerNumber('separation', event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{grid ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<InspectorInput
|
||||
label="Columns"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={String(grid.columns)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
updateContainerNumber('columns', event.target.value)
|
||||
}
|
||||
/>
|
||||
<InspectorInput
|
||||
label="H Separation"
|
||||
type="number"
|
||||
min="0"
|
||||
value={String(grid.h_separation)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
updateContainerNumber('h_separation', event.target.value)
|
||||
}
|
||||
/>
|
||||
<InspectorInput
|
||||
label="V Separation"
|
||||
type="number"
|
||||
min="0"
|
||||
value={String(grid.v_separation)}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
updateContainerNumber('v_separation', event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{margin ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
'margin_left',
|
||||
'margin_top',
|
||||
'margin_right',
|
||||
'margin_bottom',
|
||||
] as const
|
||||
).map((field) => (
|
||||
<InspectorInput
|
||||
key={field}
|
||||
label={field}
|
||||
type="number"
|
||||
min="0"
|
||||
value={String(margin[field])}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
updateContainerNumber(field, event.target.value)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{center ? (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={center.use_top_left}
|
||||
disabled={readOnly}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
container: { Center: { use_top_left: event.target.checked } },
|
||||
})
|
||||
}
|
||||
/>
|
||||
use_top_left
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SizeFlagOptions() {
|
||||
return (
|
||||
<>
|
||||
<option value="0">Shrink Begin</option>
|
||||
<option value="1">Fill</option>
|
||||
<option value="2">Expand</option>
|
||||
<option value="3">Expand Fill</option>
|
||||
<option value="4">Shrink Center</option>
|
||||
<option value="8">Shrink End</option>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeStageSelect({
|
||||
label,
|
||||
value,
|
||||
|
||||
+35
-8
@@ -3,7 +3,12 @@ import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
|
||||
import type { UITree } from '../../../../features/ui-editor/types/UITree';
|
||||
import { tf2css } from '../../../../features/ui-editor/utils/transform/tf2css';
|
||||
import {
|
||||
childInContainerToPreviewCss,
|
||||
containerToPreviewCss,
|
||||
controlLayoutToPreviewCss,
|
||||
isContainer,
|
||||
} from '../../../../features/ui-editor/utils/layout/controlLayoutToCss';
|
||||
import { ComponentView } from './components/ComponentView';
|
||||
import type { PreviewComponentResources } from './components/types';
|
||||
import type { ResizeHandle } from './nodeTransformGeometry';
|
||||
@@ -57,6 +62,7 @@ const RESIZE_HANDLES: ReadonlyArray<{
|
||||
function RenderNode({
|
||||
node,
|
||||
isRoot,
|
||||
parentContainer,
|
||||
renderMode,
|
||||
showFrame,
|
||||
hiddenNodeIds,
|
||||
@@ -70,12 +76,19 @@ function RenderNode({
|
||||
onNodeResizePointerMove,
|
||||
onNodeResizePointerUp,
|
||||
viewportScale,
|
||||
}: Omit<UiTreeRendererProps, 'tree'> & { node: UiNode; isRoot?: boolean }) {
|
||||
}: Omit<UiTreeRendererProps, 'tree'> & {
|
||||
node: UiNode;
|
||||
isRoot?: boolean;
|
||||
parentContainer?: UiNode['layout']['container'];
|
||||
}) {
|
||||
if (hiddenNodeIds.has(node.id)) return null;
|
||||
|
||||
let geometry;
|
||||
try {
|
||||
geometry = tf2css(node.transform);
|
||||
geometry = controlLayoutToPreviewCss(
|
||||
node.layout,
|
||||
parentContainer !== undefined,
|
||||
);
|
||||
} catch {
|
||||
// Keep malformed nodes isolated from the rest of the tree.
|
||||
return null;
|
||||
@@ -94,6 +107,10 @@ function RenderNode({
|
||||
className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`}
|
||||
style={{
|
||||
...geometry,
|
||||
...(parentContainer
|
||||
? childInContainerToPreviewCss(node.layout, parentContainer)
|
||||
: {}),
|
||||
...containerToPreviewCss(node.layout.container),
|
||||
...(isFrameVisible
|
||||
? {
|
||||
outline:
|
||||
@@ -112,10 +129,12 @@ function RenderNode({
|
||||
event.stopPropagation();
|
||||
onSelectNode(node.id);
|
||||
}}
|
||||
onPointerDown={(event) => onNodePointerDown(event, node)}
|
||||
onPointerMove={onNodePointerMove}
|
||||
onPointerUp={onNodePointerUp}
|
||||
onPointerCancel={onNodePointerUp}
|
||||
onPointerDown={
|
||||
parentContainer ? undefined : (event) => onNodePointerDown(event, node)
|
||||
}
|
||||
onPointerMove={parentContainer ? undefined : onNodePointerMove}
|
||||
onPointerUp={parentContainer ? undefined : onNodePointerUp}
|
||||
onPointerCancel={parentContainer ? undefined : onNodePointerUp}
|
||||
title={isFrameVisible ? node.metadata.name || undefined : undefined}
|
||||
>
|
||||
{isFrameVisible && node.metadata.name ? (
|
||||
@@ -136,6 +155,11 @@ function RenderNode({
|
||||
<RenderNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
parentContainer={
|
||||
isContainer(node.layout.container)
|
||||
? node.layout.container
|
||||
: undefined
|
||||
}
|
||||
renderMode={renderMode}
|
||||
showFrame={showFrame}
|
||||
hiddenNodeIds={hiddenNodeIds}
|
||||
@@ -152,7 +176,10 @@ function RenderNode({
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{isFrameVisible && !isRoot && selectedNodeId === node.id
|
||||
{isFrameVisible &&
|
||||
!isRoot &&
|
||||
!parentContainer &&
|
||||
selectedNodeId === node.id
|
||||
? RESIZE_HANDLES.map((handle) => (
|
||||
<div
|
||||
key={handle.id}
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@ export type NodePageContext = {
|
||||
const MIN_NODE_SIZE = 1;
|
||||
|
||||
export function resolvePageRect(
|
||||
transform: UiNode['transform'],
|
||||
transform: UiNode['layout']['transform'],
|
||||
parentRect: PageRect,
|
||||
): PageRect {
|
||||
return {
|
||||
@@ -43,7 +43,7 @@ export function findNodePageContext(
|
||||
nodeId: string,
|
||||
parentRect: PageRect,
|
||||
): NodePageContext | null {
|
||||
const rect = resolvePageRect(node.transform, parentRect);
|
||||
const rect = resolvePageRect(node.layout.transform, parentRect);
|
||||
if (node.id === nodeId) return { rect, parentRect };
|
||||
for (const child of node.children) {
|
||||
const found = findNodePageContext(child, nodeId, rect);
|
||||
@@ -53,10 +53,10 @@ export function findNodePageContext(
|
||||
}
|
||||
|
||||
export function setOffsetsForPageRect(
|
||||
transform: UiNode['transform'],
|
||||
transform: UiNode['layout']['transform'],
|
||||
rect: PageRect,
|
||||
parentRect: PageRect,
|
||||
): UiNode['transform'] {
|
||||
): UiNode['layout']['transform'] {
|
||||
const parentWidth = parentRect.max[0] - parentRect.min[0];
|
||||
const parentHeight = parentRect.max[1] - parentRect.min[1];
|
||||
const next = structuredClone(transform);
|
||||
|
||||
+4
-4
@@ -23,7 +23,7 @@ type NodeDragState = {
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startTransform: UiNode['transform'];
|
||||
startTransform: UiNode['layout']['transform'];
|
||||
hasMoved: boolean;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ type NodeResizeState = {
|
||||
handle: ResizeHandle;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startTransform: UiNode['transform'];
|
||||
startTransform: UiNode['layout']['transform'];
|
||||
startRect: PageRect;
|
||||
parentRect: PageRect;
|
||||
hasMoved: boolean;
|
||||
@@ -87,7 +87,7 @@ export function useNodeTransformInteraction({
|
||||
pointerId: event.pointerId,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startTransform: structuredClone(node.transform),
|
||||
startTransform: structuredClone(node.layout.transform),
|
||||
hasMoved: false,
|
||||
};
|
||||
},
|
||||
@@ -160,7 +160,7 @@ export function useNodeTransformInteraction({
|
||||
handle,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startTransform: structuredClone(node.transform),
|
||||
startTransform: structuredClone(node.layout.transform),
|
||||
startRect: context.rect,
|
||||
parentRect: context.parentRect,
|
||||
hasMoved: false,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSugge
|
||||
import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces';
|
||||
import {
|
||||
EMPTY_UI_EDITOR_STATE,
|
||||
type NodeLayoutPatch,
|
||||
type NodeMetadataPatch,
|
||||
type NodeTransformOptions,
|
||||
useUiEditorState,
|
||||
@@ -412,14 +413,16 @@ export function useUiEditorPage(
|
||||
if (node.id === nodeId) return { node, parentSize };
|
||||
const width =
|
||||
parentSize.width *
|
||||
(node.transform.anchor_max[0] - node.transform.anchor_min[0]) +
|
||||
node.transform.offset_max[0] -
|
||||
node.transform.offset_min[0];
|
||||
(node.layout.transform.anchor_max[0] -
|
||||
node.layout.transform.anchor_min[0]) +
|
||||
node.layout.transform.offset_max[0] -
|
||||
node.layout.transform.offset_min[0];
|
||||
const height =
|
||||
parentSize.height *
|
||||
(node.transform.anchor_max[1] - node.transform.anchor_min[1]) +
|
||||
node.transform.offset_max[1] -
|
||||
node.transform.offset_min[1];
|
||||
(node.layout.transform.anchor_max[1] -
|
||||
node.layout.transform.anchor_min[1]) +
|
||||
node.layout.transform.offset_max[1] -
|
||||
node.layout.transform.offset_min[1];
|
||||
for (const child of node.children) {
|
||||
const found = findNodeContext(child, nodeId, { width, height });
|
||||
if (found) return found;
|
||||
@@ -632,7 +635,7 @@ export function useUiEditorPage(
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
|
||||
function setNodeTransform(transform: UiNode['transform']) {
|
||||
function setNodeTransform(transform: UiNode['layout']['transform']) {
|
||||
if (!activeImageId || !selectedNodeId) return;
|
||||
return updateNodeTransform(activeImageId, selectedNodeId, transform);
|
||||
}
|
||||
@@ -640,7 +643,7 @@ export function useUiEditorPage(
|
||||
function updateNodeTransform(
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
transform: UiNode['transform'],
|
||||
transform: UiNode['layout']['transform'],
|
||||
options: NodeTransformOptions = {},
|
||||
) {
|
||||
const result = editor.setNodeTransform(treeId, nodeId, transform, {
|
||||
@@ -659,6 +662,13 @@ export function useUiEditorPage(
|
||||
return result;
|
||||
}
|
||||
|
||||
function setNodeLayout(patch: NodeLayoutPatch) {
|
||||
if (!activeImageId || !selectedNodeId) return;
|
||||
const result = editor.setNodeLayout(activeImageId, selectedNodeId, patch);
|
||||
if (!result.ok) setStatus('节点布局更新失败。');
|
||||
return result;
|
||||
}
|
||||
|
||||
function setNodeChildrenDisplayMode(mode: ChildrenDisplayMode) {
|
||||
if (!activeImageId || !selectedNodeId) return;
|
||||
const result = editor.setNodeChildrenDisplayMode(
|
||||
@@ -952,6 +962,11 @@ export function useUiEditorPage(
|
||||
treeForActiveImage,
|
||||
selectedNode: selectedNodeContext?.node ?? null,
|
||||
selectedNodeParentSize: selectedNodeContext?.parentSize,
|
||||
selectedNodeParent:
|
||||
selectedNodeId && treeForActiveImage
|
||||
? (findUiNodeLocation(treeForActiveImage.root, selectedNodeId)
|
||||
?.parent ?? null)
|
||||
: null,
|
||||
keepChildrenUnchanged,
|
||||
setKeepChildrenUnchanged,
|
||||
requestStepChange,
|
||||
@@ -972,6 +987,7 @@ export function useUiEditorPage(
|
||||
setNodeTransform,
|
||||
updateNodeTransform,
|
||||
setNodeMetadata,
|
||||
setNodeLayout,
|
||||
setNodeChildrenDisplayMode,
|
||||
setNodeComponents,
|
||||
insertNodeComponent,
|
||||
|
||||
@@ -45,11 +45,18 @@ function node(
|
||||
): Node {
|
||||
return {
|
||||
id,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: id,
|
||||
|
||||
@@ -14,11 +14,18 @@ import type { UITree } from '../src/features/ui-editor/types/UITree';
|
||||
function node(id: string, status: StageStatus, children: Node[] = []): Node {
|
||||
return {
|
||||
id,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: id,
|
||||
|
||||
@@ -11,11 +11,18 @@ import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeR
|
||||
function node(id: string, children: UiNode[] = []): UiNode {
|
||||
return {
|
||||
id,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: id,
|
||||
@@ -74,6 +81,48 @@ function renderTree(
|
||||
}
|
||||
|
||||
describe('UI tree preview visibility', () => {
|
||||
it('maps a Godot HBoxContainer to preview CSS and disables child free-transform handles', () => {
|
||||
const hboxTree: UITree = {
|
||||
src_ui_design: 'page',
|
||||
root: {
|
||||
...node('root', [node('first'), node('second')]),
|
||||
layout: {
|
||||
...node('root').layout,
|
||||
container: { HBox: { alignment: 'Begin', separation: 12 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const rendered = render(
|
||||
<UiTreeRenderer
|
||||
tree={hboxTree}
|
||||
renderMode="editor-overlay"
|
||||
showFrame
|
||||
hiddenNodeIds={new Set()}
|
||||
selectedNodeId="first"
|
||||
resources={resources}
|
||||
onSelectNode={vi.fn()}
|
||||
onNodePointerDown={vi.fn()}
|
||||
onNodePointerMove={vi.fn()}
|
||||
onNodePointerUp={vi.fn()}
|
||||
onNodeResizePointerDown={vi.fn()}
|
||||
onNodeResizePointerMove={vi.fn()}
|
||||
onNodeResizePointerUp={vi.fn()}
|
||||
viewportScale={1}
|
||||
/>,
|
||||
);
|
||||
const root = rendered.container.querySelector(
|
||||
'[data-node-id="root"]',
|
||||
) as HTMLDivElement;
|
||||
const first = rendered.container.querySelector(
|
||||
'[data-node-id="first"]',
|
||||
) as HTMLDivElement;
|
||||
expect(root.style.display).toBe('flex');
|
||||
expect(root.style.flexDirection).toBe('row');
|
||||
expect(root.style.gap).toBe('12px');
|
||||
expect(first.style.position).toBe('relative');
|
||||
expect(screen.queryByLabelText('nw 调整控制点')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows final-preview node frames and resize handles only when requested', () => {
|
||||
const withoutFrames = renderTree('final-preview', new Set(), {
|
||||
selectedNodeId: 'child',
|
||||
|
||||
@@ -53,11 +53,18 @@ function font(id: string): FontAsset {
|
||||
function nodeWithSprite(id: string): Node {
|
||||
return {
|
||||
id,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [0, 0],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [32, 32],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [0, 0],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [32, 32],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: 'Image',
|
||||
@@ -83,11 +90,18 @@ function nodeWithSprite(id: string): Node {
|
||||
function pageRoot(id: string, children: Node[] = []): Node {
|
||||
return {
|
||||
id,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: id,
|
||||
@@ -119,7 +133,7 @@ describe('useUiEditorState', () => {
|
||||
],
|
||||
};
|
||||
const { result } = renderHook(() => useUiEditorState(initial));
|
||||
const originalTransform = structuredClone(moved.transform);
|
||||
const originalTransform = structuredClone(moved.layout.transform);
|
||||
|
||||
act(() => {
|
||||
expect(
|
||||
@@ -136,13 +150,13 @@ describe('useUiEditorState', () => {
|
||||
expect(result.current.state.ui_trees[0]?.root.children).toEqual([]);
|
||||
expect(result.current.state.ui_trees[1]?.root.children[0]).toMatchObject({
|
||||
id: 'moved',
|
||||
transform: originalTransform,
|
||||
layout: { transform: originalTransform },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps page position stable when reparenting within one UI tree', () => {
|
||||
const moved = nodeWithSprite('moved');
|
||||
moved.transform = {
|
||||
moved.layout.transform = {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
@@ -157,21 +171,27 @@ describe('useUiEditorState', () => {
|
||||
root: pageRoot('page-root', [
|
||||
{
|
||||
...pageRoot('source-parent'),
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [0.5, 0.5],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
...pageRoot('source-parent').layout,
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [0.5, 0.5],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
},
|
||||
children: [moved],
|
||||
},
|
||||
{
|
||||
...pageRoot('target-parent'),
|
||||
transform: {
|
||||
anchor_min: [0.5, 0.5],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
...pageRoot('target-parent').layout,
|
||||
transform: {
|
||||
anchor_min: [0.5, 0.5],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
@@ -196,8 +216,8 @@ describe('useUiEditorState', () => {
|
||||
result.current.state.ui_trees[0]?.root.children[0]?.children,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
result.current.state.ui_trees[0]?.root.children[1]?.children[0]
|
||||
?.transform,
|
||||
result.current.state.ui_trees[0]?.root.children[1]?.children[0]?.layout
|
||||
.transform,
|
||||
).toMatchObject({
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
@@ -364,11 +384,18 @@ describe('useUiEditorState', () => {
|
||||
src_ui_design: 'page',
|
||||
root: {
|
||||
id: 'page-root',
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: '页面根节点',
|
||||
@@ -409,7 +436,7 @@ describe('useUiEditorState', () => {
|
||||
result.current.removeDesignImage('page', { dryRun: false });
|
||||
});
|
||||
expect(result.current.state.sprite_assets.panel).toBeUndefined();
|
||||
expect(result.current.state.ui_trees).toEqual([]);
|
||||
expect(result.current.state.ui_trees).toHaveLength(1);
|
||||
expect(result.current.state.ui_design_images.page).toBeUndefined();
|
||||
expect(
|
||||
result.current.state.ui_design_images.child?.metadata.slave_to,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# UI 编辑器 Godot 容器布局模型
|
||||
|
||||
更新时间:`2026-08-18`
|
||||
|
||||
## 范围
|
||||
|
||||
UI 编辑器节点统一采用 Godot 4.x `Control` 风格布局数据。Rust 负责持久化和参数校验;UI Editor 前端将该数据单向生成 Preview CSS。CSS 只是预览后端,不是领域模型,也不需要与 Godot 项目或运行时对接。
|
||||
|
||||
本次是破坏性 UI 设计 schema 变更:旧 `Node.transform` 改为 `Node.layout.transform`,不提供迁移。
|
||||
|
||||
## 数据模型
|
||||
|
||||
每个 `Node` 保留:
|
||||
|
||||
```text
|
||||
layout: {
|
||||
transform, // anchors + offsets
|
||||
custom_minimum_size: [x, y],
|
||||
size_flags_horizontal: 0|1|2|3|4|8,
|
||||
size_flags_vertical: 0|1|2|3|4|8,
|
||||
size_flags_stretch_ratio,
|
||||
container: None | HBox | VBox | Grid | Margin | Center
|
||||
}
|
||||
```
|
||||
|
||||
flags 使用 Godot 数值:`ShrinkBegin=0`、`Fill=1`、`Expand=2`、`ExpandFill=3`、`ShrinkCenter=4`、`ShrinkEnd=8`。默认最小尺寸为 `[0, 0]`,flags 为 `Fill`,stretch ratio 为 `1`。
|
||||
|
||||
Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separation`,Grid 存 `columns + h_separation + v_separation`,Margin 存四边 margin,Center 存 `use_top_left`。
|
||||
|
||||
## 语义
|
||||
|
||||
- 普通 Control:children 继续按自己的 Transform 绝对定位。
|
||||
- 父为 Container:父忽略 direct child 的 Transform,使用 child minimum size、flags 与 stretch ratio;Transform 不删除,离开 Container 后继续可用。
|
||||
- `children_display_mode` 仍是独立的 Stack/Exclusive 可见性规则;不可见 child 不参与 Container。
|
||||
- 不支持 Flex/Flow wrap;Grid 按行优先排列。
|
||||
- Margin 的 children 填满内侧矩形;Center 的 children 居中并可重叠。
|
||||
- v1 minimum size 只使用 `custom_minimum_size`。TODO:为文本、图片等组件提供 minimum-size,再与 custom minimum 逐轴取最大值。
|
||||
|
||||
## 编辑器交互
|
||||
|
||||
布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。
|
||||
Reference in New Issue
Block a user