实现 UI 编辑器子节点互斥显示

新增 Stack / Exclusive 子节点显示模式及 Rust/TypeScript DTO。

Merged 物化自动使用 Exclusive,识别和手动节点默认使用 Stack。

拆分预览可见性 hook 与互斥子节点 Tab 组件,统一 Tree Panel 和最终预览状态。

补充 Inspector 模式选择、渲染规则、同步测试和技术方案文档。
This commit is contained in:
2026-08-18 15:15:58 +08:00
parent e88cb6ed9e
commit 3012519911
20 changed files with 527 additions and 40 deletions
@@ -159,6 +159,7 @@ mod priority {
mod materialize {
use super::llm_contract::Node;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::node::{
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
};
@@ -288,6 +289,7 @@ mod materialize {
source: NodeSource::Llm,
},
components: Vec::new(),
children_display_mode: ChildrenDisplayMode::Exclusive,
children: members.into_iter().map(|member| member.node).collect(),
},
priority,
@@ -385,3 +387,70 @@ pub(crate) async fn merge_ui_impl(state: State) -> Result<MergeDTO, String> {
})?;
Ok(MergeDTO { ui_tree })
}
#[cfg(test)]
mod tests {
use super::llm_contract::{MergedNode, Node as PlanNode, SimpleNode};
use super::materialize;
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
use std::collections::HashMap;
fn node(id: &str, children: Vec<Node>) -> Node {
Node {
id: NodeId::new(id).expect("valid node id"),
transform: Transform::stretch(),
metadata: NodeMetadata {
name: id.to_string(),
description: String::new(),
layout_status: StageStatus::Passed,
components_status: StageStatus::Passed,
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: NodeSource::Human,
},
components: Vec::<Component>::new(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
}
}
#[test]
fn merged_materialization_sets_exclusive_children_display_mode() {
let page_id = UIDesignImageId::new("page").expect("valid page id");
let state = State {
ui_trees: vec![UITree {
src_ui_design: page_id.clone(),
root: node("root", vec![node("a", vec![]), node("b", vec![])]),
}],
ui_design_images: HashMap::new(),
sprite_assets: HashMap::new(),
font_assets: HashMap::new(),
};
let plan = PlanNode::Merged(MergedNode {
name: "状态容器".to_string(),
description: String::new(),
merged_from: vec![
PlanNode::Simple(SimpleNode {
id: NodeId::new("a").expect("valid node id"),
children: vec![],
}),
PlanNode::Simple(SimpleNode {
id: NodeId::new("b").expect("valid node id"),
children: vec![],
}),
],
});
let result = materialize::plan(plan, &state, &[1]).expect("materialize plan");
assert_eq!(
result.root.children_display_mode,
ChildrenDisplayMode::Exclusive
);
assert_eq!(result.root.children.len(), 2);
}
}
@@ -1,5 +1,6 @@
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::dimension::UIRect;
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::transform::Transform;
@@ -280,6 +281,7 @@ fn convert_node(
},
// TODO: 等识别 DTO 增加 type 字段后,再映射 Image/Text Component。
components: Vec::new(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
})
}
@@ -651,6 +653,7 @@ pub(crate) async fn recognize_ui_impl(
},
// TODO: root components can host page-level background content once component preview is supported.
components: Vec::new(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
};
// Tree identity and root identity are assigned by Rust, never chosen by the model.
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum ChildrenDisplayMode {
Stack,
Exclusive,
}
@@ -1,3 +1,4 @@
pub mod children_display_mode;
pub mod dimension;
pub mod node;
pub mod transform;
@@ -1,4 +1,5 @@
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::utils::NodeId;
use serde::{Deserialize, Serialize};
@@ -11,6 +12,7 @@ pub struct Node {
pub transform: Transform,
pub metadata: NodeMetadata,
pub components: Vec<Component>,
pub children_display_mode: ChildrenDisplayMode,
pub children: Vec<Node>,
}
@@ -0,0 +1,2 @@
// 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';
@@ -1,7 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Component } from "./Component";
import type { NodeId } from "./NodeId";
import type { NodeMetadata } from "./NodeMetadata";
import type { Transform } from "./Transform";
import type { ChildrenDisplayMode } from './ChildrenDisplayMode';
import type { Component } from './Component';
import type { NodeId } from './NodeId';
import type { NodeMetadata } from './NodeMetadata';
import type { Transform } from './Transform';
export type Node = { id: NodeId, transform: Transform, metadata: NodeMetadata, components: Array<Component>, children: Array<Node>, };
export type Node = {
id: NodeId;
transform: Transform;
metadata: NodeMetadata;
components: Array<Component>;
children_display_mode: ChildrenDisplayMode;
children: Array<Node>;
};
@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { validateSpriteBorder } from './spriteBorder';
import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode';
import type { Component } from './types/Component';
import type { FontAsset } from './types/FontAsset';
import type { FontAssetId } from './types/FontAssetId';
@@ -108,6 +109,7 @@ function createPageRoot(state: State): Node {
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children: [],
};
}
@@ -131,6 +133,7 @@ function createHumanNode(state: State): Node {
source: 'Human',
},
components: [],
children_display_mode: 'Stack',
children: [],
};
}
@@ -1151,6 +1154,35 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
[commit, guard],
);
const setNodeChildrenDisplayMode = useCallback(
(
treeId: UIDesignImageId,
nodeId: NodeId,
childrenDisplayMode: ChildrenDisplayMode,
): 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) return { ok: false, reason: 'missing' };
if (!findNodeLocation(tree.root, nodeId)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
const node = findNodeLocation(
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
.root,
nodeId,
)!.node;
node.children_display_mode = childrenDisplayMode;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const moveNode = useCallback(
({
sourceTreeId,
@@ -1378,6 +1410,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
deleteComponent,
moveComponent,
setNodeMetadata,
setNodeChildrenDisplayMode,
moveNode,
removeDesignImage,
removeSpriteAsset,
@@ -63,6 +63,7 @@ export function InputSidebar({
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children: editor.state.ui_trees.map((tree) => tree.root),
};
}, [editor.state.ui_trees]);
@@ -314,7 +315,7 @@ export function InputSidebar({
selectedNodeId={selectedNodeId}
focusRequest={focusRequest}
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
hiddenNodeIds={controller.hiddenNodeIds}
isNodePreviewVisible={controller.isNodePreviewVisible}
onSelectNode={(treeId, nodeId) => {
controller.selectDesignImage(treeId);
controller.selectNode(nodeId);
@@ -82,6 +82,7 @@ export function InspectorSidebar({
onSetNodePreviewVisible={(visible) =>
controller.setNodePreviewVisible(view.node.id, visible)
}
onChildrenDisplayModeChange={controller.setNodeChildrenDisplayMode}
transformReadOnly={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
@@ -228,6 +229,7 @@ function NodeInspector({
readOnly,
isNodePreviewVisible,
onSetNodePreviewVisible,
onChildrenDisplayModeChange,
transformReadOnly,
keepChildrenUnchanged,
onKeepChildrenUnchangedChange,
@@ -247,6 +249,7 @@ function NodeInspector({
readOnly: boolean;
isNodePreviewVisible: boolean;
onSetNodePreviewVisible: (visible: boolean) => void;
onChildrenDisplayModeChange: UiEditorPageController['setNodeChildrenDisplayMode'];
transformReadOnly: boolean;
keepChildrenUnchanged: boolean;
onKeepChildrenUnchangedChange: (value: boolean) => void;
@@ -271,6 +274,24 @@ function NodeInspector({
/>
</label>
{node.children.length > 0 ? (
<label className="text-[10px] text-(--platform-text-soft)">
<select
className={INSPECTOR_INPUT_CLASS_NAME}
value={node.children_display_mode}
disabled={readOnly}
onChange={(event) =>
onChildrenDisplayModeChange(
event.target.value === 'Exclusive' ? 'Exclusive' : 'Stack',
)
}
>
<option value="Stack"></option>
<option value="Exclusive"></option>
</select>
</label>
) : null}
<div
className="flex items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 p-0.5 text-[10px]"
role="group"
@@ -24,7 +24,7 @@ type UiTreePanelProps = {
selectedNodeId: NodeId | null;
focusRequest: UiEditorNodeFocusRequest | null;
treeIdForNode: (nodeId: NodeId) => UIDesignImageId | null;
hiddenNodeIds: ReadonlySet<NodeId>;
isNodePreviewVisible: (nodeId: NodeId) => boolean;
onSelectNode: (treeId: UIDesignImageId, id: NodeId) => void;
onToggleNodeVisibility: (id: NodeId) => void;
onInsertNode: (treeId: UIDesignImageId, parentId: NodeId) => void;
@@ -38,18 +38,18 @@ function TreeRow({
style,
dragHandle,
onSelectNode,
hiddenNodeIds,
isNodeVisible,
onToggleNodeVisibility,
onOpenContextMenu,
}: NodeRendererProps<UiNode> & {
onSelectNode: (id: NodeId) => void;
hiddenNodeIds: ReadonlySet<NodeId>;
isNodeVisible: (nodeId: NodeId) => boolean;
onToggleNodeVisibility: (id: NodeId) => void;
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
}) {
const data = node.data;
const nodeLabel = data.metadata.name || '未命名节点';
const isVisible = !hiddenNodeIds.has(data.id);
const isVisible = isNodeVisible(data.id);
return (
<div
ref={dragHandle}
@@ -111,7 +111,7 @@ export function UiTreePanel({
selectedNodeId,
focusRequest,
treeIdForNode,
hiddenNodeIds,
isNodePreviewVisible,
onSelectNode,
onToggleNodeVisibility,
onInsertNode,
@@ -218,7 +218,7 @@ export function UiTreePanel({
const nodeTreeId = treeIdForNode(nodeId);
if (nodeTreeId) onSelectNode(nodeTreeId, nodeId);
}}
hiddenNodeIds={hiddenNodeIds}
isNodeVisible={isNodePreviewVisible}
onToggleNodeVisibility={onToggleNodeVisibility}
onOpenContextMenu={(event, nodeId) =>
nodeId === root.id
@@ -0,0 +1,51 @@
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
type ExclusiveChildrenTabsProps = {
parent: UiNode;
isChildVisible: (nodeId: NodeId) => boolean;
onToggleChild: (nodeId: NodeId) => void;
};
export function ExclusiveChildrenTabs({
parent,
isChildVisible,
onToggleChild,
}: ExclusiveChildrenTabsProps) {
if (
parent.children_display_mode !== 'Exclusive' ||
parent.children.length === 0
) {
return null;
}
return (
<div
className="pointer-events-auto absolute left-3 top-3 z-40 flex max-w-[calc(100%-1.5rem)] overflow-x-auto rounded-lg border border-black/10 bg-white/90 p-1 shadow-lg backdrop-blur"
role="tablist"
aria-label={`${parent.metadata.name || '当前节点'} 子节点显示`}
data-exclusive-child-tabs
>
{parent.children.map((child, index) => {
const label = child.metadata.name || `未命名子节点 ${index + 1}`;
const selected = isChildVisible(child.id);
return (
<button
key={child.id}
type="button"
role="tab"
aria-selected={selected}
className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] transition-colors ${
selected
? 'bg-orange-500 text-white shadow-sm'
: 'text-(--platform-text-soft) hover:bg-black/5 hover:text-(--platform-text-strong)'
}`}
onClick={() => onToggleChild(child.id)}
>
{label}
</button>
);
})}
</div>
);
}
@@ -23,6 +23,7 @@ import {
} from 'react';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs';
import { findNodePageContext } from './nodeTransformGeometry';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
@@ -302,6 +303,17 @@ export function PreviewWorkspace({
logicalSize &&
(renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? (
<div className="relative min-h-0 flex-1">
{renderMode === 'final-preview' && controller.selectedNode ? (
<ExclusiveChildrenTabs
parent={controller.selectedNode}
isChildVisible={(nodeId) =>
controller.isNodePreviewVisible(nodeId)
}
onToggleChild={(nodeId) =>
controller.toggleNodePreviewVisibility(nodeId)
}
/>
) : null}
<SharedCanvasViewport
ref={viewportElementRef}
className="size-full"
@@ -80,6 +80,11 @@ function RenderNode({
}
const isEditorOverlay = renderMode === 'editor-overlay';
const exclusiveVisibleChildId =
node.children_display_mode === 'Exclusive'
? (node.children.find((child) => !hiddenNodeIds.has(child.id))?.id ??
null)
: null;
return (
<div
data-node-id={node.id}
@@ -122,24 +127,27 @@ function RenderNode({
resources={resources}
/>
))}
{node.children.map((child) => (
<RenderNode
key={child.id}
node={child}
renderMode={renderMode}
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
viewportScale={viewportScale}
/>
))}
{node.children.map((child) =>
exclusiveVisibleChildId &&
child.id !== exclusiveVisibleChildId ? null : (
<RenderNode
key={child.id}
node={child}
renderMode={renderMode}
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
viewportScale={viewportScale}
/>
),
)}
{isEditorOverlay && !isRoot && selectedNodeId === node.id
? RESIZE_HANDLES.map((handle) => (
<div
@@ -21,6 +21,7 @@ import {
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
import type { Component } from '../../features/ui-editor/types/Component';
import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId';
import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
@@ -60,6 +61,45 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
function findUiNodeLocation(
node: UiNode,
nodeId: NodeId,
parent: UiNode | null = null,
): { node: UiNode; parent: UiNode | null } | null {
if (node.id === nodeId) return { node, parent };
for (const child of node.children) {
const location = findUiNodeLocation(child, nodeId, node);
if (location) return location;
}
return null;
}
function isUiNodeEffectivelyVisible(
node: UiNode,
targetNodeId: NodeId,
hidden: ReadonlySet<NodeId>,
ancestorsVisible = true,
): boolean | null {
const nodeVisible = ancestorsVisible && !hidden.has(node.id);
if (node.id === targetNodeId) return nodeVisible;
const exclusiveChildId =
node.children_display_mode === 'Exclusive'
? (node.children.find((child) => !hidden.has(child.id))?.id ?? null)
: null;
for (const child of node.children) {
const visible = isUiNodeEffectivelyVisible(
child,
targetNodeId,
hidden,
nodeVisible &&
(node.children_display_mode !== 'Exclusive' ||
child.id === exclusiveChildId),
);
if (visible !== null) return visible;
}
return null;
}
export type PendingWorkflowStepChange = {
from: UiEditorStepId;
to: UiEditorStepId;
@@ -284,31 +324,87 @@ export function useUiEditorPage(
}, [editor.state.ui_trees]);
const isNodePreviewVisible = useCallback(
(nodeId: NodeId) => !hiddenNodeIds.has(nodeId),
[hiddenNodeIds],
(nodeId: NodeId) => {
for (const tree of editor.state.ui_trees) {
const visible = isUiNodeEffectivelyVisible(
tree.root,
nodeId,
hiddenNodeIds,
);
if (visible !== null) return visible;
}
return !hiddenNodeIds.has(nodeId);
},
[editor.state.ui_trees, hiddenNodeIds],
);
const setNodePreviewVisible = useCallback(
(nodeId: NodeId, visible: boolean) => {
setHiddenNodeIds((current) => {
const currentlyVisible = !current.has(nodeId);
let location: { node: UiNode; parent: UiNode | null } | null = null;
for (const tree of editor.state.ui_trees) {
location = findUiNodeLocation(tree.root, nodeId);
if (location) break;
}
const parent = location?.parent;
const isExclusiveChild =
parent?.children_display_mode === 'Exclusive' &&
parent.children.some((child) => child.id === nodeId);
const currentlyVisible = isExclusiveChild
? parent.children.some(
(child) =>
child.id === nodeId &&
!current.has(child.id) &&
!current.has(parent.id),
)
: !current.has(nodeId);
if (currentlyVisible === visible) return current;
const next = new Set(current);
if (visible) next.delete(nodeId);
else next.add(nodeId);
if (isExclusiveChild) {
if (visible) {
for (const child of parent.children) {
if (child.id === nodeId) next.delete(child.id);
else next.add(child.id);
}
} else {
for (const child of parent.children) next.add(child.id);
}
} else if (visible) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
},
[],
[editor.state.ui_trees],
);
const toggleNodePreviewVisibility = useCallback(
(nodeId: NodeId) => {
setNodePreviewVisible(nodeId, !isNodePreviewVisible(nodeId));
let parent: UiNode | null = null;
for (const tree of editor.state.ui_trees) {
const location = findUiNodeLocation(tree.root, nodeId);
if (location) {
parent = location.parent;
break;
}
}
const isExclusiveChild =
parent?.children_display_mode === 'Exclusive' &&
parent.children.some((child) => child.id === nodeId);
const visible = isExclusiveChild
? isNodePreviewVisible(nodeId)
: !hiddenNodeIds.has(nodeId);
setNodePreviewVisible(nodeId, !visible);
},
[isNodePreviewVisible, setNodePreviewVisible],
[
editor.state.ui_trees,
hiddenNodeIds,
isNodePreviewVisible,
setNodePreviewVisible,
],
);
const findNodeContext = useCallback(function findNodeContext(
node: UiNode,
nodeId: NodeId,
@@ -592,6 +688,17 @@ export function useUiEditorPage(
return result;
}
function setNodeChildrenDisplayMode(mode: ChildrenDisplayMode) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.setNodeChildrenDisplayMode(
activeImageId,
selectedNodeId,
mode,
);
if (!result.ok) setStatus('子节点显示模式更新失败。');
return result;
}
function setNodeComponents(components: Component[]) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.setNodeComponents(
@@ -893,6 +1000,7 @@ export function useUiEditorPage(
setNodeTransform,
updateNodeTransform,
setNodeMetadata,
setNodeChildrenDisplayMode,
setNodeComponents,
insertNodeComponent,
deleteNodeComponent,
@@ -30,6 +30,7 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node {
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children,
};
}
@@ -223,4 +223,56 @@ describe('UiEditorPage', () => {
act(() => result.current.clearState());
expect(result.current.hiddenNodeIds.size).toBe(0);
});
it('shares exclusive child visibility between tree actions and final preview state', () => {
const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor'));
act(() => {
result.current.editor.addDesignImages([
{
id: 'page',
image: {
metadata: {
name: 'Page',
description: '',
role: 'Page',
slave_to: null,
},
path: 'assets/page.png',
pixel_size: [320, 180],
pixels_per_unit: 1,
},
},
]);
result.current.selectDesignImage('page');
});
const rootId = result.current.treeForActiveImage!.root.id;
let parentId: string | undefined;
let childAId: string | undefined;
let childBId: string | undefined;
let childBDescendantId: string | undefined;
act(() => {
parentId = result.current.insertNode(rootId)?.value;
childAId = result.current.insertNode(parentId!)?.value;
childBId = result.current.insertNode(parentId!)?.value;
childBDescendantId = result.current.insertNode(childBId!)?.value;
});
act(() => result.current.selectNode(parentId!));
act(() => result.current.setNodeChildrenDisplayMode('Exclusive'));
expect(parentId && childAId && childBId).toBeTruthy();
expect(result.current.isNodePreviewVisible(childAId!)).toBe(true);
expect(result.current.isNodePreviewVisible(childBId!)).toBe(false);
expect(result.current.isNodePreviewVisible(childBDescendantId!)).toBe(
false,
);
act(() => result.current.toggleNodePreviewVisibility(childAId!));
expect(result.current.isNodePreviewVisible(childAId!)).toBe(false);
expect(result.current.isNodePreviewVisible(childBId!)).toBe(false);
act(() => result.current.toggleNodePreviewVisibility(childBId!));
expect(result.current.isNodePreviewVisible(childAId!)).toBe(false);
expect(result.current.isNodePreviewVisible(childBId!)).toBe(true);
expect(result.current.isNodePreviewVisible(childBDescendantId!)).toBe(true);
});
});
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
import { render } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UITree } from '../src/features/ui-editor/types/UITree';
import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ExclusiveChildrenTabs';
import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer';
function node(id: string, children: UiNode[] = []): UiNode {
@@ -26,10 +27,15 @@ function node(id: string, children: UiNode[] = []): UiNode {
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children,
};
}
function exclusiveNode(id: string, children: UiNode[]): UiNode {
return { ...node(id, children), children_display_mode: 'Exclusive' };
}
const tree: UITree = {
src_ui_design: 'page',
root: node('root', [node('child', [node('grandchild')])]),
@@ -81,4 +87,76 @@ describe('UI tree preview visibility', () => {
const finalPreview = renderTree('final-preview', new Set(['root']));
expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull();
});
it('renders only the first unhidden child for an exclusive parent', () => {
const exclusiveTree: UITree = {
src_ui_design: 'page',
root: exclusiveNode('root', [node('child-a'), node('child-b')]),
};
const renderExclusive = (hiddenNodeIds: ReadonlySet<string>) =>
render(
<UiTreeRenderer
tree={exclusiveTree}
renderMode="final-preview"
hiddenNodeIds={hiddenNodeIds}
selectedNodeId={null}
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 first = renderExclusive(new Set());
expect(
first.container.querySelector('[data-node-id="child-a"]'),
).toBeTruthy();
expect(
first.container.querySelector('[data-node-id="child-b"]'),
).toBeNull();
first.unmount();
const second = renderExclusive(new Set(['child-a']));
expect(
second.container.querySelector('[data-node-id="child-a"]'),
).toBeNull();
expect(
second.container.querySelector('[data-node-id="child-b"]'),
).toBeTruthy();
second.unmount();
const none = renderExclusive(new Set(['child-a', 'child-b']));
expect(none.container.querySelector('[data-node-id="child-a"]')).toBeNull();
expect(none.container.querySelector('[data-node-id="child-b"]')).toBeNull();
});
it('uses a highlighted tab bar for exclusive child switching', () => {
const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]);
const onToggleChild = vi.fn();
render(
<ExclusiveChildrenTabs
parent={parent}
isChildVisible={(nodeId) => nodeId === 'child-a'}
onToggleChild={onToggleChild}
/>,
);
expect(
screen
.getByRole('tab', { name: 'child-a' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
screen
.getByRole('tab', { name: 'child-b' })
.getAttribute('aria-selected'),
).toBe('false');
fireEvent.click(screen.getByRole('tab', { name: 'child-b' }));
expect(onToggleChild).toHaveBeenCalledWith('child-b');
});
});
@@ -99,6 +99,7 @@ function pageRoot(id: string, children: Node[] = []): Node {
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children,
};
}
@@ -345,6 +346,7 @@ describe('useUiEditorState', () => {
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children: [nodeWithSprite('panel')],
},
},
@@ -0,0 +1,26 @@
# UI 编辑器子节点显示规则
## 规则
`Node.children_display_mode` 控制父节点直接 children 的预览显示方式:
- `Stack`:所有直接 children 按现有树结构显示。
- `Exclusive`:直接 children 中允许显示 0 或 1 个。
`Merged` 合并容器由 Rust materialize 阶段直接设置为 `Exclusive`,不由 LLM 决定,也不需要历史数据迁移。
## 状态与交互
编辑器预览可见性仍是按界面图隔离的临时状态。Tree Panel 的节点眼睛操作和最终预览的互斥 tab bar 必须共用同一套可见性状态:
```text
[子节点 A] [子节点 B] [子节点 C]
```
当前可见 child 高亮;点击其它 child 会隐藏兄弟并显示目标;点击当前高亮 child 会将其隐藏,允许进入全部隐藏状态。没有 child 可见时不高亮任何 tab。
最终预览只在当前选中的父节点为 `Exclusive` 时显示 tab bar。Inspector 只提供“子节点显示模式:叠加 / 互斥”选择,不增加独立的当前状态或隐藏按钮。
## 默认行为
`Exclusive` 节点没有显式隐藏状态时,最终预览按 children 顺序显示第一个 child。首次通过 Tree Panel 或 tab bar 修改后,状态由共享可见性状态明确记录。