= ({
dragIds,
parentId,
@@ -143,6 +158,7 @@ export function UiTreePanel({
{root ? (
+ ref={treeApiRef}
data={[root]}
width="100%"
height={256}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx
index 34b49300d..4c244010b 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx
@@ -23,6 +23,7 @@ import {
} from 'react';
import type { UiEditorPageController } from '../../useUiEditorPage';
+import { findNodePageContext } from './nodeTransformGeometry';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
@@ -34,6 +35,7 @@ export function PreviewWorkspace({
const { editor, activeImage, activeImageId, previewUrls } = controller;
const viewportElementRef = useRef(null);
const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 });
+ const handledFocusRequestIdRef = useRef(null);
const panRef = useRef | null>(null);
const [viewport, setViewportState] = useState(
viewportRef.current,
@@ -120,11 +122,53 @@ export function PreviewWorkspace({
useEffect(() => {
fitToCanvas();
- controller.clearNodeSelection();
// This effect intentionally follows the active image, not every controller render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeImageId, fitToCanvas]);
+ useEffect(() => {
+ const request = controller.focusRequest;
+ if (
+ !request ||
+ request.requestId === handledFocusRequestIdRef.current ||
+ request.treeId !== activeImageId ||
+ !tree ||
+ !logicalSize
+ ) {
+ return;
+ }
+ const context = findNodePageContext(tree.root, request.nodeId, {
+ min: [0, 0],
+ max: [logicalSize.width, logicalSize.height],
+ });
+ if (!context) return;
+ const element = viewportElementRef.current;
+ const size = {
+ width: element?.clientWidth || canvasSize.width,
+ height: element?.clientHeight || canvasSize.height,
+ };
+ handledFocusRequestIdRef.current = request.requestId;
+ setViewport(
+ fitViewportToBounds({
+ bounds: {
+ x: context.rect.min[0],
+ y: context.rect.min[1],
+ width: context.rect.max[0] - context.rect.min[0],
+ height: context.rect.max[1] - context.rect.min[1],
+ },
+ canvasSize: size,
+ }),
+ );
+ }, [
+ activeImageId,
+ canvasSize.height,
+ canvasSize.width,
+ controller.focusRequest,
+ logicalSize,
+ setViewport,
+ tree,
+ ]);
+
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts
index 9139e837f..4959833c7 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts
@@ -1,9 +1,17 @@
+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 UiEditorToolId = 'input' | 'components' | 'assets' | 'layout';
export type UiEditorImportKind = 'design-image' | 'sprite';
+export type UiEditorNodeFocusRequest = {
+ treeId: UIDesignImageId;
+ nodeId: NodeId;
+ requestId: number;
+};
+
export type PendingResourceRemoval = {
kind: UiEditorImportKind;
id: string;
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts
new file mode 100644
index 000000000..fa7b1e702
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts
@@ -0,0 +1,38 @@
+import { useCallback, useRef, useState } from 'react';
+
+import type { NodeId } from '../../features/ui-editor/types/NodeId';
+import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
+import type { UiEditorNodeFocusRequest } from './model';
+
+export function useUiEditorNodeFocus({
+ activateImage,
+ clearSpriteSelection,
+ selectNode,
+}: {
+ activateImage: (id: UIDesignImageId) => void;
+ clearSpriteSelection: () => void;
+ selectNode: (id: NodeId) => void;
+}) {
+ const nextRequestIdRef = useRef(0);
+ const [focusRequest, setFocusRequest] =
+ useState(null);
+
+ const focusNode = useCallback(
+ (treeId: UIDesignImageId, nodeId: NodeId) => {
+ activateImage(treeId);
+ clearSpriteSelection();
+ selectNode(nodeId);
+ nextRequestIdRef.current += 1;
+ setFocusRequest({
+ treeId,
+ nodeId,
+ requestId: nextRequestIdRef.current,
+ });
+ },
+ [activateImage, clearSpriteSelection, selectNode],
+ );
+
+ const clearFocusRequest = useCallback(() => setFocusRequest(null), []);
+
+ return { focusRequest, focusNode, clearFocusRequest };
+}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts
index dd546edc7..c3e6398bc 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ImportedAsset } from '../../components/AssetImporter';
+import { applyBindingResult } from '../../features/ui-editor/binding';
import {
prepareDesignImageBatch,
prepareSpriteAssetBatch,
@@ -14,7 +15,6 @@ import {
validateLayoutReviewPrerequisites,
} from '../../features/ui-editor/prerequisites';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
-import { applyBindingResult } from '../../features/ui-editor/binding';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { Component } from '../../features/ui-editor/types/Component';
import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
@@ -44,6 +44,7 @@ import {
uiEditorOperationError,
type UiEditorToolId,
} from './model';
+import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
@@ -62,6 +63,11 @@ export function useUiEditorPage(
const [selectedSpriteId, setSelectedSpriteId] =
useState(null);
const [selectedNodeId, setSelectedNodeId] = useState(null);
+ const { focusRequest, focusNode, clearFocusRequest } = useUiEditorNodeFocus({
+ activateImage: setActiveImageId,
+ clearSpriteSelection: () => setSelectedSpriteId(null),
+ selectNode: setSelectedNodeId,
+ });
const [keepChildrenUnchanged, setKeepChildrenUnchanged] = useState(false);
const [importKind, setImportKind] = useState(null);
const [previewUrls, setPreviewUrls] = useState>({});
@@ -82,6 +88,7 @@ export function useUiEditorPage(
const [mergeStatus, setMergeStatus] = useState(null);
const [isBinding, setIsBinding] = useState(false);
const [bindingStatus, setBindingStatus] = useState(null);
+ const { replaceState } = editor;
useEffect(() => {
if (!resourceId) {
@@ -94,7 +101,7 @@ export function useUiEditorPage(
.load(resourceId)
.then((state) => {
if (!cancelled) {
- editor.replaceState(state);
+ replaceState(state);
}
})
.catch((cause: unknown) => {
@@ -110,7 +117,7 @@ export function useUiEditorPage(
return () => {
cancelled = true;
};
- }, [editor.replaceState, resourceId, stateStore]);
+ }, [replaceState, resourceId, stateStore]);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -289,6 +296,7 @@ export function useUiEditorPage(
}
function clearState() {
+ clearFocusRequest();
editor.clearState();
setImageOrder([]);
setActiveImageId(null);
@@ -300,22 +308,26 @@ export function useUiEditorPage(
}
function selectTool(tool: UiEditorToolId) {
+ clearFocusRequest();
setActiveTool(tool);
setIssues(null);
}
function selectDesignImage(id: UIDesignImageId) {
+ clearFocusRequest();
setActiveImageId(id);
setSelectedSpriteId(null);
setSelectedNodeId(null);
}
function selectNode(id: NodeId) {
+ clearFocusRequest();
setSelectedSpriteId(null);
setSelectedNodeId(id);
}
function clearNodeSelection() {
+ clearFocusRequest();
setSelectedNodeId(null);
}
@@ -441,6 +453,7 @@ export function useUiEditorPage(
}
function selectSprite(id: SpriteAssetId) {
+ clearFocusRequest();
setSelectedNodeId(null);
setSelectedSpriteId(id);
}
@@ -562,9 +575,7 @@ export function useUiEditorPage(
index < allSpriteIds.length;
index += ASSET_BATCH_SIZE
) {
- batches.push(
- allSpriteIds.slice(index, index + ASSET_BATCH_SIZE),
- );
+ batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE));
}
if (batches.length === 0) batches.push([]);
let current = snapshot;
@@ -578,7 +589,9 @@ export function useUiEditorPage(
current = applyBindingResult(current, result);
editor.replaceState(current);
}
- setBindingStatus(`组件绑定完成(${batches.length}/${batches.length})。`);
+ setBindingStatus(
+ `组件绑定完成(${batches.length}/${batches.length})。`,
+ );
});
} catch (cause) {
setBindingStatus(cause instanceof Error ? cause.message : String(cause));
@@ -598,6 +611,7 @@ export function useUiEditorPage(
activeImageId,
selectedSpriteId,
selectedNodeId,
+ focusRequest,
importKind,
previewUrls,
status,
@@ -619,6 +633,7 @@ export function useUiEditorPage(
selectDesignImage,
selectSprite,
selectNode,
+ focusNode,
clearNodeSelection,
setNodeTransform,
updateNodeTransform,
diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts
new file mode 100644
index 000000000..db6108fa9
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ collectUiTreeNodeTargets,
+ getNextUiTreeNodeTarget,
+ getStageStatusOverview,
+ getStageStatusTargets,
+ isNeedReview,
+} from '../src/features/ui-editor/stageStatusOverview';
+import type { Node } from '../src/features/ui-editor/types/Node';
+import type { StageStatus } from '../src/features/ui-editor/types/StageStatus';
+import type { UITree } from '../src/features/ui-editor/types/UITree';
+
+function node(id: string, status: StageStatus, children: Node[] = []): Node {
+ return {
+ id,
+ transform: {
+ anchor_min: [0, 0],
+ anchor_max: [1, 1],
+ offset_min: [0, 0],
+ offset_max: [0, 0],
+ },
+ metadata: {
+ name: id,
+ description: '',
+ layout_status: status,
+ components_status: 'Pending',
+ allow_llm_edit_layout: true,
+ allow_llm_edit_component: true,
+ source: 'System',
+ },
+ components: [],
+ children,
+ };
+}
+
+const trees: UITree[] = [
+ {
+ src_ui_design: 'page-a',
+ root: node('root-a', 'Passed', [
+ node('review-a', { NeedReview: '请检查' }),
+ node('blocked-a', 'Blocked'),
+ ]),
+ },
+ {
+ src_ui_design: 'page-b',
+ root: node('root-b', 'Pending', [node('passed-b', 'Passed')]),
+ },
+];
+
+describe('stageStatusOverview', () => {
+ it('counts all nodes and keeps the agreed overlapping status totals', () => {
+ expect(getStageStatusOverview(trees, 'layout_status')).toEqual({
+ total: 5,
+ needsAttention: 2,
+ passed: 2,
+ blocked: 1,
+ });
+ });
+
+ it('uses tree order and preorder traversal for navigation targets', () => {
+ expect(collectUiTreeNodeTargets(trees).map(({ node }) => node.id)).toEqual([
+ 'root-a',
+ 'review-a',
+ 'blocked-a',
+ 'root-b',
+ 'passed-b',
+ ]);
+ });
+
+ it('cycles from the previous target and restarts when it disappeared', () => {
+ const targets = getStageStatusTargets(
+ trees,
+ 'layout_status',
+ (status) => status === 'Blocked' || isNeedReview(status),
+ );
+
+ expect(getNextUiTreeNodeTarget(targets, null)?.node.id).toBe('review-a');
+ expect(getNextUiTreeNodeTarget(targets, 'review-a')?.node.id).toBe(
+ 'blocked-a',
+ );
+ expect(getNextUiTreeNodeTarget(targets, 'blocked-a')?.node.id).toBe(
+ 'review-a',
+ );
+ expect(getNextUiTreeNodeTarget(targets, 'removed')?.node.id).toBe(
+ 'review-a',
+ );
+ });
+});