修复互斥容器默认预览分支

统一互斥子节点选择、渲染与有效可见性计算
无显式可见子节点时仅回退显示第一个直接子节点
补充回归测试并同步更新技术方案

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-08-20 13:59:18 +08:00
parent 3ed63349a3
commit b155cd5705
5 changed files with 55 additions and 8 deletions
@@ -16,6 +16,7 @@ import {
import { ComponentView } from './components/ComponentView';
import type { PreviewComponentResources } from './components/types';
import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs';
import { resolveExclusiveVisibleChildId } from './exclusiveVisibility';
export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry';
@@ -72,6 +73,7 @@ const RESIZE_HANDLES: ReadonlyArray<{
function RenderNode({
node,
isRoot,
forceVisible,
parentContainer,
renderMode,
showFrame,
@@ -91,9 +93,10 @@ function RenderNode({
}: Omit<UiTreeRendererProps, 'tree'> & {
node: UiNode;
isRoot?: boolean;
forceVisible?: boolean;
parentContainer?: UiNode['layout']['container'];
}) {
if (hiddenNodeIds.has(node.id)) return null;
if (!forceVisible && hiddenNodeIds.has(node.id)) return null;
let geometry;
try {
@@ -110,8 +113,7 @@ function RenderNode({
const isFrameVisible = isEditorOverlay || showFrame;
const exclusiveVisibleChildId =
node.children_display_mode === 'Exclusive'
? (node.children.find((child) => !hiddenNodeIds.has(child.id))?.id ??
null)
? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds)
: null;
return (
<div
@@ -181,6 +183,10 @@ function RenderNode({
<RenderNode
key={child.id}
node={child}
forceVisible={
node.children_display_mode === 'Exclusive' &&
child.id === exclusiveVisibleChildId
}
parentContainer={
isContainer(node.layout.container)
? node.layout.container
@@ -0,0 +1,12 @@
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
export function resolveExclusiveVisibleChildId(
children: ReadonlyArray<{ id: NodeId }>,
hiddenNodeIds: ReadonlySet<NodeId>,
): NodeId | null {
return (
children.find((child) => !hiddenNodeIds.has(child.id))?.id ??
children[0]?.id ??
null
);
}
@@ -45,6 +45,7 @@ import {
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import { resolveExclusiveVisibleChildId } from './components/preview/exclusiveVisibility';
import {
activeStepPrerequisiteIssues,
postCheckIssuesForSave,
@@ -87,12 +88,14 @@ function isUiNodeEffectivelyVisible(
targetNodeId: NodeId,
hidden: ReadonlySet<NodeId>,
ancestorsVisible = true,
forceVisible = false,
): boolean | null {
const nodeVisible = ancestorsVisible && !hidden.has(node.id);
const nodeVisible =
ancestorsVisible && (forceVisible || !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)
? resolveExclusiveVisibleChildId(node.children, hidden)
: null;
for (const child of node.children) {
const visible = isUiNodeEffectivelyVisible(
@@ -102,6 +105,8 @@ function isUiNodeEffectivelyVisible(
nodeVisible &&
(node.children_display_mode !== 'Exclusive' ||
child.id === exclusiveChildId),
node.children_display_mode === 'Exclusive' &&
child.id === exclusiveChildId,
);
if (visible !== null) return visible;
}
@@ -429,8 +434,7 @@ export function useUiEditorSession(
parent.children.some((child) => child.id === nodeId);
const currentlyVisible = isExclusiveChild
? !current.has(parent.id) &&
parent.children.find((child) => !current.has(child.id))?.id ===
nodeId
resolveExclusiveVisibleChildId(parent.children, current) === nodeId
: !current.has(nodeId);
if (currentlyVisible === visible) return current;
const next = new Set(current);
@@ -442,6 +446,8 @@ export function useUiEditorSession(
}
} else {
for (const child of parent.children) next.add(child.id);
const fallbackChildId = parent.children[0]?.id;
if (fallbackChildId) next.delete(fallbackChildId);
}
} else if (visible) {
next.delete(nodeId);
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { resolveExclusiveVisibleChildId } from '../src/view/ui-editor/components/preview/exclusiveVisibility';
describe('resolveExclusiveVisibleChildId', () => {
const children = [{ id: 'first' }, { id: 'second' }];
it('uses the first non-hidden child as the explicit selection', () => {
expect(resolveExclusiveVisibleChildId(children, new Set(['first']))).toBe(
'second',
);
});
it('falls back to the first child when every child is hidden', () => {
expect(
resolveExclusiveVisibleChildId(children, new Set(['first', 'second'])),
).toBe('first');
});
it('returns null for an empty exclusive container', () => {
expect(resolveExclusiveVisibleChildId([], new Set())).toBeNull();
});
});
@@ -4,7 +4,7 @@
## 2026-08-20 UI Editor 最终预览互斥子节点
最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。
最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点没有显式可见子节点时,必须默认且仅显示第一个直接子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。
`BestFit` 文本测量的 effect 只依赖字号模式的标量值、文本测量键和实际容器,不能依赖整个 `FontSizing` 对象引用。UI State 对任意节点(包括切换 `children_display_mode`)的不可变更新会 clone 整棵树;等值 clone 不得重新创建 `ResizeObserver` 或同步重复二分测量,以避免最终预览已显示时出现明显的“重新加载”。