修复 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
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:
+16
-11
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user