修复互斥子节点最终预览卡顿
将互斥子节点切换条定位到所选节点上方 避免等值文本配置克隆重启 BestFit 测量 补充最终预览重测量回归测试并更新实施合同
This commit is contained in:
+17
-4
@@ -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>
|
||||
|
||||
+1
-10
@@ -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
-7
@@ -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 换算。
|
||||
|
||||
Reference in New Issue
Block a user