修复 UI 编辑器待办循环高亮动画
修复受控树选中回调清除 Inspector 高亮的问题 循环游标同时使用界面树与节点 ID 补充多节点循环和动画重启回归测试
This commit is contained in:
@@ -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)
|
||||
|
||||
+17
-2
@@ -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' }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user