修复 UI 编辑器待办循环高亮动画

修复受控树选中回调清除 Inspector 高亮的问题

循环游标同时使用界面树与节点 ID

补充多节点循环和动画重启回归测试
This commit is contained in:
2026-09-14 14:15:43 +08:00
parent 6dbc4a39cb
commit 5cb3801ba4
6 changed files with 187 additions and 15 deletions
@@ -1,4 +1,5 @@
import type { Node as UiNode } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
import type { StageStatus } from './types/StageStatus';
import type { UIDesignImageId } from './types/UIDesignImageId';
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
node: UiNode;
};
export type UiTreeNodeCursor = {
treeId: UIDesignImageId;
nodeId: NodeId;
};
export type StageStatusOverview = {
total: number;
needsAttention: number;
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
export function getNextUiTreeNodeTarget(
targets: UiTreeNodeTarget[],
previousNodeId: string | null,
previous: string | UiTreeNodeCursor | null,
): UiTreeNodeTarget | null {
if (targets.length === 0) return null;
const previousIndex = targets.findIndex(
({ node }) => node.id === previousNodeId,
);
const previousIndex =
typeof previous === 'string'
? targets.findIndex(({ node }) => node.id === previous)
: previous
? targets.findIndex(
({ treeId, node }) =>
treeId === previous.treeId && node.id === previous.nodeId,
)
: -1;
return targets[(previousIndex + 1) % targets.length] ?? null;
}
export function getNextMatchingUiTreeNodeTarget(
uiTrees: UITree[],
previousNodeId: string | null,
previous: string | UiTreeNodeCursor | null,
matches: (target: UiTreeNodeTarget) => boolean,
): UiTreeNodeTarget | null {
return getNextUiTreeNodeTarget(
collectUiTreeNodeTargets(uiTrees).filter(matches),
previousNodeId,
previous,
);
}
@@ -83,8 +83,16 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
isNodePreviewVisible={input.isNodePreviewVisible}
onSelectNode={(treeId, nodeId) => {
input.selectDesignImage(treeId);
input.selectNode(nodeId);
// react-arborist emits `onSelect` when its controlled `selection`
// prop is updated. Overview navigation updates the selection and
// the status highlight in the same render, so treating that
// programmatic notification as a fresh user selection would clear
// the highlight before it can be painted. Only mutate selection
// state when the target actually differs from the current one.
const sameImage = input.activeImageId === treeId;
const sameNode = input.selectedNodeId === nodeId;
if (!sameImage) input.selectDesignImage(treeId);
if (!sameImage || !sameNode) input.selectNode(nodeId);
}}
onToggleNodeVisibility={(nodeId) =>
input.toggleNodePreviewVisibility(nodeId)
@@ -827,11 +827,26 @@ function NodeStageSelect({
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
useEffect(() => {
if (!highlight) return;
statusRowRef.current?.scrollIntoView({
const statusRow = statusRowRef.current;
if (!highlight || !statusRow) return;
statusRow.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
// 强制制造一次样式边界,避免 A → B → A 时浏览器复用已完成的动画。
statusRow.classList.remove('ui-editor-status-attention');
void statusRow.offsetWidth;
statusRow.classList.add('ui-editor-status-attention');
// 多节点切换会复用 Inspector 树,显式重启动画,确保回到已查看节点时
// 仍能再次播放提示,而不是依赖 class/key 的重协调行为。
if (typeof statusRow.getAnimations === 'function') {
for (const animation of statusRow.getAnimations()) {
animation.cancel();
animation.play();
}
}
}, [highlight]);
return (
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import {
getNextMatchingUiTreeNodeTarget,
type UiTreeNodeCursor,
type UiTreeNodeTarget,
} from '../../features/ui-editor/stageStatusOverview';
import type { NodeId } from '../../features/ui-editor/types/NodeId';
@@ -17,20 +18,20 @@ export function useUiTreeNodeCycle({
matches: (target: UiTreeNodeTarget) => boolean;
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const [lastNodeId, setLastNodeId] = useState<NodeId | null>(null);
const [lastCursor, setLastCursor] = useState<UiTreeNodeCursor | null>(null);
useEffect(() => setLastNodeId(null), [uiTrees]);
useEffect(() => setLastCursor(null), [uiTrees]);
const focusNext = useCallback(() => {
const target = getNextMatchingUiTreeNodeTarget(
uiTrees,
lastNodeId,
lastCursor,
matches,
);
if (!target) return;
setLastNodeId(target.node.id);
setLastCursor({ treeId: target.treeId, nodeId: target.node.id });
onFocusNode(target.treeId, target.node.id);
}, [lastNodeId, matches, onFocusNode, uiTrees]);
}, [lastCursor, matches, onFocusNode, uiTrees]);
return { focusNext };
}
@@ -95,4 +95,34 @@ describe('stageStatusOverview', () => {
'review-a',
);
});
it('uses the tree and node ids together when node ids repeat across trees', () => {
const duplicateTargets = [
{ treeId: 'page-a', node: node('same', { NeedReview: 'A' }) },
{ treeId: 'page-b', node: node('same', { NeedReview: 'B' }) },
{ treeId: 'page-c', node: node('same', { NeedReview: 'C' }) },
];
expect(getNextUiTreeNodeTarget(duplicateTargets, null)?.treeId).toBe(
'page-a',
);
expect(
getNextUiTreeNodeTarget(duplicateTargets, {
treeId: 'page-a',
nodeId: 'same',
})?.treeId,
).toBe('page-b');
expect(
getNextUiTreeNodeTarget(duplicateTargets, {
treeId: 'page-b',
nodeId: 'same',
})?.treeId,
).toBe('page-c');
expect(
getNextUiTreeNodeTarget(duplicateTargets, {
treeId: 'page-c',
nodeId: 'same',
})?.treeId,
).toBe('page-a');
});
});
@@ -395,6 +395,112 @@ describe('UiEditorPage', () => {
).toBeNull();
});
it('restarts inspector animation for every item in a multi-item cycle', async () => {
const state = stateWithPages(['page']);
state.ui_trees[0]!.root.children = [
node('review-a'),
node('review-b'),
node('review-c'),
];
for (const [index, child] of state.ui_trees[0]!.root.children.entries()) {
child.metadata.layout_status = { NeedReview: `请检查 ${index}` };
}
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue({ revision: 0, state }),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
const scrollSpy = vi.fn();
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: scrollSpy,
});
const cancel = vi.fn();
const play = vi.fn();
const getAnimationsSpy = vi.fn(() => [
{ cancel, play } as unknown as Animation,
]);
Object.defineProperty(Element.prototype, 'getAnimations', {
configurable: true,
value: getAnimationsSpy,
});
try {
render(
createElement(UiEditorPage, {
projectPath: '/tmp/ui-editor-cycle-animation',
resourceId: 'ui-resource',
stateStore,
initialStep: 'structure-recognition',
initialFurthestStepIndex: 1,
}),
);
const button = await screen.findByRole('button', {
name: '待用户检查 3,定位下一项',
});
for (const reason of ['请检查 0', '请检查 1', '请检查 2', '请检查 0']) {
fireEvent.click(button);
await screen.findByText(reason);
}
expect(getAnimationsSpy).toHaveBeenCalledTimes(4);
expect(cancel).toHaveBeenCalledTimes(4);
expect(play).toHaveBeenCalledTimes(4);
expect(scrollSpy).toHaveBeenCalledTimes(4);
} finally {
delete (Element.prototype as Element & { getAnimations?: unknown })
.getAnimations;
delete (Element.prototype as Element & { scrollIntoView?: unknown })
.scrollIntoView;
}
});
it('keeps the status highlight after the tree handles controlled selection', async () => {
const state = stateWithPages(['page']);
state.ui_trees[0]!.root.children = [
node('review-a'),
node('review-b'),
node('review-c'),
];
for (const child of state.ui_trees[0]!.root.children) {
child.metadata.layout_status = { NeedReview: '请检查' };
}
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue({ revision: 0, state }),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
render(
createElement(UiEditorPage, {
projectPath: '/tmp/ui-editor-cycle-highlight',
resourceId: 'ui-resource',
stateStore,
initialStep: 'structure-recognition',
initialFurthestStepIndex: 1,
}),
);
const button = await screen.findByRole('button', {
name: '待用户检查 3,定位下一项',
});
try {
for (let index = 0; index < 4; index += 1) {
fireEvent.click(button);
await waitFor(() => {
expect(
document.querySelector('[data-status-attention]'),
).not.toBeNull();
});
}
} finally {
delete (Element.prototype as Element & { scrollIntoView?: unknown })
.scrollIntoView;
}
});
it('switches tools freely without inventing completed workflow state', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));