重构和扩展 UI 编辑器功能:
- 改进页面树同步逻辑,统一设计图像树管理。 - 引入节点组件操作接口,支持新增、删除、移动与更新组件。 - 增加组件和节点验证逻辑,提升数据一致性。 - 优化合并 UI 树相关功能,支持界面树合并状态跟踪和操作。 - 调整 UI 布局细节,增强交互体验。
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user