新增UI编辑器Godot容器布局

将节点布局统一收敛到 ControlLayout 并校验 Container 参数

支持 HBox、VBox、Grid、Margin、Center 的 Inspector 与 Preview

补齐布局类型、状态和预览测试及技术文档
This commit is contained in:
2026-08-19 10:56:27 +08:00
parent 9eb47f637b
commit 03e513324f
23 changed files with 1015 additions and 121 deletions
@@ -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,12 +95,19 @@ function createUniqueNodeId(state: State): NodeId {
function createPageRoot(state: State): Node {
return {
id: createUniqueNodeId(state),
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: '页面根节点',
description: '',
@@ -117,12 +126,19 @@ function createPageRoot(state: State): Node {
function createHumanNode(state: State): Node {
return {
id: createUniqueNodeId(state),
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: '新节点',
description: '',
@@ -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';
}
@@ -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,14 +467,289 @@ 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>
</>
);
}
@@ -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}
@@ -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);
@@ -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,12 +45,19 @@ function node(
): Node {
return {
id,
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,
description: '',
@@ -14,12 +14,19 @@ import type { UITree } from '../src/features/ui-editor/types/UITree';
function node(id: string, status: StageStatus, children: Node[] = []): Node {
return {
id,
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,
description: '',
@@ -11,12 +11,19 @@ import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeR
function node(id: string, children: UiNode[] = []): UiNode {
return {
id,
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,
description: '',
@@ -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,12 +53,19 @@ function font(id: string): FontAsset {
function nodeWithSprite(id: string): Node {
return {
id,
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',
description: '',
@@ -83,12 +90,19 @@ function nodeWithSprite(id: string): Node {
function pageRoot(id: string, children: Node[] = []): Node {
return {
id,
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,
description: '',
@@ -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,16 +171,21 @@ describe('useUiEditorState', () => {
root: pageRoot('page-root', [
{
...pageRoot('source-parent'),
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'),
layout: {
...pageRoot('target-parent').layout,
transform: {
anchor_min: [0.5, 0.5],
anchor_max: [1, 1],
@@ -174,6 +193,7 @@ describe('useUiEditorState', () => {
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,12 +384,19 @@ describe('useUiEditorState', () => {
src_ui_design: 'page',
root: {
id: 'page-root',
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: '页面根节点',
description: '',
@@ -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 unionHBox/VBox 存 `alignment + separation`Grid 存 `columns + h_separation + v_separation`Margin 存四边 marginCenter 存 `use_top_left`
## 语义
- 普通 Controlchildren 继续按自己的 Transform 绝对定位。
- 父为 Container:父忽略 direct child 的 Transform,使用 child minimum size、flags 与 stretch ratioTransform 不删除,离开 Container 后继续可用。
- `children_display_mode` 仍是独立的 Stack/Exclusive 可见性规则;不可见 child 不参与 Container。
- 不支持 Flex/Flow wrapGrid 按行优先排列。
- Margin 的 children 填满内侧矩形;Center 的 children 居中并可重叠。
- v1 minimum size 只使用 `custom_minimum_size`。TODO:为文本、图片等组件提供 minimum-size,再与 custom minimum 逐轴取最大值。
## 编辑器交互
布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。