实现 UI 编辑器子节点互斥显示
新增 Stack / Exclusive 子节点显示模式及 Rust/TypeScript DTO。 Merged 物化自动使用 Exclusive,识别和手动节点默认使用 Stack。 拆分预览可见性 hook 与互斥子节点 Tab 组件,统一 Tree Panel 和最终预览状态。 补充 Inspector 模式选择、渲染规则、同步测试和技术方案文档。
This commit is contained in:
@@ -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);
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
+51
@@ -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"
|
||||
|
||||
+26
-18
@@ -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 修改后,状态由共享可见性状态明确记录。
|
||||
Reference in New Issue
Block a user