前端移除分析参考图步骤与界面图元数据编辑

- model.ts 收敛为“识别界面结构 / 自动切分素材”两步并删除界面角色标签表

- 删除 suggestion 异步操作、结果通知分支、uiDesignSuggestions 与其 ts-rs 生成类型

- InputSidebar、InspectorSidebar、PreviewWorkspace 的界面图显示名改用 path basename,Inspector 只保留尺寸与资源 ID

- 删除因步骤退役而不可达的 ImportOverview,ToolNavigation 改为两格,删除“清空界面归属”提示

- requisites 删除角色/归属前置校验与参考图分析结果校验,importAdapter 不再写入界面图元数据

- 测试夹具去掉界面图元数据,删除界面角色与归属用例,历史用例改用 sprite 名称断言
This commit is contained in:
2026-09-23 13:30:04 +08:00
parent e18a398cdf
commit 81dc7b0cd6
25 changed files with 73 additions and 772 deletions
@@ -66,12 +66,6 @@ export async function prepareDesignImageBatch(
scopeId,
);
const image: UIDesignImage = {
metadata: {
name: '',
description: '',
role: null,
slave_to: null,
},
path: asset.localPath,
pixel_size: pixelSize,
pixels_per_unit: 1,
@@ -38,37 +38,6 @@ export function validateComponentRecognitionPrerequisites(
if (ids.length > 4) {
issues.push({ code: 'design-image-limit', message: '界面图最多 4 张' });
}
for (const id of ids) {
const image = state.ui_design_images[id]!;
const { role, slave_to: slaveTo } = image.metadata;
if (role === 'Page' && slaveTo !== null) {
issues.push({
code: 'page-has-slave-to',
message: '主页面不能设置归属页面',
resourceId: id,
});
} else if (role !== null && role !== 'Page') {
if (slaveTo === null) {
issues.push({
code: 'missing-slave-to',
message: '该界面角色需要选择归属主页面',
resourceId: id,
});
} else if (slaveTo === id) {
issues.push({
code: 'self-slave-to',
message: '界面不能归属于自身',
resourceId: id,
});
} else if (state.ui_design_images[slaveTo]?.metadata.role !== 'Page') {
issues.push({
code: 'invalid-slave-to',
message: '归属页面必须是有效主页面',
resourceId: id,
});
}
}
}
return issues;
}
@@ -131,24 +100,6 @@ function visitNodes(
}
// 结果检查与进入步骤的前置条件保持分离;调用方只把结果作为非阻塞提示。
export function validateReferenceAnalysisResult(
state: State,
): UiEditorPrerequisiteIssue[] {
const images = Object.values(state.ui_design_images);
if (images.length === 0) {
return [{ code: 'missing-analysis-input', message: '尚未导入参考图' }];
}
if (images.every((image) => image.metadata.role === null)) {
return [
{
code: 'missing-reference-analysis',
message: '参考图尚未设置界面用途',
},
];
}
return [];
}
export function validateStructureRecognitionResult(
state: State,
): UiEditorPrerequisiteIssue[] {
@@ -1,4 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UIDesignImageMetadata } from "./UIDesignImageMetadata";
export type UIDesignImage = { metadata: UIDesignImageMetadata, path: string, pixel_size: [number, number], pixels_per_unit: number, };
export type UIDesignImage = { path: string, pixel_size: [number, number], pixels_per_unit: number, };
@@ -1,5 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UIDesignImageId } from "./UIDesignImageId";
import type { UIDesignImageRole } from "./UIDesignImageRole";
export type UIDesignImageMetadata = { name: string, description: string, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, };
@@ -1,3 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type UIDesignImageRole = "Page" | "Section" | "Modal" | "Drawer" | "Popover" | "State" | "Scrolled" | "Detail";
@@ -1,5 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UIDesignImageId } from "./UIDesignImageId";
import type { UIDesignImageRole } from "./UIDesignImageRole";
export type UIDesignSuggestionTreeNode = { id: UIDesignImageId, name: string, description: string, role: UIDesignImageRole, children: Array<UIDesignSuggestionTreeNode>, };
@@ -1,52 +0,0 @@
import type { State } from './types/State';
import type { UIDesignImageId } from './types/UIDesignImageId';
import type { UIDesignSuggestionTreeNode } from './types/UIDesignSuggestionTreeNode';
/**
* Applies semantic suggestions conservatively. The tree carries relationships;
* existing metadata remains authoritative whenever it is already populated.
* Persisted `slave_to` remains the owning Page ID, so nested descendants inherit
* their root Page rather than pointing at an intermediate Section.
*/
export function applyUiDesignSuggestions(
state: State,
suggestions: readonly UIDesignSuggestionTreeNode[],
): State {
const next = structuredClone(state);
function applyNode(
node: UIDesignSuggestionTreeNode,
pageId: UIDesignImageId | null,
): void {
const image = next.ui_design_images[node.id];
if (!image) return;
if (image.metadata.name.trim().length === 0 && node.name.trim()) {
image.metadata.name = node.name.trim();
}
if (image.metadata.role === null) {
image.metadata.role = node.role;
}
if (image.metadata.slave_to === null && pageId !== null) {
image.metadata.slave_to = pageId;
}
if (
image.metadata.description.trim().length === 0 &&
node.description.trim()
) {
image.metadata.description = node.description.trim();
}
const effectiveRole = image.metadata.role ?? node.role;
const childPageId = effectiveRole === 'Page' ? node.id : pageId;
for (const child of node.children) {
applyNode(child, childPageId);
}
}
for (const root of suggestions) {
applyNode(root, null);
}
return next;
}
@@ -25,7 +25,6 @@ import type { SpriteBorder } from './types/SpriteBorder';
import type { State } from './types/State';
import type { UIDesignImage } from './types/UIDesignImage';
import type { UIDesignImageId } from './types/UIDesignImageId';
import type { UIDesignImageRole } from './types/UIDesignImageRole';
import type { UiNodeMoveRequest } from './types/UiNodeMoveRequest';
export const EMPTY_UI_EDITOR_STATE: State = {
@@ -85,23 +84,6 @@ type NodeLocation = {
index: number;
};
function wouldCreateSlaveToCycle(
images: State['ui_design_images'],
id: UIDesignImageId,
slaveTo: UIDesignImageId,
): boolean {
let current: UIDesignImageId | null = slaveTo;
const visited = new Set<UIDesignImageId>();
while (current !== null) {
if (current === id || visited.has(current)) return true;
visited.add(current);
const image: UIDesignImage | undefined = images[current];
if (!image) return true;
current = image.metadata.slave_to;
}
return false;
}
function visitNodes(node: Node, visit: (node: Node) => void): void {
visit(node);
for (const child of node.children) visitNodes(child, visit);
@@ -278,7 +260,6 @@ export type DesignImageInput = {
export type RemovalImpact = {
removedResourceCount: number;
removedTreeCount: number;
clearedSlaveToCount: number;
clearedTargetGraphicCount: number;
clearedFontCount: number;
};
@@ -495,9 +476,6 @@ export function designImageRemovalImpact(
removedResourceCount: id in state.ui_design_images ? 1 : 0,
removedTreeCount: state.ui_trees.filter((tree) => tree.src_ui_design === id)
.length,
clearedSlaveToCount: Object.values(state.ui_design_images).filter(
(image) => image.metadata.slave_to === id,
).length,
clearedTargetGraphicCount: 0,
clearedFontCount: 0,
};
@@ -518,7 +496,6 @@ export function spriteAssetRemovalImpact(
return {
removedResourceCount: id in state.sprite_assets ? 1 : 0,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount,
clearedFontCount: 0,
};
@@ -539,7 +516,6 @@ export function fontAssetRemovalImpact(
return {
removedResourceCount: id in state.font_assets ? 1 : 0,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount: 0,
clearedFontCount,
};
@@ -740,86 +716,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
[],
);
const setImageName = useCallback(
(id: UIDesignImageId, name: string): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.name = name;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setImageDescription = useCallback(
(id: UIDesignImageId, description: string): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.description = description;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setImageRole = useCallback(
(
id: UIDesignImageId,
role: UIDesignImageRole | null,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.role = role;
synchronizeDesignImageTrees(next);
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setImageSlaveTo = useCallback(
(
id: UIDesignImageId,
slaveTo: UIDesignImageId | null,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
const current = stateRef.current;
if (!(id in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
if (slaveTo !== null && !(slaveTo in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
if (
slaveTo !== null &&
wouldCreateSlaveToCycle(current.ui_design_images, id, slaveTo)
) {
return { ok: false, reason: 'invalid:slave_to 不能形成循环' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.slave_to = slaveTo;
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const addDesignImages = useCallback(
(entries: readonly DesignImageInput[]): UiEditorOperationResult => {
const blocked = guard();
@@ -1384,9 +1280,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
const next = cloneState(current);
delete next.ui_design_images[id];
next.ui_trees = next.ui_trees.filter((tree) => tree.src_ui_design !== id);
for (const image of Object.values(next.ui_design_images)) {
if (image.metadata.slave_to === id) image.metadata.slave_to = null;
}
commit(next);
return { ok: true, value: impact };
},
@@ -1486,10 +1379,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
isLocked,
runWithStateLocked,
setImageName,
setImageDescription,
setImageRole,
setImageSlaveTo,
addDesignImages,
addSpriteAssets,
addFontAssets,
@@ -6352,7 +6352,7 @@ export default function ProjectDevelopmentView({
...(result.asset.source.generationKind === 'ui-workflow.completed'
? {
initialStep: 'asset-separation',
initialFurthestStepIndex: 2,
initialFurthestStepIndex: 1,
}
: {}),
});
@@ -6403,7 +6403,7 @@ export default function ProjectDevelopmentView({
)?.source.generationKind === 'ui-workflow.completed'
? {
initialStep: 'asset-separation' as const,
initialFurthestStepIndex: 2,
initialFurthestStepIndex: 1,
}
: {}),
});
@@ -6508,7 +6508,7 @@ export default function ProjectDevelopmentView({
completed.localPath.split(/[\\/]/u).filter(Boolean).pop() ??
'UI 设计资源',
initialStep: 'asset-separation',
initialFurthestStepIndex: 2,
initialFurthestStepIndex: 1,
});
}, [advanceFocusGeneration, manifest.assets, uiEditorRoute]);
@@ -74,7 +74,6 @@ export function EditorDialogs({
</p>
<ul className="rounded-lg bg-black/4 p-3 pl-7 text-xs leading-6">
<li>{impact?.removedTreeCount ?? 0}</li>
<li>{impact?.clearedSlaveToCount ?? 0}</li>
<li>{impact?.clearedTargetGraphicCount ?? 0}</li>
<li>{impact?.clearedFontCount ?? 0}</li>
</ul>
@@ -1,69 +0,0 @@
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { SpriteAsset } from '../../../features/ui-editor/types/SpriteAsset';
import type { UIDesignImage } from '../../../features/ui-editor/types/UIDesignImage';
import type { UITree } from '../../../features/ui-editor/types/UITree';
function countUiComponents(nodes: UiNode[]): number {
return nodes.reduce(
(total, node) =>
total + (node.component ? 1 : 0) + countUiComponents(node.children),
0,
);
}
export function ImportOverview({
images,
sprites,
uiTrees,
}: {
images: Record<string, UIDesignImage>;
sprites: Record<string, SpriteAsset>;
uiTrees: UITree[];
}) {
const componentCount = uiTrees.reduce(
(total, tree) => total + countUiComponents([tree.root]),
0,
);
return (
<section
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
aria-label="导入概览"
>
<div className="flex items-center justify-between">
<div>
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
Overview
</span>
<h2 className="m-0 text-sm font-semibold"></h2>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
<div className="rounded-lg border border-(--platform-subpanel-border) bg-white/45 p-2 text-center">
<strong className="block text-base font-semibold text-(--platform-text-strong)">
{Object.keys(images).length}
</strong>
<span className="block text-[10px] text-(--platform-text-soft)">
</span>
</div>
<div className="rounded-lg border border-(--platform-subpanel-border) bg-white/45 p-2 text-center">
<strong className="block text-base font-semibold text-(--platform-text-strong)">
{Object.keys(sprites).length}
</strong>
<span className="block text-[10px] text-(--platform-text-soft)">
</span>
</div>
<div className="rounded-lg border border-(--platform-subpanel-border) bg-white/45 p-2 text-center">
<strong className="block text-base font-semibold text-(--platform-text-strong)">
{componentCount}
</strong>
<span className="block text-[10px] text-(--platform-text-soft)">
</span>
</div>
</div>
</section>
);
}
@@ -6,12 +6,17 @@ import { visitUiNodes } from '../../../features/ui-editor/treeUtils';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import { resourceAssetDisplayName } from '../../project-development/resourceAssetDisplayName';
import type { UiEditorInputProjection } from '../useUiEditorPage';
import { CollapsibleSidebarPanel } from './CollapsibleSidebarPanel';
import { UiTreePanel } from './UiTreePanel';
const SUPER_ROOT_ID = '__ui-editor-super-root__';
function designImageLabel(path: string, index: number) {
return resourceAssetDisplayName(path) || `界面图 ${index + 1}`;
}
export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
const {
projectPath,
@@ -130,7 +135,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
>
<div className="space-y-2">
{imageOrder.length > 0 ? (
imageOrder.map((id) => {
imageOrder.map((id, index) => {
const image = images[id];
if (!image) return null;
return (
@@ -157,10 +162,9 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
</div>
<span className="min-w-0 flex-1">
<strong className="block truncate text-xs">
{image.metadata.name}
{designImageLabel(image.path, index)}
</strong>
<span className="block truncate text-[10px] text-(--platform-text-soft)">
{image.metadata.role ?? '自动判断'} ·{' '}
{image.pixel_size[0]} × {image.pixel_size[1]}
</span>
</span>
@@ -12,10 +12,8 @@ import { FontSamplePreview } from '../../../../features/ui-editor/components/Fon
import { SpriteImagePreview } from '../../../../features/ui-editor/components/SpriteImagePreview';
import type { StageStatusField } from '../../../../features/ui-editor/stageStatusOverview';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole';
import { uiEditorPrivateFontFamily } from '../../../../features/ui-editor/useUiEditorFontFaces';
import { UI_DESIGN_IMAGE_ROLES } from '../../model';
import { resourceAssetDisplayName } from '../../../project-development/resourceAssetDisplayName';
import type { UiEditorInspectorProjection } from '../../useUiEditorPage';
import { ComponentPanel } from './Components/ComponentPanel';
import {
@@ -64,7 +62,6 @@ type InspectorView =
kind: 'image';
image: ActiveImage;
imageId: ActiveImageId;
pageOptions: UiEditorInspectorProjection['pageOptions'];
}
| { kind: 'empty' };
@@ -155,11 +152,6 @@ export function InspectorSidebar({
<ImageInspector
image={view.image}
imageId={view.imageId}
pageOptions={view.pageOptions}
onNameChange={inspector.setImageName}
onDescriptionChange={inspector.setImageDescription}
onRoleChange={inspector.setImageRole}
onSlaveToChange={inspector.setImageSlaveTo}
onDelete={() => inspector.requestDesignImageRemoval(view.imageId)}
deleteDisabled={inspector.isLocked}
/>
@@ -226,7 +218,6 @@ function getInspectorView(
spriteReferenceCounts,
fontReferenceCounts,
fontFaces,
pageOptions,
} = inspector;
if (selectedNode) {
@@ -263,7 +254,6 @@ function getInspectorView(
kind: 'image',
image: activeImage,
imageId: activeImageId,
pageOptions,
};
}
@@ -1034,21 +1024,11 @@ function InspectorReadout({ label, value }: { label: string; value: string }) {
function ImageInspector({
image,
imageId,
pageOptions,
onNameChange,
onDescriptionChange,
onRoleChange,
onSlaveToChange,
onDelete,
deleteDisabled,
}: {
image: ActiveImage;
imageId: ActiveImageId;
pageOptions: UiEditorInspectorProjection['pageOptions'];
onNameChange: UiEditorInspectorProjection['setImageName'];
onDescriptionChange: UiEditorInspectorProjection['setImageDescription'];
onRoleChange: UiEditorInspectorProjection['setImageRole'];
onSlaveToChange: UiEditorInspectorProjection['setImageSlaveTo'];
onDelete: () => void;
deleteDisabled: boolean;
}) {
@@ -1056,65 +1036,13 @@ function ImageInspector({
return (
<div className="mt-4 space-y-4">
<InspectorInput
label="名称"
value={image.metadata.name}
onChange={(event) => {
if (!readOnly) onNameChange(event.target.value);
}}
/>
<InspectorTextarea
label="描述"
value={image.metadata.description}
placeholder="补充这张界面图的语义描述"
onChange={(event) => {
if (!readOnly) onDescriptionChange(event.target.value);
}}
/>
<div className="text-xs text-(--platform-text-soft)">
{resourceAssetDisplayName(image.path)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<Metric label="宽度" value={`${image.pixel_size[0]} px`} />
<Metric label="高度" value={`${image.pixel_size[1]} px`} />
</div>
<InspectorSelect
label="界面角色"
value={image.metadata.role ?? ''}
onChange={(event) => {
if (!readOnly) {
onRoleChange(
(event.target.value || null) as UIDesignImageRole | null,
);
}
}}
>
<option value=""></option>
{UI_DESIGN_IMAGE_ROLES.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</InspectorSelect>
{image.metadata.role !== 'Page' || image.metadata.slave_to !== null ? (
<InspectorSelect
label="归属主页面"
value={image.metadata.slave_to ?? ''}
onChange={(event) => {
if (!readOnly) {
onSlaveToChange(
(event.target.value || null) as UIDesignImageId | null,
);
}
}}
>
<option value=""></option>
{pageOptions
.filter(([id]) => id !== imageId)
.map(([id, page]) => (
<option key={id} value={id}>
{page.metadata.name}
</option>
))}
</InspectorSelect>
) : null}
<ResourceId value={imageId} />
<DeleteResourceButton
label="删除"
@@ -13,7 +13,7 @@ export function ToolNavigation({
}) {
return (
<nav
className="grid shrink-0 grid-cols-3 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
className="grid shrink-0 grid-cols-2 gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3"
aria-label="UI 编辑流程"
>
{UI_EDITOR_STEPS.map((step, index) => {
@@ -8,17 +8,14 @@ export function WorkflowActionCard({
}) {
const action = getStepAction(workflow);
const running =
(workflow.activeStep === 'reference-analysis' && workflow.isSuggesting) ||
(workflow.activeStep === 'structure-recognition' &&
workflow.isRecognizing) ||
(workflow.activeStep === 'asset-separation' && workflow.isSeparating);
const status = {
'reference-analysis': workflow.suggestionStatus,
'structure-recognition': workflow.recognitionStatus,
'asset-separation': workflow.separationStatus,
}[workflow.activeStep];
const hasRun = {
'reference-analysis': workflow.hasSuggested,
'structure-recognition': workflow.hasRecognized,
'asset-separation': workflow.hasSeparated,
}[workflow.activeStep];
@@ -100,13 +97,6 @@ export function WorkflowActionCard({
}
function getStepAction(workflow: UiEditorWorkflowProjection) {
if (workflow.activeStep === 'reference-analysis') {
return {
label: '分析参考图',
runningLabel: '分析中…',
action: workflow.suggestUiDesignSemantics,
};
}
if (workflow.activeStep === 'structure-recognition') {
return {
label: '识别界面结构',
@@ -3,7 +3,6 @@ import {
validateAssetSeparationPrerequisites,
validateAssetSeparationResult,
validateComponentRecognitionPrerequisites,
validateReferenceAnalysisResult,
validateStructureRecognitionResult,
} from '../../../features/ui-editor/requisites';
import type { State } from '../../../features/ui-editor/types/State';
@@ -16,8 +15,6 @@ export function prerequisiteIssuesForStep(
step: UiEditorStepId,
): UiEditorPrerequisiteIssue[] {
switch (step) {
case 'reference-analysis':
return [];
case 'structure-recognition':
return validateComponentRecognitionPrerequisites(state);
case 'asset-separation':
@@ -30,8 +27,6 @@ export function postCheckIssuesForStep(
step: UiEditorStepId,
): UiEditorPrerequisiteIssue[] {
switch (step) {
case 'reference-analysis':
return validateReferenceAnalysisResult(state);
case 'structure-recognition':
return validateStructureRecognitionResult(state);
case 'asset-separation':
@@ -43,7 +38,6 @@ export function postCheckIssuesForSave(
state: State,
): UiEditorPrerequisiteIssue[] {
return [
...validateReferenceAnalysisResult(state),
...validateStructureRecognitionResult(state),
...validateAssetSeparationResult(state),
];
@@ -54,8 +48,6 @@ export function activeStepPrerequisiteIssues(
step: UiEditorStepId,
): UiEditorPrerequisiteIssue[] {
switch (step) {
case 'reference-analysis':
return validateComponentRecognitionPrerequisites(state);
case 'structure-recognition':
return validateAssetSeparationPrerequisites(state);
case 'asset-separation':
@@ -31,6 +31,7 @@ import {
import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry';
import type { Node } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import { resourceAssetDisplayName } from '../../../project-development/resourceAssetDisplayName';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
import { resolvePreviewGridStep } from './previewGrid';
@@ -694,7 +695,9 @@ export function PreviewWorkspace({
{showOriginImage && previewUrls[item.src_ui_design] ? (
<img
src={previewUrls[item.src_ui_design]}
alt={image?.metadata.name ?? ''}
alt={
image ? resourceAssetDisplayName(image.path) : ''
}
className="pointer-events-none absolute inset-0 size-full object-fill shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
draggable={false}
/>
@@ -14,8 +14,6 @@ export function appendWorkflowCheckPrompt(message: string): string {
export function workflowStepLabel(step: UiEditorStepId): string {
switch (step) {
case 'reference-analysis':
return '分析参考图';
case 'structure-recognition':
return '识别界面结构';
case 'asset-separation':
@@ -9,7 +9,6 @@ import {
uiDesignStateStore,
} from '../../features/ui-editor/uiDesignStateStore';
import { EditorDialogs } from './components/EditorDialogs';
import { ImportOverview } from './components/ImportOverview';
import { InputSidebar } from './components/InputSidebar';
import { InspectorSidebar } from './components/Inspector/InspectorSidebar';
import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
@@ -91,7 +90,7 @@ export default function UiEditorPage({
}}
/>
);
} else if (session.input.activeStep === 'asset-separation') {
} else {
overview = (
<SeparationOverview
uiTrees={session.input.uiTrees}
@@ -103,14 +102,6 @@ export default function UiEditorPage({
}}
/>
);
} else {
overview = (
<ImportOverview
images={session.input.images}
sprites={session.input.sprites}
uiTrees={session.input.uiTrees}
/>
);
}
const saveDisabled =
session.save.isSaving ||
@@ -1,12 +1,8 @@
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState';
export type UiEditorStepId =
| 'reference-analysis'
| 'structure-recognition'
| 'asset-separation';
export type UiEditorStepId = 'structure-recognition' | 'asset-separation';
export type UiEditorImportKind = 'design-image' | 'font' | 'sprite';
export type UiEditorNodeFocusRequest = {
@@ -25,29 +21,13 @@ export const UI_EDITOR_STEPS: Array<{
id: UiEditorStepId;
label: string;
}> = [
{ id: 'reference-analysis', label: '分析参考图' },
{ id: 'structure-recognition', label: '识别界面结构' },
{ id: 'asset-separation', label: '自动切分素材' },
];
export const UI_DESIGN_IMAGE_ROLES: Array<{
value: UIDesignImageRole;
label: string;
}> = [
{ value: 'Page', label: '主页面' },
{ value: 'Section', label: '子界面 / 页签' },
{ value: 'Modal', label: '模态弹窗' },
{ value: 'Drawer', label: '抽屉 / 侧栏' },
{ value: 'Popover', label: '局部浮层' },
{ value: 'State', label: '交互状态' },
{ value: 'Scrolled', label: '滚动后内容' },
{ value: 'Detail', label: '局部详情' },
];
export function removalHasDownstreamReferences(impact: RemovalImpact) {
return (
impact.removedTreeCount +
impact.clearedSlaveToCount +
impact.clearedTargetGraphicCount +
impact.clearedFontCount >
0
@@ -35,18 +35,13 @@ import type { SeparationDTO } from '../../features/ui-editor/types/SeparationDTO
import type { SeparationRecoveryDTO } from '../../features/ui-editor/types/SeparationRecoveryDTO';
import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId';
import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder';
import type { State } from '../../features/ui-editor/types/State';
import type { UIDesignImage } from '../../features/ui-editor/types/UIDesignImage';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
import type { UIDesignSuggestionTreeNode } from '../../features/ui-editor/types/UIDesignSuggestionTreeNode';
import type { UiNodeMoveRequest } from '../../features/ui-editor/types/UiNodeMoveRequest';
import {
type IUiDesignStateStore,
type UiDesignCodeGenerationResult,
uiDesignStateStore,
} from '../../features/ui-editor/uiDesignStateStore';
import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions';
import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces';
import { addSpriteAssetsToState } from '../../features/ui-editor/useUiEditorState';
import {
@@ -77,6 +72,7 @@ import {
import {
type PendingResourceRemoval,
removalHasDownstreamReferences,
UI_EDITOR_STEPS,
type UiEditorImportKind,
uiEditorOperationError,
type UiEditorStepId,
@@ -151,25 +147,6 @@ function isUiNodeEffectivelyVisible(
return null;
}
function isSlaveToDescendant(
images: State['ui_design_images'],
candidateId: UIDesignImageId,
ancestorId: UIDesignImageId | null,
): boolean {
if (ancestorId === null || candidateId === ancestorId) return true;
let current: UIDesignImageId | null = candidateId;
const visited = new Set<UIDesignImageId>();
while (current !== null) {
if (visited.has(current)) return true;
visited.add(current);
const image: UIDesignImage | undefined = images[current];
if (!image) return true;
current = image.metadata.slave_to;
if (current === ancestorId) return true;
}
return false;
}
export type PendingWorkflowStepChange = {
from: UiEditorStepId;
to: UiEditorStepId;
@@ -187,7 +164,7 @@ export function useUiEditorSession(
projectPath: string,
resourceId?: string,
stateStore: IUiDesignStateStore = uiDesignStateStore,
initialStep: UiEditorStepId = 'reference-analysis',
initialStep: UiEditorStepId = 'structure-recognition',
initialFurthestStepIndex = 0,
) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
@@ -206,15 +183,16 @@ export function useUiEditorSession(
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const normalizedInitialStepIndex =
initialStep === 'reference-analysis'
? 0
: initialStep === 'structure-recognition'
? 1
: 2;
const normalizedInitialStepIndex = Math.max(
0,
UI_EDITOR_STEPS.findIndex((step) => step.id === initialStep),
);
const normalizedInitialFurthestStepIndex = Math.max(
normalizedInitialStepIndex,
Math.min(2, Math.max(0, Math.trunc(initialFurthestStepIndex))),
Math.min(
UI_EDITOR_STEPS.length - 1,
Math.max(0, Math.trunc(initialFurthestStepIndex)),
),
);
const [activeStep, setActiveStep] = useState<UiEditorStepId>(initialStep);
const [furthestStepIndex, setFurthestStepIndex] = useState(
@@ -251,15 +229,9 @@ export function useUiEditorSession(
const [clearOpen, setClearOpen] = useState(false);
const [pendingRemoval, setPendingRemoval] =
useState<PendingResourceRemoval | null>(null);
const suggestionOperation = useUiEditorOperation();
const recognitionOperation = useUiEditorOperation();
const mergeOperation = useUiEditorOperation();
const separationOperation = useUiEditorOperation();
const isSuggesting = suggestionOperation.running;
const suggestionStatus = suggestionOperation.status;
const beginSuggestion = suggestionOperation.begin;
const finishSuggestion = suggestionOperation.finish;
const setSuggestionStatus = suggestionOperation.setStatus;
const isRecognizing = recognitionOperation.running;
const recognitionStatus = recognitionOperation.status;
const beginRecognition = recognitionOperation.begin;
@@ -277,7 +249,6 @@ export function useUiEditorSession(
const setSeparationStatus = separationOperation.setStatus;
const [separationRecovery, setSeparationRecovery] =
useState<SeparationRecoveryDTO | null>(null);
const [hasSuggested, setHasSuggested] = useState(false);
const [hasRecognized, setHasRecognized] = useState(false);
const [hasSeparated, setHasSeparated] = useState(false);
const [completionNotice, setCompletionNotice] =
@@ -411,13 +382,7 @@ export function useUiEditorSession(
const activeImage = activeImageId ? images[activeImageId] : null;
const selectedSprite = selectedSpriteId ? sprites[selectedSpriteId] : null;
const selectedFont = selectedFontId ? fonts[selectedFontId] : null;
const pageOptions = Object.entries(images).filter(
([id, image]) =>
image.metadata.role === 'Page' &&
!isSlaveToDescendant(images, id as UIDesignImageId, activeImageId),
);
const isAiRunning =
isSuggesting || isRecognizing || isMerging || isSeparating;
const isAiRunning = isRecognizing || isMerging || isSeparating;
const isWorkflowBusy =
isAiRunning || isSaving || isGenerating || isLoading || editor.isLocked;
const stateSignature = JSON.stringify(editor.state);
@@ -426,7 +391,6 @@ export function useUiEditorSession(
savedStateSignature !== null &&
savedStateSignature !== stateSignature;
const nextStepByStep: Partial<Record<UiEditorStepId, UiEditorStepId>> = {
'reference-analysis': 'structure-recognition',
'structure-recognition': 'asset-separation',
};
const nextStep = nextStepByStep[activeStep] ?? null;
@@ -717,13 +681,10 @@ export function useUiEditorSession(
function enterStep(step: UiEditorStepId) {
setActiveStep(step);
const index =
step === 'reference-analysis'
? 0
: step === 'structure-recognition'
? 1
: 2;
setFurthestStepIndex((current) => Math.max(current, index));
const index = UI_EDITOR_STEPS.findIndex(
(candidate) => candidate.id === step,
);
setFurthestStepIndex((current) => Math.max(current, Math.max(0, index)));
}
function requestStepChange(step: UiEditorStepId) {
@@ -953,26 +914,6 @@ export function useUiEditorSession(
setSelectedFontId(id);
}
function setImageName(name: string) {
if (!activeImageId) return;
editor.setImageName(activeImageId, name);
}
function setImageDescription(description: string) {
if (!activeImageId) return;
editor.setImageDescription(activeImageId, description);
}
function setImageRole(role: UIDesignImageRole | null) {
if (!activeImageId) return;
editor.setImageRole(activeImageId, role);
}
function setImageSlaveTo(slaveTo: UIDesignImageId | null) {
if (!activeImageId) return;
editor.setImageSlaveTo(activeImageId, slaveTo);
}
function setSpriteName(name: string) {
if (!selectedSpriteId) return;
editor.setSpriteName(selectedSpriteId, name);
@@ -988,38 +929,6 @@ export function useUiEditorSession(
editor.setSpriteBorder(selectedSpriteId, border);
}
async function suggestUiDesignSemantics() {
if (isSuggesting || isWorkflowBusy) return;
setCompletionNotice(null);
setSuggestionStatus(null);
beginSuggestion();
try {
await editor.runWithStateLocked(async (snapshot) => {
const suggestions = await invoke<UIDesignSuggestionTreeNode[]>(
'suggest_ui_design_semantic',
{ projectPath, state: snapshot },
);
editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions));
setHasSuggested(true);
reportWorkflowCompletion(
'reference-analysis',
'success',
`参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`,
setSuggestionStatus,
);
});
} catch (cause) {
reportWorkflowCompletion(
'reference-analysis',
'failure',
cause instanceof Error ? cause.message : String(cause),
setSuggestionStatus,
);
} finally {
finishSuggestion();
}
}
async function recognizeUi() {
if (isRecognizing || isWorkflowBusy) return;
setCompletionNotice(null);
@@ -1587,16 +1496,13 @@ export function useUiEditorSession(
operations: {
isMerging,
isRecognizing,
isSuggesting,
mergeStatus,
recognitionStatus,
suggestionStatus,
},
checkPrerequisites,
separateUi,
mergeUi,
recognizeUi,
suggestUiDesignSemantics,
openImporter: setImportKind,
selectDesignImage,
selectSprite,
@@ -1666,7 +1572,6 @@ export function useUiEditorSession(
sprites,
fonts,
fontFaces,
pageOptions,
spriteReferenceCounts,
fontReferenceCounts,
keepChildrenUnchanged,
@@ -1685,10 +1590,6 @@ export function useUiEditorSession(
setSpriteBorder,
requestSpriteRemoval,
requestFontRemoval,
setImageName,
setImageDescription,
setImageRole,
setImageSlaveTo,
requestDesignImageRemoval,
},
workflow: {
@@ -1698,10 +1599,6 @@ export function useUiEditorSession(
isAiRunning,
isBusy: isWorkflowBusy,
pendingStepChange: pendingWorkflowStepChange,
isSuggesting,
hasSuggested,
suggestionStatus,
suggestUiDesignSemantics,
isRecognizing,
hasRecognized,
recognitionStatus,
@@ -52,12 +52,6 @@ const root: UiNode = {
};
const activeImage: UIDesignImage = {
metadata: {
name: '测试界面',
description: '',
role: null,
slave_to: null,
},
path: 'assets/page.png',
pixel_size: [1200, 800],
pixels_per_unit: 1,
@@ -1,93 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { State } from '../src/features/ui-editor/types/State';
import { applyUiDesignSuggestions } from '../src/features/ui-editor/uiDesignSuggestions';
function state(): State {
return {
ui_trees: [],
sprite_assets: {},
font_assets: {},
ui_design_images: {
page: {
metadata: { name: '', description: '', role: null, slave_to: null },
path: 'page.png',
pixel_size: [100, 100],
pixels_per_unit: 1,
},
section: {
metadata: {
name: '已有名称',
description: '',
role: 'Section',
slave_to: null,
},
path: 'section.png',
pixel_size: [100, 100],
pixels_per_unit: 1,
},
detail: {
metadata: {
name: '',
description: '已有描述',
role: null,
slave_to: null,
},
path: 'detail.png',
pixel_size: [100, 100],
pixels_per_unit: 1,
},
},
};
}
describe('applyUiDesignSuggestions', () => {
it('masks existing metadata and maps descendants to their owning Page', () => {
const before = state();
const after = applyUiDesignSuggestions(before, [
{
id: 'page',
name: '主页面',
description: '页面描述',
role: 'Page',
children: [
{
id: 'section',
name: '模型名称不应覆盖',
description: '页签描述',
role: 'Section',
children: [
{
id: 'detail',
name: '详情',
description: '模型描述不应覆盖',
role: 'Detail',
children: [],
},
],
},
],
},
]);
expect(after.ui_design_images.page.metadata).toEqual({
name: '主页面',
description: '页面描述',
role: 'Page',
slave_to: null,
});
expect(after.ui_design_images.section.metadata).toEqual({
name: '已有名称',
description: '页签描述',
role: 'Section',
slave_to: 'page',
});
expect(after.ui_design_images.detail.metadata).toEqual({
name: '详情',
description: '已有描述',
role: 'Detail',
slave_to: 'page',
});
expect(before.ui_design_images.page.metadata.name).toBe('');
});
});
@@ -103,12 +103,6 @@ function stateWithPages(pageIds: string[]): State {
pageIds.map((id) => [
id,
{
metadata: {
name: id,
description: '',
role: 'Page',
slave_to: null,
},
path: `assets/${id}.png`,
pixel_size: [320, 180],
pixels_per_unit: 1,
@@ -362,10 +356,6 @@ describe('UiEditorPage', () => {
it('renders the overview that belongs to the active workflow stage', () => {
render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' }));
expect(screen.getByRole('heading', { name: '导入概览' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /识别界面结构/ }));
fireEvent.click(screen.getByRole('button', { name: '仍然继续' }));
expect(screen.getByRole('heading', { name: '识别概览' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /自动切分素材/ }));
@@ -391,7 +381,7 @@ describe('UiEditorPage', () => {
resourceId: 'ui-resource',
stateStore,
initialStep: 'asset-separation',
initialFurthestStepIndex: 2,
initialFurthestStepIndex: 1,
}),
);
@@ -456,7 +446,7 @@ describe('UiEditorPage', () => {
resourceId: 'ui-resource',
stateStore,
initialStep: 'structure-recognition',
initialFurthestStepIndex: 1,
initialFurthestStepIndex: 0,
}),
);
const button = await screen.findByRole('button', {
@@ -504,7 +494,7 @@ describe('UiEditorPage', () => {
resourceId: 'ui-resource',
stateStore,
initialStep: 'structure-recognition',
initialFurthestStepIndex: 1,
initialFurthestStepIndex: 0,
}),
);
@@ -18,7 +18,6 @@ import {
function image(name: string): UIDesignImage {
return {
metadata: { name, role: null, slave_to: null },
path: `assets/${name}.png`,
pixel_size: [100, 80],
pixels_per_unit: 1,
@@ -264,15 +263,11 @@ describe('useUiEditorState', () => {
{ id: 'section-a', image: image('Section A') },
]),
).toEqual({ ok: true, value: undefined });
result.current.setImageRole('page-a', 'Page');
result.current.setImageRole('section-a', 'Section');
result.current.setImageSlaveTo('section-a', 'page-a');
result.current.setImageName('section-a', '任务页');
});
expect(result.current.state.ui_design_images['section-a']).toMatchObject({
metadata: { name: '任务页', role: 'Section', slave_to: 'page-a' },
});
expect(result.current.state.ui_design_images['section-a']).toEqual(
image('Section A'),
);
expect(result.current.state.ui_trees).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -312,33 +307,6 @@ describe('useUiEditorState', () => {
});
});
it('rejects self-references and slave_to cycles', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([
{ id: 'page', image: image('Page') },
{ id: 'section', image: image('Section') },
]);
result.current.setImageRole('page', 'Page');
result.current.setImageSlaveTo('section', 'page');
});
act(() => {
expect(result.current.setImageSlaveTo('page', 'page')).toEqual({
ok: false,
reason: 'invalid:slave_to 不能形成循环',
});
expect(result.current.setImageSlaveTo('page', 'section')).toEqual({
ok: false,
reason: 'invalid:slave_to 不能形成循环',
});
});
expect(result.current.state.ui_design_images.page?.metadata.slave_to).toBe(
null,
);
});
it('adds a batch atomically and rejects duplicates and limits', () => {
const { result } = renderHook(() => useUiEditorState());
@@ -395,7 +363,11 @@ describe('useUiEditorState', () => {
expect(result.current.isLocked).toBe(true);
act(() => {
expect(result.current.setImageName('a', 'Locked')).toEqual({
expect(
result.current.addDesignImages([
{ id: 'locked', image: image('Locked') },
]),
).toEqual({
ok: false,
reason: 'locked',
});
@@ -424,14 +396,8 @@ describe('useUiEditorState', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: {
page: {
...image('Page'),
metadata: { name: 'Page', role: 'Page', slave_to: null },
},
child: {
...image('Child'),
metadata: { name: 'Child', role: 'Section', slave_to: 'page' },
},
page: image('Page'),
child: image('Child'),
},
sprite_assets: { panel: sprite('panel') },
ui_trees: [
@@ -478,7 +444,6 @@ describe('useUiEditorState', () => {
value: {
removedResourceCount: 1,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount: 1,
clearedFontCount: 0,
},
@@ -493,9 +458,7 @@ describe('useUiEditorState', () => {
expect(result.current.state.sprite_assets.panel).toBeUndefined();
expect(result.current.state.ui_trees).toHaveLength(1);
expect(result.current.state.ui_design_images.page).toBeUndefined();
expect(
result.current.state.ui_design_images.child?.metadata.slave_to,
).toBeNull();
expect(result.current.state.ui_design_images.child).toBeDefined();
});
it('merges identical sprite and font resources idempotently and rejects identity conflicts', () => {
@@ -584,7 +547,6 @@ describe('useUiEditorState', () => {
value: {
removedResourceCount: 1,
removedTreeCount: 0,
clearedSlaveToCount: 0,
clearedTargetGraphicCount: 0,
clearedFontCount: 1,
},
@@ -597,48 +559,15 @@ describe('useUiEditorState', () => {
});
});
it('keeps setters local and defers workflow errors to prerequisites', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: {
page: {
...image('Page'),
metadata: { name: 'Page', role: 'Page', slave_to: null },
},
child: {
...image('Child'),
metadata: { name: 'Child', role: 'Section', slave_to: 'page' },
},
},
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageRole('page', null);
});
expect(result.current.state.ui_design_images.child?.metadata.slave_to).toBe(
'page',
);
expect(
validateComponentRecognitionPrerequisites(result.current.state),
).toEqual([
expect.objectContaining({
code: 'invalid-slave-to',
resourceId: 'child',
}),
]);
});
it('records, undoes, redoes, and clears redo after a new edit', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
sprite_assets: { panel: sprite('panel') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageName('page', '第一次');
result.current.setSpriteName('panel', '第一次');
});
expect(result.current.historyState).toEqual({
canUndo: true,
@@ -648,8 +577,8 @@ describe('useUiEditorState', () => {
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'Page',
expect(result.current.state.sprite_assets.panel?.metadata.name).toBe(
'panel',
);
expect(result.current.historyState).toEqual({
canUndo: false,
@@ -659,15 +588,15 @@ describe('useUiEditorState', () => {
act(() => {
expect(result.current.redo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
expect(result.current.state.sprite_assets.panel?.metadata.name).toBe(
'第一次',
);
act(() => {
result.current.setImageName('page', '第二次');
result.current.setSpriteName('panel', '第二次');
expect(result.current.redo()).toBe(false);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
expect(result.current.state.sprite_assets.panel?.metadata.name).toBe(
'第二次',
);
});
@@ -675,17 +604,17 @@ describe('useUiEditorState', () => {
it('does not record no-op edits and resets history when replacing loaded state', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
sprite_assets: { panel: sprite('panel') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageName('page', 'Page');
result.current.setSpriteName('panel', 'panel');
});
expect(result.current.historyState.canUndo).toBe(false);
act(() => {
result.current.setImageName('page', '编辑后');
result.current.setSpriteName('panel', '编辑后');
result.current.replaceState(initial, { history: 'reset' });
});
expect(result.current.historyState).toEqual({
@@ -694,7 +623,7 @@ describe('useUiEditorState', () => {
});
act(() => {
result.current.setImageName('page', '清空前');
result.current.setSpriteName('panel', '清空前');
result.current.clearState();
});
expect(result.current.state).toEqual(EMPTY_UI_EDITOR_STATE);
@@ -707,27 +636,27 @@ describe('useUiEditorState', () => {
it('records each replacement as an independent history entry', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
sprite_assets: { panel: sprite('panel') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.replaceState({
...initial,
ui_design_images: { page: image('中间') },
sprite_assets: { panel: sprite('中间') },
});
result.current.replaceState({
...initial,
ui_design_images: { page: image('最终') },
sprite_assets: { panel: sprite('最终') },
});
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
expect(result.current.state.sprite_assets.panel?.metadata.name).toBe(
'最终',
);
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
expect(result.current.state.sprite_assets.panel?.metadata.name).toBe(
'中间',
);
});
@@ -735,7 +664,7 @@ describe('useUiEditorState', () => {
it('records one history entry after skipped replacement batches', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
sprite_assets: { panel: sprite('panel') },
};
const { result } = renderHook(() => useUiEditorState(initial));
@@ -743,20 +672,20 @@ describe('useUiEditorState', () => {
result.current.replaceState(
{
...initial,
ui_design_images: { page: image('第一批') },
sprite_assets: { panel: sprite('第一批') },
},
{ history: 'skip' },
);
result.current.replaceState(
{
...initial,
ui_design_images: { page: image('最终') },
sprite_assets: { panel: sprite('最终') },
},
{ history: 'record' },
);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
expect(result.current.state.sprite_assets.panel?.metadata.name).toBe(
'最终',
);
expect(result.current.historyState).toEqual({
@@ -766,8 +695,8 @@ describe('useUiEditorState', () => {
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'Page',
expect(result.current.state.sprite_assets.panel?.metadata.name).toBe(
'panel',
);
expect(result.current.historyState).toEqual({
canUndo: false,