重构和扩展 UI 编辑器功能:

- 改进页面树同步逻辑,统一设计图像树管理。
- 引入节点组件操作接口,支持新增、删除、移动与更新组件。
- 增加组件和节点验证逻辑,提升数据一致性。
- 优化合并 UI 树相关功能,支持界面树合并状态跟踪和操作。
- 调整 UI 布局细节,增强交互体验。
This commit is contained in:
2026-08-14 20:25:20 +08:00
parent eca6659e86
commit ab0d929d3d
3 changed files with 475 additions and 50 deletions
@@ -1,7 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import type { Component } from './types/Component';
import { validateSpriteBorder } from './spriteBorder';
import type { Component } from './types/Component';
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
@@ -38,6 +38,12 @@ export type NodeMetadataPatch = Partial<
Pick<NodeMetadata, 'name' | 'description'>
>;
export type ComponentIndex = number;
export type NodeTransformOptions = {
keepChildrenUnchanged?: boolean;
};
type Rect = {
min: [number, number];
size: [number, number];
@@ -112,14 +118,10 @@ function createHumanNode(state: State): Node {
};
}
function synchronizePageTrees(state: State): void {
const pageIds = new Set(
Object.entries(state.ui_design_images)
.filter(([, image]) => image.metadata.role === 'Page')
.map(([id]) => id),
);
function synchronizeDesignImageTrees(state: State): void {
const imageIds = new Set(Object.keys(state.ui_design_images));
state.ui_trees = state.ui_trees.filter((tree) =>
pageIds.has(tree.src_ui_design),
imageIds.has(tree.src_ui_design),
);
for (const [id] of Object.entries(state.ui_design_images)) {
if (
@@ -247,6 +249,140 @@ function visitComponents(nodes: Node[], visit: (component: Component) => void) {
}
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isFinitePositive(value: unknown): value is number {
return isFiniteNumber(value) && value > 0;
}
function isEnumValue<T extends string>(
value: unknown,
values: readonly T[],
): value is T {
return typeof value === 'string' && values.includes(value as T);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object'
? (value as Record<string, unknown>)
: null;
}
function isValidFillMethod(method: unknown): boolean {
const record = asRecord(method);
if (!record) return false;
if ('Horizontal' in record) {
return isEnumValue(record.Horizontal, ['Left', 'Right'] as const);
}
if ('Vertical' in record) {
return isEnumValue(record.Vertical, ['Bottom', 'Top'] as const);
}
for (const key of ['Radial90', 'Radial180', 'Radial360'] as const) {
if (key in record) {
const radial = asRecord(record[key]);
if (!radial) return false;
return (
typeof radial.clockwise === 'boolean' &&
isEnumValue(
radial.origin,
key === 'Radial90'
? (['BottomLeft', 'TopLeft', 'TopRight', 'BottomRight'] as const)
: key === 'Radial180'
? (['Bottom', 'Left', 'Top', 'Right'] as const)
: (['Bottom', 'Right', 'Top', 'Left'] as const),
)
);
}
}
return false;
}
function isValidImageType(imageType: unknown): boolean {
const record = asRecord(imageType);
if (!record) return false;
if ('Simple' in record) {
const value = asRecord(record.Simple);
if (!value) return false;
return typeof value.preserve_aspect === 'boolean';
}
if ('Sliced' in record || 'Tiled' in record) {
const value = asRecord('Sliced' in record ? record.Sliced : record.Tiled);
return (
value !== null &&
typeof value.fill_center === 'boolean' &&
isFinitePositive(value.pixels_per_unit_multiplier)
);
}
if ('Filled' in record) {
const value = asRecord(record.Filled);
return (
value !== null &&
typeof value.preserve_aspect === 'boolean' &&
isFiniteNumber(value.amount) &&
value.amount >= 0 &&
value.amount <= 1 &&
isValidFillMethod(value.method)
);
}
return false;
}
function isValidComponent(component: Component): boolean {
if ('Image' in component) {
return (
(component.Image.target_graphic === null ||
typeof component.Image.target_graphic === 'string') &&
isValidImageType(component.Image.image_type)
);
}
if ('Text' in component) {
const text = component.Text;
const colorValid =
Array.isArray(text.color) &&
text.color.length === 4 &&
text.color.every(
(channel) =>
Number.isInteger(channel) && channel >= 0 && channel <= 255,
);
const sizingValid =
('Fixed' in text.font_sizing &&
isFinitePositive(text.font_sizing.Fixed)) ||
('BestFit' in text.font_sizing &&
isFinitePositive(text.font_sizing.BestFit.min) &&
isFinitePositive(text.font_sizing.BestFit.max) &&
text.font_sizing.BestFit.min <= text.font_sizing.BestFit.max);
return (
typeof text.content === 'string' &&
(text.font === null || typeof text.font === 'string') &&
isEnumValue(text.font_style, [
'Normal',
'Bold',
'Italic',
'BoldItalic',
] as const) &&
sizingValid &&
colorValid &&
isEnumValue(text.alignment, [
'UpperLeft',
'UpperCenter',
'UpperRight',
'MiddleLeft',
'MiddleCenter',
'MiddleRight',
'LowerLeft',
'LowerCenter',
'LowerRight',
] as const) &&
isEnumValue(text.horizontal_overflow, ['Wrap', 'Overflow'] as const) &&
isEnumValue(text.vertical_overflow, ['Truncate', 'Overflow'] as const) &&
isFinitePositive(text.line_spacing)
);
}
return false;
}
export function designImageRemovalImpact(
state: State,
id: UIDesignImageId,
@@ -311,7 +447,7 @@ function spriteResourceValidationError(sprite: SpriteAsset) {
export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
const [state, setState] = useState<State>(() => {
const next = cloneState(initialState);
synchronizePageTrees(next);
synchronizeDesignImageTrees(next);
return next;
});
const [isLocked, setIsLocked] = useState(false);
@@ -396,7 +532,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.role = role;
synchronizePageTrees(next);
synchronizeDesignImageTrees(next);
commit(next);
return { ok: true, value: undefined };
},
@@ -456,7 +592,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
for (const entry of entries) {
next.ui_design_images[entry.id] = structuredClone(entry.image);
}
synchronizePageTrees(next);
synchronizeDesignImageTrees(next);
commit(next);
return { ok: true, value: undefined };
},
@@ -634,6 +770,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
treeId: UIDesignImageId,
nodeId: NodeId,
transform: Node['transform'],
options: NodeTransformOptions = {},
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
@@ -661,8 +798,223 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
const nextTree = next.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
)!;
findNodeLocation(nextTree.root, nodeId)!.node.transform =
structuredClone(transform);
const nextNode = findNodeLocation(nextTree.root, nodeId)!.node;
nextNode.transform = structuredClone(transform);
if (options.keepChildrenUnchanged && location.parent) {
const image = current.ui_design_images[treeId];
if (
!image ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
return { ok: false, reason: 'invalid' };
}
const pageRect: Rect = {
min: [0, 0],
size: [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
],
};
const oldParentRect = findNodePageRect(
tree.root,
location.parent.id,
pageRect,
);
const oldNodeRect = findNodePageRect(tree.root, nodeId, pageRect);
if (
!oldParentRect ||
!isValidRect(oldParentRect) ||
!oldNodeRect ||
!isValidRect(oldNodeRect)
) {
return { ok: false, reason: 'invalid' };
}
const newNodeRect = resolveNodeRect(transform, oldParentRect);
if (!isValidRect(newNodeRect)) {
return { ok: false, reason: 'invalid' };
}
const childTransforms = location.node.children.map((child) => {
const childRect = resolveNodeRect(child.transform, oldNodeRect);
return {
id: child.id,
transform: setOffsetsForPageRect(
child.transform,
childRect,
newNodeRect,
),
};
});
if (childTransforms.some((child) => child.transform === null)) {
return { ok: false, reason: 'invalid' };
}
for (const child of childTransforms) {
const nextChild = nextNode.children.find(
(candidate) => candidate.id === child.id,
);
if (!nextChild || !child.transform) {
return { ok: false, reason: 'invalid' };
}
nextChild.transform = child.transform;
}
}
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const setNodeComponents = useCallback(
(
treeId: UIDesignImageId,
nodeId: NodeId,
components: Component[],
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
if (!components.every(isValidComponent)) {
return { ok: false, reason: 'invalid' };
}
const current = stateRef.current;
const tree = current.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
const location = findNodeLocation(tree.root, nodeId);
if (!location) return { ok: false, reason: 'missing' };
if (location.node.id === tree.root.id)
return { ok: false, reason: 'invalid' };
const next = cloneState(current);
const nextTree = next.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
)!;
findNodeLocation(nextTree.root, nodeId)!.node.components =
structuredClone(components);
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const insertComponent = useCallback(
(
treeId: UIDesignImageId,
nodeId: NodeId,
index: ComponentIndex,
component: Component,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
if (
!isValidComponent(component) ||
!Number.isInteger(index) ||
index < 0
) {
return { ok: false, reason: 'invalid' };
}
const current = stateRef.current;
const tree = current.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
const location = findNodeLocation(tree.root, nodeId);
if (!location) return { ok: false, reason: 'missing' };
if (
location.node.id === tree.root.id ||
index > location.node.components.length
) {
return { ok: false, reason: 'invalid' };
}
const next = cloneState(current);
const nextNode = findNodeLocation(
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
.root,
nodeId,
)!.node;
nextNode.components.splice(index, 0, structuredClone(component));
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const deleteComponent = useCallback(
(
treeId: UIDesignImageId,
nodeId: NodeId,
index: ComponentIndex,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
if (!Number.isInteger(index) || index < 0)
return { ok: false, reason: 'invalid' };
const current = stateRef.current;
const tree = current.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
const location = findNodeLocation(tree.root, nodeId);
if (!location) return { ok: false, reason: 'missing' };
if (
location.node.id === tree.root.id ||
index >= location.node.components.length
) {
return { ok: false, reason: 'invalid' };
}
const next = cloneState(current);
const nextNode = findNodeLocation(
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
.root,
nodeId,
)!.node;
nextNode.components.splice(index, 1);
commit(next);
return { ok: true, value: undefined };
},
[commit, guard],
);
const moveComponent = useCallback(
(
treeId: UIDesignImageId,
nodeId: NodeId,
fromIndex: ComponentIndex,
toIndex: ComponentIndex,
): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
if (
!Number.isInteger(fromIndex) ||
!Number.isInteger(toIndex) ||
fromIndex < 0 ||
toIndex < 0
) {
return { ok: false, reason: 'invalid' };
}
const current = stateRef.current;
const tree = current.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
const location = findNodeLocation(tree.root, nodeId);
if (!location) return { ok: false, reason: 'missing' };
if (
location.node.id === tree.root.id ||
fromIndex >= location.node.components.length ||
toIndex >= location.node.components.length
) {
return { ok: false, reason: 'invalid' };
}
if (fromIndex === toIndex) return { ok: true, value: undefined };
const next = cloneState(current);
const nextNode = findNodeLocation(
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
.root,
nodeId,
)!.node;
const [component] = nextNode.components.splice(fromIndex, 1);
if (!component) return { ok: false, reason: 'invalid' };
nextNode.components.splice(toIndex, 0, component);
commit(next);
return { ok: true, value: undefined };
},
@@ -844,7 +1196,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
const replaceState = useCallback(
(nextState: State) => {
const next = cloneState(nextState);
synchronizePageTrees(next);
synchronizeDesignImageTrees(next);
commit(next);
},
[commit],
@@ -868,6 +1220,10 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
insertNodeAfter,
deleteNode,
setNodeTransform,
setNodeComponents,
insertComponent,
deleteComponent,
moveComponent,
setNodeMetadata,
moveNode,
removeDesignImage,
@@ -1,7 +1,7 @@
import { EditorDialogs } from './components/EditorDialogs';
import { InputSidebar } from './components/InputSidebar';
import { InspectorSidebar } from './components/Inspector/InspectorSidebar';
import { PreviewWorkspace } from './components/PreviewWorkspace';
import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
import { ToolNavigation } from './components/ToolNavigation';
import { useUiEditorPage } from './useUiEditorPage';
@@ -18,7 +18,7 @@ export default function UiEditorPage({
activeTool={controller.activeTool}
onChange={controller.selectTool}
/>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(360px,1fr)_320px] overflow-hidden">
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(360px,1fr)_400px] overflow-hidden">
<InputSidebar controller={controller} />
<PreviewWorkspace controller={controller} />
<InspectorSidebar controller={controller} />
@@ -35,6 +35,24 @@ export default function UiEditorPage({
{controller.recognitionStatus}
</p>
) : null}
{controller.mergeStatus ? (
<p className="rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs shadow-lg">
{controller.mergeStatus}
</p>
) : null}
<button
type="button"
className="rounded-full bg-violet-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-violet-700 disabled:cursor-wait disabled:opacity-60"
onClick={() => void controller.mergeUi()}
disabled={
controller.isMerging ||
controller.editor.isLocked ||
controller.editor.state.ui_trees.length === 0
}
title="调试:合并 UI 树"
>
{controller.isMerging ? '界面树合并中…' : '合并 UI 树'}
</button>
<button
type="button"
className="rounded-full bg-blue-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-60"
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useMemo, useState } from 'react';
import type { ImportedAsset } from '../../components/AssetImporter';
import {
@@ -12,19 +12,23 @@ import {
validateComponentRecognitionPrerequisites,
validateLayoutReviewPrerequisites,
} from '../../features/ui-editor/prerequisites';
import { applyMergeResult } from '../../features/ui-editor/merge';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import type { Component } from '../../features/ui-editor/types/Component';
import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
import type { Node as UiNode } from '../../features/ui-editor/types/Node';
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO';
import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId';
import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder';
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 { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions';
import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import {
EMPTY_UI_EDITOR_STATE,
type NodeMetadataPatch,
type NodeTransformOptions,
useUiEditorState,
} from '../../features/ui-editor/useUiEditorState';
import {
@@ -45,6 +49,7 @@ export function useUiEditorPage(projectPath: string) {
const [selectedSpriteId, setSelectedSpriteId] =
useState<SpriteAssetId | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const [keepChildrenUnchanged, setKeepChildrenUnchanged] = useState(false);
const [importKind, setImportKind] = useState<UiEditorImportKind | null>(null);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [status, setStatus] = useState<string | null>(null);
@@ -52,10 +57,6 @@ export function useUiEditorPage(projectPath: string) {
null,
);
const [clearOpen, setClearOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState<{
id: UIDesignImageId;
role: UIDesignImageRole | null;
} | null>(null);
const [pendingRemoval, setPendingRemoval] =
useState<PendingResourceRemoval | null>(null);
const [isSuggesting, setIsSuggesting] = useState(false);
@@ -64,6 +65,8 @@ export function useUiEditorPage(projectPath: string) {
const [recognitionStatus, setRecognitionStatus] = useState<string | null>(
null,
);
const [isMerging, setIsMerging] = useState(false);
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -258,7 +261,6 @@ export function useUiEditorPage(projectPath: string) {
}
function selectDesignImage(id: UIDesignImageId) {
setPendingRoleChange(null);
setActiveImageId(id);
setSelectedSpriteId(null);
setSelectedNodeId(null);
@@ -282,8 +284,13 @@ export function useUiEditorPage(projectPath: string) {
treeId: UIDesignImageId,
nodeId: NodeId,
transform: UiNode['transform'],
options: NodeTransformOptions = {},
) {
const result = editor.setNodeTransform(treeId, nodeId, transform);
const result = editor.setNodeTransform(treeId, nodeId, transform, {
...options,
keepChildrenUnchanged:
options.keepChildrenUnchanged ?? keepChildrenUnchanged,
});
if (!result.ok) setStatus('节点 Transform 更新失败。');
return result;
}
@@ -295,6 +302,48 @@ export function useUiEditorPage(projectPath: string) {
return result;
}
function setNodeComponents(components: Component[]) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.setNodeComponents(
activeImageId,
selectedNodeId,
components,
);
if (!result.ok) setStatus('组件更新失败。');
return result;
}
function insertNodeComponent(index: number, component: Component) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.insertComponent(
activeImageId,
selectedNodeId,
index,
component,
);
if (!result.ok) setStatus('组件新增失败。');
return result;
}
function deleteNodeComponent(index: number) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.deleteComponent(activeImageId, selectedNodeId, index);
if (!result.ok) setStatus('组件删除失败。');
return result;
}
function moveNodeComponent(fromIndex: number, toIndex: number) {
if (!activeImageId || !selectedNodeId) return;
const result = editor.moveComponent(
activeImageId,
selectedNodeId,
fromIndex,
toIndex,
);
if (!result.ok) setStatus('组件顺序更新失败。');
return result;
}
function moveNode(
treeId: UIDesignImageId,
nodeId: NodeId,
@@ -365,32 +414,10 @@ export function useUiEditorPage(projectPath: string) {
function setImageRole(role: UIDesignImageRole | null) {
if (!activeImageId) return;
const tree = editor.state.ui_trees.find(
(candidate) => candidate.src_ui_design === activeImageId,
);
if (activeImage?.metadata.role === 'Page' && role !== 'Page' && tree) {
setPendingRoleChange({ id: activeImageId, role });
return;
}
editor.setImageRole(activeImageId, role);
setIssues(null);
}
function confirmRoleChange() {
if (!pendingRoleChange) return;
const result = editor.setImageRole(
pendingRoleChange.id,
pendingRoleChange.role,
);
if (!result.ok) {
setStatus('界面角色更新失败。');
return;
}
setPendingRoleChange(null);
setSelectedNodeId(null);
setIssues(null);
}
function setImageSlaveTo(slaveTo: UIDesignImageId | null) {
if (!activeImageId) return;
editor.setImageSlaveTo(activeImageId, slaveTo);
@@ -459,6 +486,24 @@ export function useUiEditorPage(projectPath: string) {
}
}
async function mergeUi() {
if (isMerging) return;
setMergeStatus(null);
setIsMerging(true);
try {
await editor.runWithStateLocked(async (snapshot) => {
const result = await invoke<MergeDTO>('merge_ui', { state: snapshot });
editor.replaceState(applyMergeResult(snapshot, result));
setSelectedNodeId(null);
setMergeStatus('已生成合并后的单棵界面树。');
});
} catch (cause) {
setMergeStatus(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsMerging(false);
}
}
return {
projectPath,
editor,
@@ -473,7 +518,6 @@ export function useUiEditorPage(projectPath: string) {
status,
issues,
clearOpen,
pendingRoleChange,
pendingRemoval,
images,
sprites,
@@ -484,6 +528,8 @@ export function useUiEditorPage(projectPath: string) {
treeForActiveImage,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeParentSize: selectedNodeContext?.parentSize,
keepChildrenUnchanged,
setKeepChildrenUnchanged,
selectTool,
selectDesignImage,
selectSprite,
@@ -492,6 +538,10 @@ export function useUiEditorPage(projectPath: string) {
setNodeTransform,
updateNodeTransform,
setNodeMetadata,
setNodeComponents,
insertNodeComponent,
deleteNodeComponent,
moveNodeComponent,
moveNode,
insertNode,
insertNodeAfter,
@@ -510,8 +560,6 @@ export function useUiEditorPage(projectPath: string) {
setImageName,
setImageDescription,
setImageRole,
confirmRoleChange,
cancelRoleChange: () => setPendingRoleChange(null),
setImageSlaveTo,
setSpriteName,
setSpriteAssetType,
@@ -522,6 +570,9 @@ export function useUiEditorPage(projectPath: string) {
isRecognizing,
recognitionStatus,
recognizeUi,
isMerging,
mergeStatus,
mergeUi,
};
}