修复互斥子节点最终预览卡顿

将互斥子节点切换条定位到所选节点上方

避免等值文本配置克隆重启 BestFit 测量

补充最终预览重测量回归测试并更新实施合同
This commit is contained in:
2026-08-20 10:51:32 +08:00
parent ca0b16cf36
commit abbac66254
9 changed files with 142 additions and 30 deletions
@@ -1,16 +1,18 @@
import type { PointerEvent } from 'react';
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;
onSelectChild: (nodeId: NodeId) => void;
};
export function ExclusiveChildrenTabs({
parent,
isChildVisible,
onToggleChild,
onSelectChild,
}: ExclusiveChildrenTabsProps) {
if (
parent.children_display_mode !== 'Exclusive' ||
@@ -19,12 +21,20 @@ export function ExclusiveChildrenTabs({
return null;
}
const stopNodeGesture = (event: PointerEvent<HTMLDivElement>) => {
event.stopPropagation();
};
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"
className="pointer-events-auto absolute bottom-[calc(100%+0.5rem)] left-0 z-40 inline-flex w-max whitespace-nowrap overflow-visible 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
onPointerDown={stopNodeGesture}
onPointerMove={stopNodeGesture}
onPointerUp={stopNodeGesture}
onPointerCancel={stopNodeGesture}
>
{parent.children.map((child, index) => {
const label = child.metadata.name || `未命名子节点 ${index + 1}`;
@@ -40,7 +50,10 @@ export function ExclusiveChildrenTabs({
? 'bg-orange-500 text-white shadow-sm'
: 'text-(--platform-text-soft) hover:bg-black/5 hover:text-(--platform-text-strong)'
}`}
onClick={() => onToggleChild(child.id)}
onClick={(event) => {
event.stopPropagation();
onSelectChild(child.id);
}}
>
{label}
</button>
@@ -27,7 +27,6 @@ import { findNodePageContext } from '../../../../features/ui-editor/nodeTransfor
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
@@ -343,15 +342,6 @@ export function PreviewWorkspace({
</button>
</div>
</div>
{renderMode === 'final-preview' && canvas.selectedNode ? (
<ExclusiveChildrenTabs
parent={canvas.selectedNode}
isChildVisible={(nodeId) => canvas.isNodePreviewVisible(nodeId)}
onToggleChild={(nodeId) =>
canvas.toggleNodePreviewVisibility(nodeId)
}
/>
) : null}
<SharedCanvasViewport
ref={viewportElementRef}
className="size-full"
@@ -406,6 +396,7 @@ export function PreviewWorkspace({
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
onSelectExclusiveChild={canvas.selectExclusiveChild}
viewportScale={viewport.scale}
/>
</div>
@@ -15,6 +15,7 @@ import {
} from '../../../../features/ui-editor/utils/layout/controlLayoutToCss';
import { ComponentView } from './components/ComponentView';
import type { PreviewComponentResources } from './components/types';
import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs';
export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry';
@@ -48,6 +49,7 @@ type UiTreeRendererProps = {
) => void;
onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onSelectExclusiveChild: (nodeId: NodeId) => void;
viewportScale: number;
};
@@ -84,6 +86,7 @@ function RenderNode({
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
onSelectExclusiveChild,
viewportScale,
}: Omit<UiTreeRendererProps, 'tree'> & {
node: UiNode;
@@ -165,6 +168,13 @@ function RenderNode({
resources={resources}
/>
))}
{renderMode === 'final-preview' && selectedNodeId === node.id ? (
<ExclusiveChildrenTabs
parent={node}
isChildVisible={(nodeId) => nodeId === exclusiveVisibleChildId}
onSelectChild={onSelectExclusiveChild}
/>
) : null}
{node.children.map((child) =>
exclusiveVisibleChildId &&
child.id !== exclusiveVisibleChildId ? null : (
@@ -189,6 +199,7 @@ function RenderNode({
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
onSelectExclusiveChild={onSelectExclusiveChild}
viewportScale={viewportScale}
/>
),
@@ -23,20 +23,29 @@ export function useBestFitFontSize({
measurementKey: string;
}): number {
const initialSize = initialFontSize(sizing);
const fixedSize = 'Fixed' in sizing ? sizing.Fixed : null;
const bestFitMin = 'BestFit' in sizing ? sizing.BestFit.min : null;
const bestFitMax = 'BestFit' in sizing ? sizing.BestFit.max : null;
const [fontSize, setFontSize] = useState(initialSize);
useLayoutEffect(() => {
if ('Fixed' in sizing) {
setFontSize((current) =>
current === sizing.Fixed ? current : sizing.Fixed,
);
if (fixedSize !== null) {
setFontSize((current) => (current === fixedSize ? current : fixedSize));
return;
}
const container = containerRef.current;
const measurement = measurementRef.current;
if (!container || !measurement) return;
const { min, max } = sizing.BestFit;
if (
!container ||
!measurement ||
bestFitMin === null ||
bestFitMax === null
) {
return;
}
const min = bestFitMin;
const max = bestFitMax;
const measure = () => {
const width = container.clientWidth;
const height = container.clientHeight;
@@ -61,7 +70,14 @@ export function useBestFitFontSize({
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [containerRef, initialSize, measurementKey, measurementRef, sizing]);
}, [
bestFitMax,
bestFitMin,
containerRef,
fixedSize,
measurementKey,
measurementRef,
]);
return fontSize;
}
@@ -428,12 +428,9 @@ export function useUiEditorSession(
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(parent.id) &&
parent.children.find((child) => !current.has(child.id))?.id ===
nodeId
: !current.has(nodeId);
if (currentlyVisible === visible) return current;
const next = new Set(current);
@@ -482,6 +479,10 @@ export function useUiEditorSession(
setNodePreviewVisible,
],
);
const selectExclusiveChild = useCallback(
(nodeId: NodeId) => setNodePreviewVisible(nodeId, true),
[setNodePreviewVisible],
);
const selectedNodeContext = useMemo(() => {
if (!activeImage || !treeForActiveImage || !selectedNodeId) return null;
const ppu = activeImage.pixels_per_unit;
@@ -1091,6 +1092,7 @@ export function useUiEditorSession(
status,
isNodePreviewVisible,
toggleNodePreviewVisibility,
selectExclusiveChild,
selectNode,
clearNodeSelection,
updateNodeTransform,
@@ -508,6 +508,14 @@ describe('UiEditorPage', () => {
result.current.canvas.isNodePreviewVisible(childBDescendantId!),
).toBe(false);
act(() => result.current.canvas.selectExclusiveChild(childBId!));
expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(false);
expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(true);
expect(
result.current.canvas.isNodePreviewVisible(childBDescendantId!),
).toBe(true);
act(() => result.current.canvas.selectExclusiveChild(childAId!));
act(() => result.current.canvas.toggleNodePreviewVisibility(childAId!));
expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(false);
expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(false);
@@ -518,6 +526,10 @@ describe('UiEditorPage', () => {
expect(
result.current.canvas.isNodePreviewVisible(childBDescendantId!),
).toBe(true);
act(() => result.current.canvas.selectExclusiveChild(childAId!));
expect(result.current.canvas.isNodePreviewVisible(childAId!)).toBe(true);
expect(result.current.canvas.isNodePreviewVisible(childBId!)).toBe(false);
});
it('shows a page-level save failure and allows a retry', async () => {
@@ -259,12 +259,12 @@ describe('UI tree preview visibility', () => {
it('uses a highlighted tab bar for exclusive child switching', () => {
const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]);
const onToggleChild = vi.fn();
const onSelectChild = vi.fn();
render(
<ExclusiveChildrenTabs
parent={parent}
isChildVisible={(nodeId) => nodeId === 'child-a'}
onToggleChild={onToggleChild}
onSelectChild={onSelectChild}
/>,
);
@@ -279,7 +279,40 @@ describe('UI tree preview visibility', () => {
.getAttribute('aria-selected'),
).toBe('false');
fireEvent.click(screen.getByRole('tab', { name: 'child-b' }));
expect(onToggleChild).toHaveBeenCalledWith('child-b');
expect(onSelectChild).toHaveBeenCalledWith('child-b');
});
it('keeps exclusive tab pointer gestures out of the parent node', () => {
const onSelectChild = vi.fn();
const onNodePointerDown = vi.fn();
const parent = exclusiveNode('parent', [node('child-a'), node('child-b')]);
render(
<UiTreeRenderer
tree={{ src_ui_design: 'page', root: node('root', [parent]) }}
renderMode="final-preview"
showFrame={false}
hiddenNodeIds={new Set()}
selectedNodeId="parent"
resources={resources}
onSelectNode={vi.fn()}
onNodeContextMenu={vi.fn()}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={vi.fn()}
onNodePointerUp={vi.fn()}
onNodeResizePointerDown={vi.fn()}
onNodeResizePointerMove={vi.fn()}
onNodeResizePointerUp={vi.fn()}
onSelectExclusiveChild={onSelectChild}
viewportScale={1}
/>,
);
const tab = screen.getByRole('tab', { name: 'child-b' });
fireEvent.pointerDown(tab, { button: 0, pointerId: 1 });
fireEvent.click(tab);
expect(onNodePointerDown).not.toHaveBeenCalled();
expect(onSelectChild).toHaveBeenCalledWith('child-b');
});
it('opens a context-menu target only from editor overlay nodes', () => {
@@ -147,4 +147,32 @@ describe('TextComponentView', () => {
act(() => observerCallback?.([], {} as ResizeObserver));
expect(view.style.fontSize).toBe('9px');
});
it('does not restart BestFit measurement when an equivalent component is cloned', () => {
const observers: Array<{ disconnect: ReturnType<typeof vi.fn> }> = [];
class TestResizeObserver {
readonly disconnect = vi.fn();
constructor(_callback: ResizeObserverCallback) {
observers.push(this);
}
observe = vi.fn();
}
vi.stubGlobal('ResizeObserver', TestResizeObserver);
const original = text({ font_sizing: { BestFit: { min: 8, max: 20 } } });
const view = render(
<TextComponentView component={original} resources={resources} />,
);
view.rerender(
<TextComponentView
component={structuredClone(original)}
resources={resources}
/>,
);
expect(observers).toHaveLength(1);
expect(observers[0]!.disconnect).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,12 @@
## 2026-08-19 UI Editor 节点右键菜单
## 2026-08-20 UI Editor 最终预览互斥子节点
最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。
`BestFit` 文本测量的 effect 只依赖字号模式的标量值、文本测量键和实际容器,不能依赖整个 `FontSizing` 对象引用。UI State 对任意节点(包括切换 `children_display_mode`)的不可变更新会 clone 整棵树;等值 clone 不得重新创建 `ResizeObserver` 或同步重复二分测量,以避免最终预览已显示时出现明显的“重新加载”。
## 2026-08-19 UI Editor 节点几何唯一 seam
`features/ui-editor/nodeTransformGeometry.ts` 是 UI Editor 节点几何的唯一 seam:它统一解析页面 rect 和节点 context、按 anchor / offset 反算 transform、同树 reparent 意图、父节点变换时的直接子节点页面位置保持,以及预览 resize 的等比约束。页面 Inspector、预览 pointer interaction 与状态迁移分别作为该 Module 的 adapter;它们不得再递归计算节点尺寸、各自反算 offset 或复制子节点保持逻辑。跨界面移动仍只保留原局部 transform,不进入 page rect 换算。