深化UI编辑器前端状态与几何模块
新增 React-free 语义状态迁移 seam 收敛 State 级节点几何与保存前不变量校验 统一四类异步编辑器操作生命周期 补充状态迁移和不变量测试
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { Node } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { State } from './types/State';
|
||||
import type { Transform } from './types/Transform';
|
||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||
|
||||
export type ResizeAxis = 'horizontal' | 'vertical';
|
||||
|
||||
@@ -17,6 +19,32 @@ export type NodePageContext = {
|
||||
parentRect: PageRect;
|
||||
};
|
||||
|
||||
/** State-level geometry seam shared by preview, inspector and transitions. */
|
||||
export function findStateNodePageContext(
|
||||
state: State,
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
): NodePageContext | null {
|
||||
const tree = state.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
const image = state.ui_design_images[treeId];
|
||||
if (
|
||||
!tree ||
|
||||
!image ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const size: [number, number] = [
|
||||
image.pixel_size[0] / image.pixels_per_unit,
|
||||
image.pixel_size[1] / image.pixels_per_unit,
|
||||
];
|
||||
if (!size.every((value) => Number.isFinite(value) && value > 0)) return null;
|
||||
return findNodePageContext(tree.root, nodeId, pageRectFromSize(size));
|
||||
}
|
||||
|
||||
const MIN_NODE_SIZE = 1;
|
||||
|
||||
export function pageRectFromSize(size: [number, number]): PageRect {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Node } from './types/Node';
|
||||
import type { State } from './types/State';
|
||||
|
||||
export type UiDesignInvariantIssue = {
|
||||
code:
|
||||
| 'missing-image'
|
||||
| 'duplicate-node'
|
||||
| 'invalid-image'
|
||||
| 'missing-tree-image';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/** Frontend display/save projection of persistable State invariants.
|
||||
* Rust remains authoritative; this seam only prevents obviously invalid saves
|
||||
* and gives the view a stable, user-visible failure message.
|
||||
*/
|
||||
export function validateUiDesignState(state: State): UiDesignInvariantIssue[] {
|
||||
const issues: UiDesignInvariantIssue[] = [];
|
||||
const nodeIds = new Set<string>();
|
||||
for (const [id, image] of Object.entries(state.ui_design_images)) {
|
||||
if (
|
||||
image.path.trim() === '' ||
|
||||
!image.pixel_size.every((value) => Number.isFinite(value) && value > 0) ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
issues.push({
|
||||
code: 'invalid-image',
|
||||
message: `界面图 ${id} 的尺寸或路径无效`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const visit = (node: Node) => {
|
||||
if (nodeIds.has(node.id)) {
|
||||
issues.push({
|
||||
code: 'duplicate-node',
|
||||
message: `节点 ID 重复:${node.id}`,
|
||||
});
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const tree of state.ui_trees) {
|
||||
if (!state.ui_design_images[tree.src_ui_design]) {
|
||||
issues.push({
|
||||
code: 'missing-tree-image',
|
||||
message: `UI 树引用了缺失界面图:${tree.src_ui_design}`,
|
||||
});
|
||||
}
|
||||
visit(tree.root);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function firstUiDesignInvariantMessage(state: State): string | null {
|
||||
return validateUiDesignState(state)[0]?.message ?? null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Component } from './types/Component';
|
||||
import type { Node } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { State } from './types/State';
|
||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||
|
||||
export type StateTransitionFailure =
|
||||
| 'missing'
|
||||
| 'invalid'
|
||||
| `invalid:${string}`;
|
||||
|
||||
export type StateTransitionResult =
|
||||
| { ok: true; state: State }
|
||||
| { ok: false; reason: StateTransitionFailure };
|
||||
|
||||
export type UiEditorCommand =
|
||||
| {
|
||||
type: 'set-tree-offset';
|
||||
treeId: UIDesignImageId;
|
||||
min: [number, number];
|
||||
}
|
||||
| {
|
||||
type: 'set-node-metadata';
|
||||
treeId: UIDesignImageId;
|
||||
nodeId: NodeId;
|
||||
patch: Partial<
|
||||
Pick<
|
||||
NodeMetadata,
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'layout_status'
|
||||
| 'component_status'
|
||||
| 'allow_llm_edit_layout'
|
||||
| 'allow_llm_edit_component'
|
||||
>
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: 'set-node-component';
|
||||
treeId: UIDesignImageId;
|
||||
nodeId: NodeId;
|
||||
component: Component | null;
|
||||
};
|
||||
|
||||
function cloneState(state: State): State {
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
function findNode(node: Node, id: NodeId): Node | null {
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children) {
|
||||
const found = findNode(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function imageLogicalSize(
|
||||
state: State,
|
||||
treeId: UIDesignImageId,
|
||||
): [number, number] | null {
|
||||
const image = state.ui_design_images[treeId];
|
||||
if (
|
||||
!image ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const size: [number, number] = [
|
||||
image.pixel_size[0] / image.pixels_per_unit,
|
||||
image.pixel_size[1] / image.pixels_per_unit,
|
||||
];
|
||||
return size.every((value) => Number.isFinite(value) && value > 0)
|
||||
? size
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* React-free semantic State transition seam. The hook is an adapter that adds
|
||||
* locking/history; callers provide a command and receive a complete next State
|
||||
* or a typed failure, never a partially-mutated tree.
|
||||
*/
|
||||
export function applyUiEditorCommand(
|
||||
current: State,
|
||||
command: UiEditorCommand,
|
||||
): StateTransitionResult {
|
||||
const next = cloneState(current);
|
||||
const tree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === command.treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
|
||||
if (command.type === 'set-tree-offset') {
|
||||
if (!command.min.every(Number.isFinite))
|
||||
return { ok: false, reason: 'invalid' };
|
||||
const size = imageLogicalSize(next, command.treeId);
|
||||
if (!size) return { ok: false, reason: 'invalid' };
|
||||
tree.root.offset = {
|
||||
min: [...command.min],
|
||||
max: [command.min[0] + size[0], command.min[1] + size[1]],
|
||||
};
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
const node = findNode(tree.root, command.nodeId);
|
||||
if (!node) return { ok: false, reason: 'missing' };
|
||||
|
||||
if (command.type === 'set-node-component') {
|
||||
node.component = structuredClone(command.component);
|
||||
node.metadata.component_status = 'NoProblem';
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
if (command.patch.component_status !== undefined && node.component === null) {
|
||||
const status = command.patch.component_status;
|
||||
if (status !== 'NoProblem') return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
node.metadata = { ...node.metadata, ...structuredClone(command.patch) };
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveReparentTransform,
|
||||
} from './nodeTransformGeometry';
|
||||
import { validateSpriteBorder } from './spriteBorder';
|
||||
import { applyUiEditorCommand } from './stateTransition';
|
||||
import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode';
|
||||
import type { Component } from './types/Component';
|
||||
import type { FontAsset } from './types/FontAsset';
|
||||
@@ -106,12 +107,6 @@ function visitNodes(node: Node, visit: (node: Node) => void): void {
|
||||
for (const child of node.children) visitNodes(child, visit);
|
||||
}
|
||||
|
||||
function isProblematicComponentStatus(
|
||||
status: NodeMetadata['component_status'],
|
||||
): boolean {
|
||||
return typeof status !== 'string';
|
||||
}
|
||||
|
||||
function existingNodeIds(state: State): Set<NodeId> {
|
||||
const ids = new Set<NodeId>();
|
||||
for (const tree of state.ui_trees) {
|
||||
@@ -218,10 +213,13 @@ function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset {
|
||||
const maxX = Math.max(
|
||||
...existing.map(
|
||||
(tree) =>
|
||||
tree.root.offset.min[0] + treeSize(state, tree.src_ui_design)[0],
|
||||
(tree.root.offset?.min?.[0] ?? 0) +
|
||||
treeSize(state, tree.src_ui_design)[0],
|
||||
),
|
||||
);
|
||||
const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1]));
|
||||
const minY = Math.min(
|
||||
...existing.map((tree) => tree.root.offset?.min?.[1] ?? 0),
|
||||
);
|
||||
return {
|
||||
min: [maxX + UI_TREE_PADDING, minY],
|
||||
max: [maxX + UI_TREE_PADDING + width, minY + height],
|
||||
@@ -1006,21 +1004,13 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
if (!min.every(Number.isFinite)) 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 size = treeSize(current, treeId);
|
||||
const next = cloneState(current);
|
||||
const nextTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
)!;
|
||||
nextTree.root.offset = {
|
||||
min: [...min],
|
||||
max: [min[0] + size[0], min[1] + size[1]],
|
||||
};
|
||||
commit(next);
|
||||
const result = applyUiEditorCommand(stateRef.current, {
|
||||
type: 'set-tree-offset',
|
||||
treeId,
|
||||
min,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
commit(result.state);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
@@ -1209,21 +1199,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
if (component && !isValidComponent(component)) {
|
||||
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' };
|
||||
const next = cloneState(current);
|
||||
const nextTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
)!;
|
||||
const nextNode = findNodeLocation(nextTree.root, nodeId)!.node;
|
||||
nextNode.component = structuredClone(component);
|
||||
nextNode.metadata.component_status = 'NoProblem';
|
||||
commit(next);
|
||||
const result = applyUiEditorCommand(stateRef.current, {
|
||||
type: 'set-node-component',
|
||||
treeId,
|
||||
nodeId,
|
||||
component,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
commit(result.state);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
@@ -1237,38 +1220,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
if (!findNodeLocation(tree.root, nodeId))
|
||||
return { ok: false, reason: 'missing' };
|
||||
const next = cloneState(current);
|
||||
const node = findNodeLocation(
|
||||
next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)!
|
||||
.root,
|
||||
const result = applyUiEditorCommand(stateRef.current, {
|
||||
type: 'set-node-metadata',
|
||||
treeId,
|
||||
nodeId,
|
||||
)!.node;
|
||||
if (
|
||||
patch.component_status !== undefined &&
|
||||
node.component === null &&
|
||||
isProblematicComponentStatus(patch.component_status)
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
if (patch.name !== undefined) node.metadata.name = patch.name;
|
||||
if (patch.description !== undefined)
|
||||
node.metadata.description = patch.description;
|
||||
if (patch.layout_status !== undefined)
|
||||
node.metadata.layout_status = patch.layout_status;
|
||||
if (patch.component_status !== undefined)
|
||||
node.metadata.component_status = patch.component_status;
|
||||
if (patch.allow_llm_edit_layout !== undefined)
|
||||
node.metadata.allow_llm_edit_layout = patch.allow_llm_edit_layout;
|
||||
if (patch.allow_llm_edit_component !== undefined)
|
||||
node.metadata.allow_llm_edit_component = patch.allow_llm_edit_component;
|
||||
commit(next);
|
||||
patch,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
commit(result.state);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export type UiEditorOperationLifecycle = {
|
||||
running: boolean;
|
||||
status: string | null;
|
||||
setRunning: (running: boolean) => void;
|
||||
setStatus: (status: string | null) => void;
|
||||
begin: () => void;
|
||||
finish: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
/** Shared async-operation adapter used by suggestion/recognition/merge/separation. */
|
||||
export function useUiEditorOperation(): UiEditorOperationLifecycle {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const begin = useCallback(() => {
|
||||
setStatus(null);
|
||||
setRunning(true);
|
||||
}, []);
|
||||
const finish = useCallback(() => setRunning(false), []);
|
||||
const reset = useCallback(() => {
|
||||
setRunning(false);
|
||||
setStatus(null);
|
||||
}, []);
|
||||
return { running, status, setRunning, setStatus, begin, finish, reset };
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
prepareSpriteAssetBatch,
|
||||
} from '../../features/ui-editor/importAdapter';
|
||||
import { applyMergeResult } from '../../features/ui-editor/merge';
|
||||
import {
|
||||
findStateNodePageContext,
|
||||
pageRectSize,
|
||||
} from '../../features/ui-editor/nodeTransformGeometry';
|
||||
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
|
||||
import {
|
||||
applySeparationProblematicStatuses,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
getStageStatusOverview,
|
||||
type StageStatusField,
|
||||
} from '../../features/ui-editor/stageStatusOverview';
|
||||
import { firstUiDesignInvariantMessage } from '../../features/ui-editor/stateInvariants';
|
||||
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
|
||||
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
|
||||
import type { Component } from '../../features/ui-editor/types/Component';
|
||||
@@ -75,6 +80,7 @@ import {
|
||||
uiEditorOperationError,
|
||||
type UiEditorStepId,
|
||||
} from './model';
|
||||
import { useUiEditorOperation } from './operationLifecycle';
|
||||
import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
|
||||
|
||||
const SEPARATION_IMPORT_BATCH_SIZE = 100;
|
||||
@@ -140,31 +146,6 @@ function isUiNodeEffectivelyVisible(
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNodeContext(
|
||||
node: UiNode,
|
||||
nodeId: NodeId,
|
||||
parentSize: { width: number; height: number },
|
||||
): { node: UiNode; parentSize: { width: number; height: number } } | null {
|
||||
if (node.id === nodeId) return { node, parentSize };
|
||||
const width =
|
||||
parentSize.width *
|
||||
(node.layout.transform.anchor_max[0] -
|
||||
node.layout.transform.anchor_min[0]) +
|
||||
node.layout.transform.offset_max[0] -
|
||||
node.layout.transform.offset_min[0];
|
||||
const height =
|
||||
parentSize.height *
|
||||
(node.layout.transform.anchor_max[1] -
|
||||
node.layout.transform.anchor_min[1]) +
|
||||
node.layout.transform.offset_max[1] -
|
||||
node.layout.transform.offset_min[1];
|
||||
for (const child of node.children) {
|
||||
const found = findNodeContext(child, nodeId, { width, height });
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSlaveToDescendant(
|
||||
images: State['ui_design_images'],
|
||||
candidateId: UIDesignImageId,
|
||||
@@ -266,16 +247,30 @@ export function useUiEditorSession(
|
||||
const [clearOpen, setClearOpen] = useState(false);
|
||||
const [pendingRemoval, setPendingRemoval] =
|
||||
useState<PendingResourceRemoval | null>(null);
|
||||
const [isSuggesting, setIsSuggesting] = useState(false);
|
||||
const [suggestionStatus, setSuggestionStatus] = useState<string | null>(null);
|
||||
const [isRecognizing, setIsRecognizing] = useState(false);
|
||||
const [recognitionStatus, setRecognitionStatus] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
|
||||
const [isSeparating, setIsSeparating] = useState(false);
|
||||
const [separationStatus, setSeparationStatus] = useState<string | 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;
|
||||
const finishRecognition = recognitionOperation.finish;
|
||||
const setRecognitionStatus = recognitionOperation.setStatus;
|
||||
const isMerging = mergeOperation.running;
|
||||
const mergeStatus = mergeOperation.status;
|
||||
const beginMerge = mergeOperation.begin;
|
||||
const finishMerge = mergeOperation.finish;
|
||||
const setMergeStatus = mergeOperation.setStatus;
|
||||
const isSeparating = separationOperation.running;
|
||||
const separationStatus = separationOperation.status;
|
||||
const beginSeparation = separationOperation.begin;
|
||||
const finishSeparation = separationOperation.finish;
|
||||
const setSeparationStatus = separationOperation.setStatus;
|
||||
const [separationRecovery, setSeparationRecovery] =
|
||||
useState<SeparationRecoveryDTO | null>(null);
|
||||
const [hasSuggested, setHasSuggested] = useState(false);
|
||||
@@ -586,17 +581,15 @@ export function useUiEditorSession(
|
||||
);
|
||||
const selectedNodeContext = useMemo(() => {
|
||||
if (!selectedNodeId || !treeForSelectedNode) return null;
|
||||
const image = images[treeForSelectedNode.src_ui_design];
|
||||
if (!image) return null;
|
||||
const ppu = image.pixels_per_unit;
|
||||
const width = image.pixel_size[0] / ppu;
|
||||
const height = image.pixel_size[1] / ppu;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
||||
return findNodeContext(treeForSelectedNode.root, selectedNodeId, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [images, selectedNodeId, treeForSelectedNode]);
|
||||
const context = findStateNodePageContext(
|
||||
editor.state,
|
||||
treeForSelectedNode.src_ui_design,
|
||||
selectedNodeId,
|
||||
);
|
||||
if (!context) return null;
|
||||
const [width, height] = pageRectSize(context.parentRect);
|
||||
return { node: context.node, parentSize: { width, height } };
|
||||
}, [editor.state, selectedNodeId, treeForSelectedNode]);
|
||||
|
||||
async function importAssets(imported: ImportedAsset[]) {
|
||||
if (!importKind || !projectPath) return;
|
||||
@@ -997,7 +990,7 @@ export function useUiEditorSession(
|
||||
if (isSuggesting || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setSuggestionStatus(null);
|
||||
setIsSuggesting(true);
|
||||
beginSuggestion();
|
||||
try {
|
||||
await editor.runWithStateLocked(async (snapshot) => {
|
||||
const suggestions = await invoke<UIDesignSuggestionTreeNode[]>(
|
||||
@@ -1021,7 +1014,7 @@ export function useUiEditorSession(
|
||||
setSuggestionStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsSuggesting(false);
|
||||
finishSuggestion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,7 +1022,7 @@ export function useUiEditorSession(
|
||||
if (isRecognizing || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setRecognitionStatus(null);
|
||||
setIsRecognizing(true);
|
||||
beginRecognition();
|
||||
try {
|
||||
await editor.runWithStateLocked(async (snapshot) => {
|
||||
const result = await invoke<RecognitionDTO>('recognize_ui', {
|
||||
@@ -1066,7 +1059,7 @@ export function useUiEditorSession(
|
||||
setRecognitionStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsRecognizing(false);
|
||||
finishRecognition();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,7 +1067,7 @@ export function useUiEditorSession(
|
||||
// TODO: This experimental operation is intentionally outside the formal workflow.
|
||||
if (isMerging || isWorkflowBusy) return;
|
||||
setMergeStatus(null);
|
||||
setIsMerging(true);
|
||||
beginMerge();
|
||||
try {
|
||||
await editor.runWithStateLocked(async (snapshot) => {
|
||||
const result = await invoke<MergeDTO>('merge_ui', { state: snapshot });
|
||||
@@ -1093,13 +1086,13 @@ export function useUiEditorSession(
|
||||
} catch (cause) {
|
||||
setMergeStatus(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setIsMerging(false);
|
||||
finishMerge();
|
||||
}
|
||||
}
|
||||
|
||||
async function runSeparationWorkflow() {
|
||||
if (!resourceId || isWorkflowBusy) return;
|
||||
setIsSeparating(true);
|
||||
beginSeparation();
|
||||
setSeparationStatus(null);
|
||||
setCompletionNotice(null);
|
||||
let preparedSprites: Awaited<ReturnType<typeof prepareSpriteAssetBatch>> =
|
||||
@@ -1303,7 +1296,7 @@ export function useUiEditorSession(
|
||||
setSeparationStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsSeparating(false);
|
||||
finishSeparation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,6 +1431,14 @@ export function useUiEditorSession(
|
||||
setIsSaving(true);
|
||||
try {
|
||||
return await editor.runWithStateLocked(async (snapshot) => {
|
||||
const invariantMessage = firstUiDesignInvariantMessage(snapshot);
|
||||
if (invariantMessage) {
|
||||
setSaveError(invariantMessage);
|
||||
return {
|
||||
status: 'failed' as const,
|
||||
message: invariantMessage,
|
||||
};
|
||||
}
|
||||
const snapshotSignature = JSON.stringify(snapshot);
|
||||
const result = await stateStore.save(
|
||||
resourceId,
|
||||
@@ -1514,6 +1515,15 @@ export function useUiEditorSession(
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
return await editor.runWithStateLocked(async (snapshot) => {
|
||||
const invariantMessage = firstUiDesignInvariantMessage(snapshot);
|
||||
if (invariantMessage) {
|
||||
setSaveError(invariantMessage);
|
||||
return {
|
||||
status: 'failed' as const,
|
||||
phase: 'save' as const,
|
||||
message: invariantMessage,
|
||||
};
|
||||
}
|
||||
const snapshotSignature = JSON.stringify(snapshot);
|
||||
const saved = await stateStore.save(
|
||||
resourceId,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validateComponentRecognitionPrerequisites } from '../src/features/ui-editor/requisites';
|
||||
import { validateUiDesignState } from '../src/features/ui-editor/stateInvariants';
|
||||
import { applyUiEditorCommand } from '../src/features/ui-editor/stateTransition';
|
||||
import type { FontAsset } from '../src/features/ui-editor/types/FontAsset';
|
||||
import type { Node } from '../src/features/ui-editor/types/Node';
|
||||
import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset';
|
||||
@@ -117,6 +119,34 @@ function pageRoot(id: string, children: Node[] = []): Node {
|
||||
}
|
||||
|
||||
describe('useUiEditorState', () => {
|
||||
it('applies semantic transitions without exposing tree traversal', () => {
|
||||
const state: State = {
|
||||
...structuredClone(EMPTY_UI_EDITOR_STATE),
|
||||
ui_design_images: { page: image('Page') },
|
||||
ui_trees: [{ src_ui_design: 'page', root: pageRoot('root') }],
|
||||
};
|
||||
const result = applyUiEditorCommand(state, {
|
||||
type: 'set-tree-offset',
|
||||
treeId: 'page',
|
||||
min: [12, 24],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.state.ui_trees[0]?.root.offset).toEqual({
|
||||
min: [12, 24],
|
||||
max: [112, 104],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('reports persistable state invariant failures before save', () => {
|
||||
const state = structuredClone(EMPTY_UI_EDITOR_STATE);
|
||||
state.ui_trees = [{ src_ui_design: 'missing', root: pageRoot('root') }];
|
||||
expect(validateUiDesignState(state)).toEqual([
|
||||
expect.objectContaining({ code: 'missing-tree-image' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves a subtree between UI trees without changing its transform', () => {
|
||||
const moved = nodeWithSprite('moved');
|
||||
const initial: State = {
|
||||
|
||||
Reference in New Issue
Block a user