修复 UI 编辑器循环游标与复制态被延迟 effect 覆盖
Project CI / AI game creator shell Rust smoke (push) Successful in 2m15s
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / Backend tests (push) Successful in 4m34s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m49s
Project CI / Frontend tests (push) Successful in 2m43s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 11m59s
Project CI / AI game creator shell web tests (push) Successful in 1m46s
Project CI / Repository checks (push) Successful in 2m25s

界面树循环游标改为渲染期换算有效游标,不再用被动 effect 事后重置
复制路径按钮把复制态绑定到具体路径,去掉延迟重置的 effect
复制态断言改为等待异步落盘,避免同步断言抢跑
新增 useUiTreeNodeCycle 循环顺序与界面树替换重置用例
This commit is contained in:
kdletters
2026-10-02 11:17:28 +08:00
parent f2127a6489
commit fbd30d7c66
4 changed files with 133 additions and 17 deletions
@@ -1,31 +1,36 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Copy } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
type CopyState = 'idle' | 'copied' | 'failed';
export function UiEditorCopyPathButton({
relativePath,
}: {
relativePath: string;
}) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
useEffect(() => setCopyState('idle'), [relativePath]);
// 复制态跟着具体路径走:路径变了就等于回到未复制。这里不用 effect 事后重置,
// 因为延迟执行的 effect 会晚于复制结果落盘,把「已复制」覆盖回未复制。
const [copyState, setCopyState] = useState<{
relativePath: string;
value: CopyState;
}>({ relativePath, value: 'idle' });
const value =
copyState.relativePath === relativePath ? copyState.value : 'idle';
async function copyPath() {
setCopyState('idle');
setCopyState({ relativePath, value: 'idle' });
try {
await writeText(relativePath);
setCopyState('copied');
setCopyState({ relativePath, value: 'copied' });
} catch {
setCopyState('failed');
setCopyState({ relativePath, value: 'failed' });
}
}
return (
<>
{copyState === 'failed' ? (
{value === 'failed' ? (
<p className="mt-2 text-xs text-red-600" role="alert">
复制失败,请手动复制路径。
</p>
@@ -36,7 +41,7 @@ export function UiEditorCopyPathButton({
onClick={() => void copyPath()}
>
<Copy size={14} aria-hidden="true" />
{copyState === 'copied' ? '已复制' : '复制路径'}
{value === 'copied' ? '已复制' : '复制路径'}
</button>
</>
);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import {
getNextMatchingUiTreeNodeTarget,
@@ -9,6 +9,11 @@ import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
import type { UITree } from '../../features/ui-editor/types/UITree';
type UiTreeNodeCycleState = {
trees: UITree[];
cursor: UiTreeNodeCursor | null;
};
export function useUiTreeNodeCycle({
uiTrees,
matches,
@@ -18,9 +23,15 @@ export function useUiTreeNodeCycle({
matches: (target: UiTreeNodeTarget) => boolean;
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) {
const [lastCursor, setLastCursor] = useState<UiTreeNodeCursor | null>(null);
useEffect(() => setLastCursor(null), [uiTrees]);
// 界面树整体被替换时,新一轮循环要从第一项重新开始。这里把「游标属于哪一版
// 界面树」一起记下来,在渲染期直接换算成有效游标,而不是放进 useEffect:
// 被动 effect 的 flush 可能晚于紧随其后的点击,会把刚落盘的游标覆盖回 null,
// 让「定位下一项」反复停在第一项。
const [cycle, setCycle] = useState<UiTreeNodeCycleState>({
trees: uiTrees,
cursor: null,
});
const lastCursor = cycle.trees === uiTrees ? cycle.cursor : null;
const focusNext = useCallback(() => {
const target = getNextMatchingUiTreeNodeTarget(
@@ -29,7 +40,10 @@ export function useUiTreeNodeCycle({
matches,
);
if (!target) return;
setLastCursor({ treeId: target.treeId, nodeId: target.node.id });
setCycle({
trees: uiTrees,
cursor: { treeId: target.treeId, nodeId: target.node.id },
});
onFocusNode(target.treeId, target.node.id);
}, [lastCursor, matches, onFocusNode, uiTrees]);
@@ -916,7 +916,8 @@ describe('UiEditorPage', () => {
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('ui/generated-example.js'),
);
expect(screen.getByRole('button', { name: '已复制' })).toBeTruthy();
// 复制态在剪贴板写入 resolve 之后才提交,必须等它落盘再断言。
expect(await screen.findByRole('button', { name: '已复制' })).toBeTruthy();
});
it('shows a save-success modal without a generated path', async () => {
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
isNeedReview,
type UiTreeNodeTarget,
} from '../src/features/ui-editor/stageStatusOverview';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UITree } from '../src/features/ui-editor/types/UITree';
import { useUiTreeNodeCycle } from '../src/view/ui-editor/useUiTreeNodeCycle';
function node(
id: string,
layoutStatus: UiNode['metadata']['layout_status'],
): UiNode {
return {
id,
layout: {
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
custom_minimum_size: [0, 0],
size_flags_horizontal: 1,
size_flags_vertical: 1,
size_flags_stretch_ratio: 1,
container: 'None',
},
metadata: {
name: id,
description: '',
layout_status: layoutStatus,
component_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
component: null,
children_display_mode: undefined,
offset: { min: [0, 0], max: [0, 0] },
children: [],
};
}
function reviewTree(): UITree {
return {
src_ui_design: 'page',
root: {
...node('page-root', 'NoProblem'),
children: [
node('review-a', { NeedReview: '请检查 0' }),
node('review-b', { NeedReview: '请检查 1' }),
node('review-c', { NeedReview: '请检查 2' }),
],
},
};
}
const matchesReviewNode = ({ node }: UiTreeNodeTarget) =>
isNeedReview(node.metadata.layout_status);
describe('useUiTreeNodeCycle', () => {
it('按顺序循环待检查节点,并在界面树整体替换后重新从第一项开始', () => {
const uiTrees = [reviewTree()];
const onFocusNode = vi.fn();
const { result, rerender } = renderHook(
({ trees }: { trees: UITree[] }) =>
useUiTreeNodeCycle({
uiTrees: trees,
matches: matchesReviewNode,
onFocusNode,
}),
{ initialProps: { trees: uiTrees } },
);
act(() => result.current.focusNext());
expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-a');
act(() => result.current.focusNext());
expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-b');
// 同一版界面树上的无关渲染不得重置循环游标。
rerender({ trees: uiTrees });
act(() => result.current.focusNext());
expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-c');
// 界面树被整体替换时,下一项回到第一项。
rerender({ trees: [...uiTrees] });
act(() => result.current.focusNext());
expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-a');
});
});