diff --git a/CONTEXT.md b/CONTEXT.md
index fc2799e85..c22344ea0 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -48,6 +48,23 @@ _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
+**UI 设计文档**:
+AGC 本地项目里 `kind=ui-design-doc`、`mediaType=application/json` 的界面编辑载体,保存设计图集合、UI 树、组件绑定和 State revision;一份文档可同时承载多张设计图与各自的结构树,不强制收敛成一棵树。
+_Avoid_: 把 UI 设计图当成设计文档、给设计文档再套一层「页面」概念
+
+**设计图**:
+UI 设计文档里的一张参考界面图,以它在 manifest 中的资产 ID 作为文档内身份,附带像素尺寸与像素比;文档内的每棵树都必须引用文档里已存在的设计图。
+_Avoid_: 用文件名当设计图身份、设计图与设计文档同一概念
+
+**UI 工作流步骤**:
+对一份 UI 设计文档执行的一次受控处理阶段,当前只有「结构识别」与「素材切分」两步;步骤产物只有在写入文档并保存后才算完成。
+_Avoid_: 把单次工具调用当成步骤、把中间产物当成步骤完成
+
+**工作流检查点日志**:
+一份 UI 设计文档旁按行追加的恢复用日志,每完成一个工作流步骤追加一行;某步是否有对应行即代表该步是否完成,恢复时从第一个缺失的行继续。
+_Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛化成通用检查点
+
+
## Language
### Puzzle Clear
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index 6b2c74b13..37271c331 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -157,11 +157,8 @@ const allowedUncalledTauriCommands = [
'set_active_game_creator_agent_session',
'start_game_creator_agent_goal',
'start_game_creator_supervisor_runtime_task',
- // TODO: Remove the retired binding command after the legacy runtime path is removed.
- 'bind_components',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
- 'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
index 753d89477..91ebf61db 100644
--- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
+++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json
@@ -1,11 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
- "description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
+ "description": "AI 游戏创作主窗口允许读写系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
+ "clipboard-manager:allow-write-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json
index 88d921118..982112915 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json
@@ -7,6 +7,7 @@
"execution": "texts/execution.json",
"media": "texts/media.json",
"nativeTools": "texts/native-tools.json",
+ "uiDesignDoc": "texts/ui-design-doc.json",
"recovery": "texts/recovery.json",
"interaction": "texts/interaction.json",
"goalContext": "texts/goal-context.json",
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json
index 1e70efd57..7942f4de3 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json
@@ -37,7 +37,6 @@
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
- "ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。",
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;仅提交 code,客户端负责绑定项目与编辑器进程。",
"unity.editor.execute.description": "在当前 Unity 项目已打开的编辑器中执行 C#。仅提交 code;结果待核对时禁止自动重发。",
"godot.editor.execute.description": "在当前 Godot 项目已打开的编辑器中执行支持 return/await 的 GDScript 函数体。仅提交 code;结果待核对时禁止自动重发。",
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/ui-design-doc.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/ui-design-doc.json
new file mode 100644
index 000000000..910a916fa
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/ui-design-doc.json
@@ -0,0 +1,8 @@
+{
+ "from_images.description": "用一至四张设计图新建一份 UI 设计文档:输入给已登记资源的 assetId,或给项目内相对路径由本工具顺带登记;随后按 ui/UI 设计 N.json 取号创建文档、把设计图写进文档并登记为 ui-design-doc 资源,返回新文档的 assetId 与 relativePath。每次调用都新建文档,不复用既有文档。",
+ "from_images.parameters.images": "一至四张设计图。每张给 assets 里已登记图片资源的 assetId,或给项目内相对路径(相对路径会先登记成图片资源);文档内的设计图身份就是该图片资源的 assetId。",
+ "run_workflow.description": "对一份 UI 设计文档在 Rust 内依次执行结构识别与自动切分素材:把切分素材回填到 Image 组件的 target_graphic、清掉已处理节点的组件状态、给达到返工上限的问题节点写 NeedReview,最后写回文档并推进 revision。每完成一步追加一行检查点,崩溃后从第一个缺失的步骤继续;不做页面发现,也不做多树合并与独立组件绑定。文档在轮次中途被改动时本轮作废并报错,必须重新调用本工具开新一轮。",
+ "run_workflow.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId;文档内必须已有设计图。",
+ "into_js.description": "把一份 UI 设计文档的当前 revision 渲染成 ui/generated--.js,返回相对路径与导出树。只读文档内容,不修改文档、不推进 revision。",
+ "into_js.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId。"
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs
index 9c397e912..34d2fda40 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs
@@ -677,160 +677,14 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
"revisionAdvanceCount": value.get("revisionAdvanceCount").and_then(serde_json::Value::as_u64).unwrap_or(0),
})).ok();
}
- if observation.tool == "ui.workflow.run" {
- let value = serde_json::from_str::(
- observation.detail.as_deref().unwrap_or_default(),
- )
- .ok()?;
- let operation = value.get("operation")?.as_str()?;
- let project_id = value.get("projectId")?.as_str()?;
- let source_asset_id = value.get("sourceAssetId")?.as_str()?;
- let completed = value.get("completed")?.as_bool()?;
- let revision_advance_count = value.get("revisionAdvanceCount")?.as_u64()?;
- if project_id.is_empty()
- || source_asset_id.is_empty()
- || !matches!(
- operation,
- "discover" | "prepare" | "recognize" | "status" | "finalize"
- )
- || project_id.chars().any(char::is_control)
- || source_asset_id.chars().any(char::is_control)
- {
- return None;
- }
- if operation == "discover" {
- let discovered_pages = value.get("discoveredPages")?.as_array()?;
- if discovered_pages.is_empty() || discovered_pages.len() > 32 {
- return None;
- }
- let safe_pages = discovered_pages
- .iter()
- .map(|page| {
- let page_id = page.get("pageId")?.as_str()?;
- let title = page.get("title")?.as_str()?;
- let description = page.get("description")?.as_str()?;
- let application_path = page.get("applicationPath")?.as_str()?;
- let required_design_asset_path =
- page.get("requiredDesignAssetPath")?.as_str()?;
- let discovered_from = page.get("discoveredFrom")?.as_str()?;
- if page_id.is_empty()
- || title.is_empty()
- || description.chars().any(char::is_control)
- || application_path.is_empty()
- || !application_path.starts_with("game/")
- || required_design_asset_path.is_empty()
- || discovered_from.is_empty()
- {
- return None;
- }
- Some(serde_json::json!({
- "pageId": agent_runtime_action_receipt_safe_text(root, page_id, 80, None)?,
- "title": agent_runtime_action_receipt_safe_text(root, title, 120, None)?,
- "description": agent_runtime_action_receipt_safe_text(root, description, 400, None)?,
- "applicationPath": agent_runtime_action_receipt_safe_text(root, application_path, 240, None)?,
- "requiredDesignAssetPath": agent_runtime_action_receipt_safe_text(root, required_design_asset_path, 240, None)?,
- "discoveredFrom": agent_runtime_action_receipt_safe_text(root, discovered_from, 240, None)?,
- }))
- })
- .collect::
- 删除组件树:{impact?.removedTreeCount ?? 0}
- - 清空界面归属:{impact?.clearedSlaveToCount ?? 0}
- 清空图片组件引用:{impact?.clearedTargetGraphicCount ?? 0}
- 清空文本字体引用:{impact?.clearedFontCount ?? 0}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx
deleted file mode 100644
index 0823299bd..000000000
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx
+++ /dev/null
@@ -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;
- sprites: Record;
- uiTrees: UITree[];
-}) {
- const componentCount = uiTrees.reduce(
- (total, tree) => total + countUiComponents([tree.root]),
- 0,
- );
-
- return (
-
-
-
-
- Overview
-
-
导入概览
-
-
-
-
-
- {Object.keys(images).length}
-
-
- 界面图数量
-
-
-
-
- {Object.keys(sprites).length}
-
-
- 素材数量
-
-
-
-
- {componentCount}
-
-
- 已有组件
-
-
-
-
- );
-}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx
index b473accb2..065780691 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx
@@ -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,
@@ -70,6 +75,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
component: null,
children_display_mode: 'Stack',
children: uiTrees.map((tree) => tree.root),
+ offset: { min: [0, 0], max: [0, 0] },
};
}, [uiTrees]);
@@ -82,9 +88,18 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
focusRequest={focusRequest}
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
isNodePreviewVisible={input.isNodePreviewVisible}
- onSelectNode={(treeId, nodeId) => {
- input.selectDesignImage(treeId);
- input.selectNode(nodeId);
+ onSelectNode={(_treeId, nodeId) => {
+ // react-arborist emits `onSelect` when its controlled `selection`
+ // prop is updated. Overview navigation updates the selection and
+ // the status highlight in the same render, so treating that
+ // programmatic notification as a fresh user selection would clear
+ // the highlight before it can be painted. Only mutate selection
+ // state when the target actually differs from the current one.
+ const sameNode = input.selectedNodeId === nodeId;
+ if (!sameNode) {
+ input.selectDesignImage(_treeId);
+ input.selectNode(nodeId);
+ }
}}
onToggleNodeVisibility={(nodeId) =>
input.toggleNodePreviewVisibility(nodeId)
@@ -120,7 +135,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
>
{imageOrder.length > 0 ? (
- imageOrder.map((id) => {
+ imageOrder.map((id, index) => {
const image = images[id];
if (!image) return null;
return (
@@ -147,10 +162,9 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
- {image.metadata.name}
+ {designImageLabel(image.path, index)}
- {image.metadata.role ?? '自动判断'} ·{' '}
{image.pixel_size[0]} × {image.pixel_size[1]}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx
index 15e77c8e7..d7005f4fa 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx
@@ -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({
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,
};
}
@@ -284,7 +274,7 @@ function NodeInspector({
onMetadataChange,
highlightedStatusField,
onTransformChange,
- onLayoutChange,
+ // onLayoutChange,
sprites,
previewUrls,
fonts,
@@ -483,11 +473,11 @@ function NodeInspector({
readOnly={transformReadOnly || isReadOnly}
onChange={onTransformChange}
/>
-
+ {/**/}
{
- if (!highlight) return;
- statusRowRef.current?.scrollIntoView({
+ const statusRow = statusRowRef.current;
+ if (!highlight || !statusRow) return;
+ statusRow.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
+
+ // 强制制造一次样式边界,避免 A → B → A 时浏览器复用已完成的动画。
+ statusRow.classList.remove('ui-editor-status-attention');
+ void statusRow.offsetWidth;
+ statusRow.classList.add('ui-editor-status-attention');
+
+ // 多节点切换会复用 Inspector 树,显式重启动画,确保回到已查看节点时
+ // 仍能再次播放提示,而不是依赖 class/key 的重协调行为。
+ if (typeof statusRow.getAnimations === 'function') {
+ for (const animation of statusRow.getAnimations()) {
+ const animationName = (animation as CSSAnimation).animationName;
+ if (!animationName?.startsWith('ui-editor-status-attention')) continue;
+ animation.cancel();
+ animation.play();
+ }
+ }
}, [highlight]);
return (
@@ -1016,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;
}) {
@@ -1038,65 +1036,13 @@ function ImageInspector({
return (
-
{
- if (!readOnly) onNameChange(event.target.value);
- }}
- />
- {
- if (!readOnly) onDescriptionChange(event.target.value);
- }}
- />
+
+ {resourceAssetDisplayName(image.path)}
+
- {
- if (!readOnly) {
- onRoleChange(
- (event.target.value || null) as UIDesignImageRole | null,
- );
- }
- }}
- >
- 自动判断
- {UI_DESIGN_IMAGE_ROLES.map((role) => (
-
- {role.label}
-
- ))}
-
- {image.metadata.role !== 'Page' || image.metadata.slave_to !== null ? (
- {
- if (!readOnly) {
- onSlaveToChange(
- (event.target.value || null) as UIDesignImageId | null,
- );
- }
- }}
- >
- 未设置
- {pageOptions
- .filter(([id]) => id !== imageId)
- .map(([id, page]) => (
-
- {page.metadata.name}
-
- ))}
-
- ) : null}
{UI_EDITOR_STEPS.map((step, index) => {
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx
new file mode 100644
index 000000000..ea19e48bf
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx
@@ -0,0 +1,43 @@
+import { writeText } from '@tauri-apps/plugin-clipboard-manager';
+import { Copy } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+export function UiEditorCopyPathButton({
+ relativePath,
+}: {
+ relativePath: string;
+}) {
+ const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
+ 'idle',
+ );
+
+ useEffect(() => setCopyState('idle'), [relativePath]);
+
+ async function copyPath() {
+ setCopyState('idle');
+ try {
+ await writeText(relativePath);
+ setCopyState('copied');
+ } catch {
+ setCopyState('failed');
+ }
+ }
+
+ return (
+ <>
+ {copyState === 'failed' ? (
+
+ 复制失败,请手动复制路径。
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorSaveResultModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorSaveResultModal.tsx
new file mode 100644
index 000000000..053b97662
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorSaveResultModal.tsx
@@ -0,0 +1,154 @@
+import { ThemedModal } from '../../../components/modal/ThemedModal';
+import { UiEditorCopyPathButton } from './UiEditorCopyPathButton';
+
+export type UiEditorSaveResultNotice =
+ | {
+ kind: 'saved';
+ }
+ | {
+ kind: 'generated';
+ relativePath: string;
+ }
+ | {
+ kind: 'failure';
+ message: string;
+ retryLabel?: string;
+ onRetry?: () => void;
+ };
+
+export function UiEditorSaveResultModal({
+ notice,
+ onClose,
+}: {
+ notice: UiEditorSaveResultNotice | null;
+ onClose: () => void;
+}) {
+ return (
+
+ {renderNoticeContent(notice, onClose)}
+
+ );
+}
+
+function renderNoticeContent(
+ notice: UiEditorSaveResultNotice | null,
+ onClose: () => void,
+) {
+ if (!notice) return null;
+ if (notice.kind === 'generated') {
+ return (
+
+ );
+ }
+ if (notice.kind === 'saved') {
+ return ;
+ }
+ return ;
+}
+
+function GeneratedNotice({
+ relativePath,
+ onClose,
+}: {
+ relativePath: string;
+ onClose: () => void;
+}) {
+ return (
+ <>
+ 代码已生成
+ 生成文件路径
+
+ {relativePath}
+
+
+
+
+
+ >
+ );
+}
+
+function SimpleNotice({
+ title,
+ onClose,
+}: {
+ title: string;
+ onClose: () => void;
+}) {
+ return (
+ <>
+ {title}
+
+ >
+ );
+}
+
+function FailureNotice({
+ notice,
+ onClose,
+}: {
+ notice: Extract;
+ onClose: () => void;
+}) {
+ return (
+ <>
+ 操作失败
+
+ {notice.message}
+
+
+ >
+ );
+}
+
+function NoticeFooter({
+ onClose,
+ onRetry,
+ retryLabel = '重试',
+}: {
+ onClose: () => void;
+ onRetry?: () => void;
+ retryLabel?: string;
+}) {
+ return (
+
+ {onRetry ? (
+
+ ) : null}
+
+
+ );
+}
+
+function ariaLabelForNotice(notice: UiEditorSaveResultNotice | null) {
+ if (notice?.kind === 'generated') return '代码已生成';
+ if (notice?.kind === 'saved') return '保存成功';
+ if (notice?.kind === 'failure') return '操作失败';
+ return '保存结果';
+}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx
index 7c392369b..24d61f8e6 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx
@@ -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: '识别界面结构',
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts
index 5df5d0d9b..4a0431922 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts
@@ -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':
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 26203c35a..04150de7a 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
@@ -1,4 +1,5 @@
import {
+ CANVAS_WORLD_SIZE,
CANVAS_ZOOM_IN_FACTOR,
CANVAS_ZOOM_OUT_FACTOR,
canvasDisplayScaleToViewportScale,
@@ -30,26 +31,107 @@ 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';
+import {
+ createPreviewRightPanGesture,
+ movePreviewRightPanGesture,
+ type PreviewRightPanGesture,
+ resolvePreviewRightPanRelease,
+ shouldInterceptRightContextMenu,
+} from './previewRightPanGesture';
import {
handlePreviewZoomKeyDown,
isPreviewZoomInteractiveTarget,
previewZoomUsesMetaModifier,
} from './previewZoomKeyboard';
-import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
+import { UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
+/** 画布初始适配比例;调小可为浮动面板预留更多可视空间。 */
+export const UI_EDITOR_FIT_DEGREE = 0.7;
+
+function releasePointerCapture(element: Element, pointerId: number) {
+ if (element.hasPointerCapture(pointerId)) {
+ element.releasePointerCapture(pointerId);
+ }
+}
+
+function isClientPointInsideElement(
+ element: Element | null,
+ clientX: number,
+ clientY: number,
+) {
+ if (!element) return false;
+ const rect = element.getBoundingClientRect();
+ return (
+ clientX >= rect.left &&
+ clientX <= rect.right &&
+ clientY >= rect.top &&
+ clientY <= rect.bottom
+ );
+}
+
+/** 右键抬起时命中的节点与它所属的树,用于打开节点菜单。 */
+function resolveRightClickMenuTarget(target: EventTarget | null) {
+ const element = target instanceof Element ? target : null;
+ const nodeId = element
+ ?.closest('[data-node-id]')
+ ?.getAttribute('data-node-id');
+ const treeId = element
+ ?.closest('[data-tree-id]')
+ ?.getAttribute('data-tree-id');
+ if (!nodeId || !treeId) return null;
+ return { nodeId, treeId };
+}
+
+function fitEditorViewport(
+ bounds: { x: number; y: number; width: number; height: number },
+ canvasSize: { width: number; height: number },
+): CanvasViewport {
+ const fit = fitViewportToBounds({ bounds, canvasSize });
+ const scale = fit.scale * UI_EDITOR_FIT_DEGREE;
+ return {
+ scale,
+ x: canvasSize.width / 2 - (bounds.x + bounds.width / 2) * scale,
+ y: canvasSize.height / 2 - (bounds.y + bounds.height / 2) * scale,
+ };
+}
+
export function PreviewWorkspace({
canvas,
+ showFrame,
+ showOriginImage,
+ showComponent,
}: {
canvas: UiEditorCanvasProjection;
+ showFrame: boolean;
+ showOriginImage: boolean;
+ showComponent: boolean;
}) {
- const { activeImage, activeImageId, previewUrls } = canvas;
+ const { previewUrls, uiTrees, images } = canvas;
const viewportElementRef = useRef(null);
const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 });
const handledFocusRequestIdRef = useRef(null);
+ const didInitialFitRef = useRef(false);
const panRef = useRef | null>(null);
+ const rightPanRef = useRef(null);
+ // 右键按下时的命中元素:指针被视口捕获后,pointerup 的 target 会变成捕获元素,
+ // 必须在按下时把真正的节点/树目标记下来。
+ const rightPanTargetRef = useRef(null);
+ const rightPressSequenceRef = useRef(false);
+ const treeDragRef = useRef<{
+ treeId: string;
+ pointerId: number;
+ startX: number;
+ startY: number;
+ origin: [number, number];
+ } | null>(null);
+ const [previewTreeOffsets, setPreviewTreeOffsets] = useState<
+ ReadonlyMap
+ >(new Map());
const previewFocusedRef = useRef(false);
const previewHoveredRef = useRef(false);
const [viewport, setViewportState] = useState(
@@ -58,19 +140,17 @@ export function PreviewWorkspace({
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
const canvasSizeRef = useRef(canvasSize);
const [spaceHeld, setSpaceHeld] = useState(false);
- const [renderMode, setRenderMode] =
- useState('editor-overlay');
- const [showFrame, setShowFrame] = useState(false);
+ const [isPanning, setIsPanning] = useState(false);
const [previewTransforms, setPreviewTransforms] = useState<
ReadonlyMap
>(new Map());
const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId;
+ treeId: string;
x: number;
y: number;
isPageRoot: boolean;
} | null>(null);
- const tree = canvas.tree ?? null;
const updatePreviewTransform = useCallback(
(nodeId: NodeId, transform: Node['layout']['transform'] | null) => {
setPreviewTransforms((current) => {
@@ -84,41 +164,62 @@ export function PreviewWorkspace({
},
[],
);
- useEffect(() => {
- setPreviewTransforms(new Map());
- }, [activeImageId]);
- const activeImagePixelWidth = activeImage?.pixel_size[0];
- const activeImagePixelHeight = activeImage?.pixel_size[1];
- const activeImagePixelsPerUnit = activeImage?.pixels_per_unit;
- const logicalSize = useMemo(() => {
- if (
- activeImagePixelWidth === undefined ||
- activeImagePixelHeight === undefined ||
- activeImagePixelsPerUnit === undefined
- ) {
- return null;
- }
- const ppu = activeImagePixelsPerUnit;
- if (!Number.isFinite(ppu) || ppu <= 0) return null;
- const width = activeImagePixelWidth / ppu;
- const height = activeImagePixelHeight / ppu;
- if (
- !Number.isFinite(width) ||
- !Number.isFinite(height) ||
- width <= 0 ||
- height <= 0
- ) {
- return null;
- }
- return { width, height };
- }, [activeImagePixelHeight, activeImagePixelsPerUnit, activeImagePixelWidth]);
+ const treeLayouts = useMemo(
+ () =>
+ uiTrees.map((item) => {
+ const image = images[item.src_ui_design];
+ const ppu = image?.pixels_per_unit ?? 0;
+ const width =
+ image && Number.isFinite(ppu) && ppu > 0
+ ? image.pixel_size[0] / ppu
+ : 0;
+ const height =
+ image && Number.isFinite(ppu) && ppu > 0
+ ? image.pixel_size[1] / ppu
+ : 0;
+ return {
+ tree: item,
+ image,
+ width,
+ height,
+ validOffset:
+ item.root.offset?.min?.every((value) => Number.isFinite(value)) ??
+ false,
+ };
+ }),
+ [images, uiTrees],
+ );
+ const allBounds = useMemo(() => {
+ const valid = treeLayouts.filter(
+ (item) => item.validOffset && item.width > 0 && item.height > 0,
+ );
+ if (valid.length === 0) return null;
+ const minX = Math.min(...valid.map(({ tree }) => tree.root.offset.min[0]));
+ const minY = Math.min(...valid.map(({ tree }) => tree.root.offset.min[1]));
+ const maxX = Math.max(
+ ...valid.map(({ tree, width }) => tree.root.offset.min[0] + width),
+ );
+ const maxY = Math.max(
+ ...valid.map(({ tree, height }) => tree.root.offset.min[1] + height),
+ );
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
+ }, [treeLayouts]);
+ const logicalSizes = useMemo(
+ () =>
+ new Map(
+ treeLayouts.map(
+ ({ tree: item, width, height }) =>
+ [item.src_ui_design, { width, height }] as const,
+ ),
+ ),
+ [treeLayouts],
+ );
const nodeInteractions = useNodeTransformInteraction({
- activeImageId,
canvas,
- logicalSize,
+ trees: uiTrees,
+ logicalSizes,
spaceHeld,
- tree,
keepChildrenUnchanged: canvas.keepChildrenUnchanged,
viewportRef,
onPreviewTransform: updatePreviewTransform,
@@ -143,28 +244,27 @@ export function PreviewWorkspace({
}, []);
const fitToCanvas = useCallback(() => {
- if (!logicalSize) return;
+ if (!allBounds) return;
const element = viewportElementRef.current;
setViewport(
- fitViewportToBounds({
- bounds: {
- x: 0,
- y: 0,
- width: logicalSize.width,
- height: logicalSize.height,
- },
- canvasSize: {
- width: element?.clientWidth || canvasSizeRef.current.width,
- height: element?.clientHeight || canvasSizeRef.current.height,
- },
+ fitEditorViewport(allBounds, {
+ width: element?.clientWidth || canvasSizeRef.current.width,
+ height: element?.clientHeight || canvasSizeRef.current.height,
}),
);
- }, [logicalSize, setViewport]);
+ }, [allBounds, setViewport]);
useEffect(() => {
- if (!activeImageId || !logicalSize) return;
- fitToCanvas();
- }, [activeImageId, fitToCanvas, logicalSize]);
+ if (!allBounds) return;
+ if (didInitialFitRef.current) return;
+ didInitialFitRef.current = true;
+ const element = viewportElementRef.current;
+ const canvasSize = {
+ width: element?.clientWidth || canvasSizeRef.current.width,
+ height: element?.clientHeight || canvasSizeRef.current.height,
+ };
+ setViewport(fitEditorViewport(allBounds, canvasSize));
+ }, [allBounds, setViewport]);
const scaleViewportFromCenter = useCallback(
(nextScale: number) => {
@@ -195,6 +295,7 @@ export function PreviewWorkspace({
}, [scaleViewportFromCenter]);
const displayPercent = formatCanvasDisplayScalePercent(viewport.scale);
+ const previewGridStep = resolvePreviewGridStep(viewport.scale);
const zoomToDisplayScale = useCallback(
(displayScale: number) => {
@@ -222,15 +323,12 @@ export function PreviewWorkspace({
useEffect(() => {
const request = canvas.focusRequest;
- if (
- !request ||
- request.requestId === handledFocusRequestIdRef.current ||
- request.treeId !== activeImageId ||
- !tree ||
- !logicalSize
- ) {
+ if (!request || request.requestId === handledFocusRequestIdRef.current) {
return;
}
+ const tree = uiTrees.find((item) => item.src_ui_design === request.treeId);
+ const logicalSize = logicalSizes.get(request.treeId);
+ if (!tree || !logicalSize) return;
const context = findNodePageContext(tree.root, request.nodeId, {
min: [0, 0],
max: [logicalSize.width, logicalSize.height],
@@ -243,24 +341,23 @@ export function PreviewWorkspace({
};
handledFocusRequestIdRef.current = request.requestId;
setViewport(
- fitViewportToBounds({
- bounds: {
+ fitEditorViewport(
+ {
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,
- }),
+ size,
+ ),
);
}, [
- activeImageId,
canvasSize.height,
canvasSize.width,
canvas.focusRequest,
- logicalSize,
+ logicalSizes,
setViewport,
- tree,
+ uiTrees,
]);
useEffect(() => {
@@ -287,7 +384,7 @@ export function PreviewWorkspace({
handlePreviewZoomKeyDown(
event,
{
- hasZoomableViewport: logicalSize !== null,
+ hasZoomableViewport: allBounds !== null,
isFocused: previewFocusedRef.current,
isHovered: previewHoveredRef.current,
usesMetaModifier,
@@ -302,7 +399,23 @@ export function PreviewWorkspace({
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') setSpaceHeld(false);
};
- const onWindowBlur = () => setSpaceHeld(false);
+ const onWindowBlur = () => {
+ setSpaceHeld(false);
+ rightPressSequenceRef.current = false;
+ rightPanRef.current = null;
+ setIsPanning(false);
+ // 失焦时浏览器不一定补 pointercancel:丢掉未提交的树拖拽,避免后续指针事件
+ // 复用陈旧手势把意外偏移写进文档。
+ const drag = treeDragRef.current;
+ if (drag) {
+ treeDragRef.current = null;
+ setPreviewTreeOffsets((current) => {
+ const next = new Map(current);
+ next.delete(drag.treeId);
+ return next;
+ });
+ }
+ };
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onWindowBlur);
@@ -311,15 +424,33 @@ export function PreviewWorkspace({
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onWindowBlur);
};
- }, [logicalSize, resetToActualSize, zoomIn, zoomOut]);
+ }, [allBounds, resetToActualSize, zoomIn, zoomOut]);
const handlePointerDown = (event: ReactPointerEvent) => {
+ // 右键 contextmenu 的拦截窗口要覆盖"按下到下一次按下"这段:
+ // Windows / Linux 在按下时触发,macOS 在抬起之后触发。
+ rightPressSequenceRef.current = event.button === 2;
+ if (event.button === 2) {
+ // 只接"单按右键"的干净起手,避免和左键拖动/框选互相抢指针捕获。
+ if (event.buttons !== 2 || rightPanRef.current !== null) return;
+ event.preventDefault();
+ event.currentTarget.focus({ preventScroll: true });
+ event.currentTarget.setPointerCapture(event.pointerId);
+ rightPanTargetRef.current = event.target;
+ rightPanRef.current = createPreviewRightPanGesture({
+ pointerId: event.pointerId,
+ pointer: { x: event.clientX, y: event.clientY },
+ viewport: viewportRef.current,
+ });
+ return;
+ }
if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) {
event.currentTarget.focus({ preventScroll: true });
}
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
+ setIsPanning(true);
panRef.current = createPanDragState({
pointerId: event.pointerId,
pointer: { x: event.clientX, y: event.clientY },
@@ -333,6 +464,18 @@ export function PreviewWorkspace({
};
const handlePointerMove = (event: ReactPointerEvent) => {
+ const rightPan = rightPanRef.current;
+ if (rightPan && rightPan.pointerId === event.pointerId) {
+ const pointer = { x: event.clientX, y: event.clientY };
+ const nextPan = movePreviewRightPanGesture(rightPan, pointer);
+ if (nextPan !== rightPan) {
+ rightPanRef.current = nextPan;
+ setIsPanning(true);
+ }
+ if (nextPan.panning)
+ setViewport(moveViewportFromPan(nextPan.pan, pointer));
+ return;
+ }
const drag = panRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setViewport(
@@ -340,12 +483,135 @@ export function PreviewWorkspace({
);
};
+ const openNodeContextMenuAt = (
+ event: ReactPointerEvent,
+ pressedTarget: EventTarget | null,
+ ) => {
+ const target = resolveRightClickMenuTarget(pressedTarget);
+ if (!target) return;
+ const layout = treeLayouts.find(
+ (item) => item.tree.src_ui_design === target.treeId,
+ );
+ if (!layout) return;
+ canvas.selectNode(target.nodeId);
+ setContextMenu({
+ nodeId: target.nodeId,
+ treeId: target.treeId,
+ x: event.clientX,
+ y: event.clientY,
+ isPageRoot: layout.tree.root.id === target.nodeId,
+ });
+ };
+
const handlePointerUp = (event: ReactPointerEvent) => {
+ const rightPan = rightPanRef.current;
+ if (rightPan && rightPan.pointerId === event.pointerId) {
+ rightPanRef.current = null;
+ setIsPanning(false);
+ releasePointerCapture(event.currentTarget, event.pointerId);
+ const release = resolvePreviewRightPanRelease({
+ gesture: rightPan,
+ pointer: { x: event.clientX, y: event.clientY },
+ isInsidePreview: isClientPointInsideElement(
+ viewportElementRef.current,
+ event.clientX,
+ event.clientY,
+ ),
+ });
+ if (release.viewport) setViewport(release.viewport);
+ const pressedTarget = rightPanTargetRef.current;
+ rightPanTargetRef.current = null;
+ if (release.openNodeMenu) openNodeContextMenuAt(event, pressedTarget);
+ return;
+ }
if (panRef.current?.pointerId !== event.pointerId) return;
panRef.current = null;
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
- event.currentTarget.releasePointerCapture(event.pointerId);
+ setIsPanning(false);
+ releasePointerCapture(event.currentTarget, event.pointerId);
+ };
+
+ const handlePointerCancel = (event: ReactPointerEvent) => {
+ const rightPan = rightPanRef.current;
+ const pan = panRef.current;
+ const cancelsRightPan =
+ rightPan !== null && rightPan.pointerId === event.pointerId;
+ const cancelsPan = pan !== null && pan.pointerId === event.pointerId;
+ if (cancelsRightPan) {
+ rightPanRef.current = null;
+ rightPanTargetRef.current = null;
}
+ if (cancelsPan) panRef.current = null;
+ if (cancelsRightPan || cancelsPan) {
+ setIsPanning(false);
+ releasePointerCapture(event.currentTarget, event.pointerId);
+ }
+ };
+
+ const handleTreePointerDown = (
+ event: ReactPointerEvent,
+ treeId: string,
+ rootId: string,
+ origin: [number, number],
+ ) => {
+ if (event.button !== 0) return;
+ const hit = (event.target as Element).closest('[data-node-id]');
+ if (!hit || hit.getAttribute('data-node-id') !== rootId) return;
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ treeDragRef.current = {
+ treeId,
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ origin,
+ };
+ };
+ const handleTreePointerMove = (event: ReactPointerEvent) => {
+ const drag = treeDragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ const next: [number, number] = [
+ drag.origin[0] +
+ (event.clientX - drag.startX) / viewportRef.current.scale,
+ drag.origin[1] +
+ (event.clientY - drag.startY) / viewportRef.current.scale,
+ ];
+ setPreviewTreeOffsets((current) => new Map(current).set(drag.treeId, next));
+ };
+ const handleTreePointerUp = (event: ReactPointerEvent) => {
+ if (treeDragRef.current?.pointerId !== event.pointerId) return;
+ const drag = treeDragRef.current;
+ if (drag) {
+ const next: [number, number] = [
+ drag.origin[0] +
+ (event.clientX - drag.startX) / viewportRef.current.scale,
+ drag.origin[1] +
+ (event.clientY - drag.startY) / viewportRef.current.scale,
+ ];
+ canvas.setTreeOffset(drag.treeId, next);
+ setPreviewTreeOffsets((current) => {
+ const result = new Map(current);
+ result.delete(drag.treeId);
+ return result;
+ });
+ }
+ treeDragRef.current = null;
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ };
+
+ /** 浏览器发起的指针取消不是正常抬起:只清拖拽状态与捕获,不写回树偏移。 */
+ const handleTreePointerCancel = (
+ event: ReactPointerEvent,
+ ) => {
+ const drag = treeDragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ treeDragRef.current = null;
+ setPreviewTreeOffsets((current) => {
+ const next = new Map(current);
+ next.delete(drag.treeId);
+ return next;
+ });
+ releasePointerCapture(event.currentTarget, event.pointerId);
};
const handleWheel = (event: React.WheelEvent) => {
@@ -369,61 +635,32 @@ export function PreviewWorkspace({
const handleNodeContextMenu = (
event: ReactMouseEvent,
- node: NonNullable['root'],
+ node: Node,
isPageRoot: boolean,
+ treeId?: string,
) => {
+ if (!treeId) return;
setContextMenu({
nodeId: node.id,
+ treeId,
x: event.clientX,
y: event.clientY,
isPageRoot,
});
};
- useEffect(() => {
- if (renderMode !== 'editor-overlay') setContextMenu(null);
- }, [renderMode]);
-
return (
-
- {activeImage &&
- logicalSize &&
- (renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? (
+
+ {treeLayouts.length > 0 && allBounds ? (
-
- {renderMode === 'final-preview' ? (
-
- ) : null}
-
-
-
-
-
{
+ if (
+ !shouldInterceptRightContextMenu({
+ button: event.button,
+ withinRightPressSequence: rightPressSequenceRef.current,
+ })
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ }}
onWheel={handleWheel}
+ onDragStart={(event) => event.preventDefault()}
onClick={(event) => {
if (!(event.target as Element).closest('[data-node-id]')) {
canvas.clearNodeSelection();
}
}}
>
-
-
- {renderMode === 'editor-overlay' ? (
-

- ) : (
-
- )}
-
-
+
+ {treeLayouts
+ .filter((item) => item.validOffset)
+ .map(({ tree: item, image, width, height }) => {
+ const offset =
+ previewTreeOffsets.get(item.src_ui_design) ??
+ item.root.offset.min;
+ return (
+
+ handleTreePointerDown(
+ event,
+ item.src_ui_design,
+ item.root.id,
+ offset,
+ )
+ }
+ onPointerMove={handleTreePointerMove}
+ onPointerUp={handleTreePointerUp}
+ onPointerCancel={handleTreePointerCancel}
+ >
+ {showOriginImage && previewUrls[item.src_ui_design] ? (
+

+ ) : null}
+
+
+ );
+ })}
{contextMenu ? (
@@ -512,13 +779,19 @@ export function PreviewWorkspace({
isPageRoot={contextMenu.isPageRoot}
disabled={canvas.isLocked}
onClose={() => setContextMenu(null)}
- onInsertChild={(nodeId) => canvas.insertNode(nodeId)}
- onInsertSibling={(nodeId) => canvas.insertNodeAfter(nodeId)}
- onDelete={(nodeId) => canvas.deleteNode(nodeId)}
+ onInsertChild={(nodeId) =>
+ canvas.insertNode(nodeId, contextMenu.treeId)
+ }
+ onInsertSibling={(nodeId) =>
+ canvas.insertNodeAfter(nodeId, contextMenu.treeId)
+ }
+ onDelete={(nodeId) =>
+ canvas.deleteNode(nodeId, contextMenu.treeId)
+ }
/>
) : null}
{
if (
event.target instanceof Element &&
@@ -527,6 +800,7 @@ export function PreviewWorkspace({
event.preventDefault();
}
}}
+ onDragStart={(event) => event.preventDefault()}
>
)}
-
);
}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx
index c4ca88a09..f92a19aad 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx
@@ -20,17 +20,17 @@ import { resolveExclusiveVisibleChildId } from './exclusiveVisibility';
export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry';
-export type UiEditorRenderMode = 'editor-overlay' | 'final-preview';
-
type NodePointerDown = (
event: ReactPointerEvent
,
node: UiNode,
+ treeId?: string,
) => void;
type UiTreeRendererProps = {
tree: UITree | null;
- renderMode: UiEditorRenderMode;
+ treeId?: string;
showFrame: boolean;
+ showComponent: boolean;
hiddenNodeIds: ReadonlySet;
previewTransforms?: ReadonlyMap;
selectedNodeId: NodeId | null;
@@ -41,6 +41,7 @@ type UiTreeRendererProps = {
event: ReactMouseEvent,
node: UiNode,
isPageRoot: boolean,
+ treeId?: string,
) => void;
onNodePointerDown: NodePointerDown;
onNodePointerMove: (event: ReactPointerEvent) => void;
@@ -50,6 +51,7 @@ type UiTreeRendererProps = {
event: ReactPointerEvent,
node: UiNode,
handle: ResizeHandle,
+ treeId?: string,
) => void;
onNodeResizePointerMove: (event: ReactPointerEvent) => void;
onNodeResizePointerUp: (event: ReactPointerEvent) => void;
@@ -81,10 +83,11 @@ const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap<
function RenderNode({
node,
+ treeId,
isRoot,
parentContainer,
- renderMode,
showFrame,
+ showComponent,
hiddenNodeIds,
previewTransforms,
selectedNodeId,
@@ -124,8 +127,8 @@ function RenderNode({
return null;
}
- const isEditorOverlay = renderMode === 'editor-overlay';
- const isFrameVisible = isEditorOverlay || showFrame;
+ const isSelected = selectedNodeId === node.id;
+ const isFrameVisible = showFrame || isSelected;
const receivesPointerGesture = parentContainer === undefined;
const hasDirectPointerGesture = receivesPointerGesture && !isRoot;
const exclusiveVisibleChildId =
@@ -170,15 +173,14 @@ function RenderNode({
}
}
onContextMenu={(event) => {
- if (!isEditorOverlay) return;
event.preventDefault();
event.stopPropagation();
onSelectNode(node.id);
- onNodeContextMenu(event, node, Boolean(isRoot));
+ onNodeContextMenu(event, node, Boolean(isRoot), treeId);
}}
onPointerDown={
receivesPointerGesture
- ? (event) => onNodePointerDown(event, node)
+ ? (event) => onNodePointerDown(event, node, treeId)
: undefined
}
onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined}
@@ -191,10 +193,10 @@ function RenderNode({
{node.metadata.name}
) : null}
- {node.component ? (
+ {showComponent && node.component ? (
) : null}
- {renderMode === 'final-preview' && selectedNodeId === node.id ? (
+ {isSelected ? (
nodeId === exclusiveVisibleChildId}
@@ -207,13 +209,14 @@ function RenderNode({
- onNodeResizePointerDown(event, node, handle.id)
+ onNodeResizePointerDown(event, node, handle.id, treeId)
}
onPointerMove={onNodeResizePointerMove}
onPointerUp={onNodeResizePointerUp}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewDragThreshold.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewDragThreshold.ts
new file mode 100644
index 000000000..a38a6f469
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewDragThreshold.ts
@@ -0,0 +1,21 @@
+/** 屏幕像素坐标点。 */
+export type ScreenPoint = {
+ x: number;
+ y: number;
+};
+
+/**
+ * 指针拖动阈值(屏幕像素)。左键拖动/缩放与右键拖拽平移共用同一判据,
+ * 保证同一张画布上"多小的移动算点击"只有一个答案。
+ */
+export const DRAG_THRESHOLD_SCREEN_PX = 2;
+
+export function passedDragThresholdScreen(
+ start: ScreenPoint,
+ current: ScreenPoint,
+): boolean {
+ return (
+ Math.hypot(current.x - start.x, current.y - start.y) >=
+ DRAG_THRESHOLD_SCREEN_PX
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts
new file mode 100644
index 000000000..db64a13e3
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewGrid.ts
@@ -0,0 +1,31 @@
+/** 背景网格的基础世界步长;缩放时按 2 倍档位调整屏幕密度。 */
+export const PREVIEW_GRID_BASE_STEP = 28;
+const PREVIEW_GRID_MIN_SCREEN_SPACING = 20;
+const PREVIEW_GRID_MAX_SCREEN_SPACING = 40;
+
+/**
+ * 选择一个离散的世界步长,让网格圆点在屏幕上保持可读密度。
+ * 背景位置仍使用 viewport 的屏幕平移量,因此切档不会破坏世界原点对齐。
+ */
+export function resolvePreviewGridStep(viewportScale: number): number {
+ if (!Number.isFinite(viewportScale) || viewportScale <= 0) {
+ return PREVIEW_GRID_BASE_STEP;
+ }
+
+ let step = PREVIEW_GRID_BASE_STEP;
+ let screenSpacing = step * viewportScale;
+ // 极大缩放下 step * scale 会溢出成 Infinity,而 Infinity / 2 仍是 Infinity:
+ // 下面两个 while 会因此永不收敛,这里先退回基础步长。
+ if (!Number.isFinite(screenSpacing)) {
+ return PREVIEW_GRID_BASE_STEP;
+ }
+ while (screenSpacing < PREVIEW_GRID_MIN_SCREEN_SPACING) {
+ step *= 2;
+ screenSpacing *= 2;
+ }
+ while (screenSpacing >= PREVIEW_GRID_MAX_SCREEN_SPACING) {
+ step /= 2;
+ screenSpacing /= 2;
+ }
+ return step;
+}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewRightPanGesture.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewRightPanGesture.ts
new file mode 100644
index 000000000..839a0ce78
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewRightPanGesture.ts
@@ -0,0 +1,92 @@
+import {
+ type CanvasViewport,
+ createPanDragState,
+ type DragState,
+ moveViewportFromPan,
+} from '@genarrative/image-canvas-core';
+
+import {
+ passedDragThresholdScreen,
+ type ScreenPoint,
+} from './previewDragThreshold';
+
+/**
+ * 右键按下到抬起之间的手势状态。右键同时承载节点菜单与视图平移:
+ * 只有越过拖动阈值的移动才判定为平移,且一旦越阈值就不再回到"点击开菜单"。
+ */
+export type PreviewRightPanGesture = {
+ pointerId: number;
+ /** 按下点(屏幕坐标),平移按"按下点全量 delta"计算。 */
+ start: ScreenPoint;
+ panning: boolean;
+ pan: Extract;
+};
+
+export function createPreviewRightPanGesture({
+ pointerId,
+ pointer,
+ viewport,
+}: {
+ pointerId: number;
+ pointer: ScreenPoint;
+ viewport: CanvasViewport;
+}): PreviewRightPanGesture {
+ return {
+ pointerId,
+ start: pointer,
+ panning: false,
+ pan: createPanDragState({ pointerId, pointer, viewport }),
+ };
+}
+
+/** 未越阈值时原样返回同一个手势,避免无意义的重复状态写入。 */
+export function movePreviewRightPanGesture(
+ gesture: PreviewRightPanGesture,
+ pointer: ScreenPoint,
+): PreviewRightPanGesture {
+ if (gesture.panning || !passedDragThresholdScreen(gesture.start, pointer)) {
+ return gesture;
+ }
+ return { ...gesture, panning: true };
+}
+
+export type PreviewRightPanRelease = {
+ /** 平移后的视口;未判定为平移时为 null,调用方不要改视口。 */
+ viewport: CanvasViewport | null;
+ /** 是否按"节点右键菜单"处理。 */
+ openNodeMenu: boolean;
+};
+
+export function resolvePreviewRightPanRelease({
+ gesture,
+ pointer,
+ isInsidePreview,
+}: {
+ gesture: PreviewRightPanGesture;
+ pointer: ScreenPoint;
+ isInsidePreview: boolean;
+}): PreviewRightPanRelease {
+ return {
+ viewport: gesture.panning
+ ? moveViewportFromPan(gesture.pan, pointer)
+ : null,
+ openNodeMenu: !gesture.panning && isInsidePreview,
+ };
+}
+
+/**
+ * 预览是否接管这一次 contextmenu。
+ * 平台差异:Windows / Linux 的 Chromium 与 Firefox 在**按下**时触发 contextmenu,
+ * macOS 在指针抬起之后触发;而原生菜单一旦弹出,页面之后收不到 pointermove / pointerup,
+ * 所以必须按下时就拦截,并把拦截窗口保留到下一次指针按下之前。
+ * 键盘菜单键不在此列:Chromium 实测上报 `button: -1`,只认 2 正好把它留给原有菜单路径。
+ */
+export function shouldInterceptRightContextMenu({
+ button,
+ withinRightPressSequence,
+}: {
+ button: number;
+ withinRightPressSequence: boolean;
+}): boolean {
+ return button === 2 && withinRightPressSequence;
+}
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts
index 9b79de2d7..f3b75413c 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts
@@ -21,6 +21,7 @@ import {
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { UITree } from '../../../../features/ui-editor/types/UITree';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
+import { passedDragThresholdScreen } from './previewDragThreshold';
type ViewportScale = { scale: number };
type GestureBase = {
@@ -47,17 +48,13 @@ type ActiveGesture =
ratioAxis: ResizeAxis | null;
});
-const DRAG_THRESHOLD_SCREEN_PX = 2;
-
function passedDragThreshold(
gesture: GestureBase,
event: ReactPointerEvent,
) {
- return (
- Math.hypot(
- event.clientX - gesture.startClientX,
- event.clientY - gesture.startClientY,
- ) >= DRAG_THRESHOLD_SCREEN_PX
+ return passedDragThresholdScreen(
+ { x: gesture.startClientX, y: gesture.startClientY },
+ { x: event.clientX, y: event.clientY },
);
}
@@ -176,22 +173,20 @@ function emitPreviewTransforms(
}
export function useNodeTransformInteraction({
- activeImageId,
canvas,
- logicalSize,
+ trees,
+ logicalSizes,
spaceHeld,
- tree,
keepChildrenUnchanged,
viewportRef,
onPreviewTransform,
previewRef,
selectedNodeId,
}: {
- activeImageId: UiEditorCanvasProjection['activeImageId'];
canvas: Pick;
- logicalSize: { width: number; height: number } | null;
+ trees: readonly UITree[];
+ logicalSizes: ReadonlyMap;
spaceHeld: boolean;
- tree: UITree | null;
keepChildrenUnchanged: boolean;
viewportRef: RefObject;
onPreviewTransform?: (
@@ -229,36 +224,32 @@ export function useNodeTransformInteraction({
return () => window.removeEventListener('blur', cancelGesture);
}, [cancelGesture]);
- useEffect(() => {
- const gesture = activeGestureRef.current;
- if (
- gesture &&
- (gesture.treeId !== activeImageId ||
- tree?.src_ui_design !== gesture.treeId)
- ) {
- cancelGesture();
- }
- }, [activeImageId, cancelGesture, tree]);
-
const acceptsGestureEvent = useCallback(
(event: ReactPointerEvent) => {
const gesture = activeGestureRef.current;
- return (
- gesture !== null &&
- gesture.pointerId === event.pointerId &&
- gesture.treeId === activeImageId
- );
+ return gesture !== null && gesture.pointerId === event.pointerId;
},
- [activeImageId],
+ [],
);
const onNodePointerDown = useCallback(
- (event: ReactPointerEvent, node: UiNode) => {
+ (
+ event: ReactPointerEvent,
+ node: UiNode,
+ treeId?: string,
+ ) => {
+ const tree = treeId
+ ? (trees.find((candidate) => candidate.src_ui_design === treeId) ??
+ null)
+ : null;
+ const logicalSize = treeId ? (logicalSizes.get(treeId) ?? null) : null;
if (
activeGestureRef.current !== null ||
event.button !== 0 ||
spaceHeld ||
- !activeImageId ||
+ !treeId ||
+ !tree ||
+ !logicalSize ||
node.id === tree?.root.id ||
!isFiniteTransform(node.layout.transform)
) {
@@ -275,7 +266,7 @@ export function useNodeTransformInteraction({
event.preventDefault();
activeGestureRef.current = {
kind: 'drag',
- treeId: activeImageId,
+ treeId,
nodeId: dragNode.id,
pointerId: event.pointerId,
target: event.currentTarget,
@@ -292,14 +283,7 @@ export function useNodeTransformInteraction({
),
};
},
- [
- activeImageId,
- keepChildrenUnchanged,
- logicalSize,
- spaceHeld,
- selectedNodeId,
- tree,
- ],
+ [keepChildrenUnchanged, logicalSizes, spaceHeld, selectedNodeId, trees],
);
const onNodePointerMove = useCallback(
@@ -309,6 +293,11 @@ export function useNodeTransformInteraction({
return;
}
event.stopPropagation();
+ const tree =
+ trees.find((candidate) => candidate.src_ui_design === gesture.treeId) ??
+ null;
+ const logicalSize = logicalSizes.get(gesture.treeId) ?? null;
+ if (!tree || !logicalSize) return;
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) {
@@ -346,8 +335,8 @@ export function useNodeTransformInteraction({
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
- logicalSize,
- tree,
+ logicalSizes,
+ trees,
viewportRef,
],
);
@@ -389,12 +378,18 @@ export function useNodeTransformInteraction({
event: ReactPointerEvent,
node: UiNode,
handle: ResizeHandle,
+ treeId?: string,
) => {
+ const tree = treeId
+ ? (trees.find((candidate) => candidate.src_ui_design === treeId) ??
+ null)
+ : null;
+ const logicalSize = treeId ? (logicalSizes.get(treeId) ?? null) : null;
if (
activeGestureRef.current !== null ||
event.button !== 0 ||
spaceHeld ||
- !activeImageId ||
+ !treeId ||
node.id === tree?.root.id ||
!tree ||
!logicalSize ||
@@ -424,7 +419,7 @@ export function useNodeTransformInteraction({
suppressNextNodeClickRef.current = false;
activeGestureRef.current = {
kind: 'resize',
- treeId: activeImageId,
+ treeId,
nodeId: node.id,
pointerId: event.pointerId,
target: event.currentTarget,
@@ -445,7 +440,7 @@ export function useNodeTransformInteraction({
),
};
},
- [activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree],
+ [keepChildrenUnchanged, logicalSizes, spaceHeld, trees],
);
const onNodeResizePointerMove = useCallback(
@@ -459,6 +454,11 @@ export function useNodeTransformInteraction({
return;
}
event.stopPropagation();
+ const tree =
+ trees.find((candidate) => candidate.src_ui_design === gesture.treeId) ??
+ null;
+ const logicalSize = logicalSizes.get(gesture.treeId) ?? null;
+ if (!tree || !logicalSize) return;
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) {
@@ -517,8 +517,8 @@ export function useNodeTransformInteraction({
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
- logicalSize,
- tree,
+ logicalSizes,
+ trees,
viewportRef,
],
);
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts
index 02c5b317a..2e370bdc7 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts
@@ -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':
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx
index 4696d76f9..fd5f9c776 100644
--- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx
@@ -1,5 +1,6 @@
-import { ChevronLeft, Redo2, Undo2 } from 'lucide-react';
+import { Redo2, Undo2, X } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
+import { createPortal } from 'react-dom';
import { ThemedModal } from '../../components/modal/ThemedModal';
import {
@@ -8,13 +9,16 @@ 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';
import { RecognitionOverview } from './components/RecognitionOverview';
import { SeparationOverview } from './components/SeparationOverview';
import { ToolNavigation } from './components/ToolNavigation';
+import {
+ UiEditorSaveResultModal,
+ type UiEditorSaveResultNotice,
+} from './components/UiEditorSaveResultModal';
import { WorkflowActionCard } from './components/WorkflowActionCard';
import { WorkflowCompletionModal } from './components/WorkflowCompletionModal';
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
@@ -69,8 +73,32 @@ export default function UiEditorPage({
const [saveWarningOpen, setSaveWarningOpen] = useState(false);
const [saveAfterReturn, setSaveAfterReturn] = useState(false);
const [generateAfterWarning, setGenerateAfterWarning] = useState(false);
- const [generateSuccess, setGenerateSuccess] = useState(null);
+ const [saveResultNotice, setSaveResultNotice] =
+ useState(null);
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
+ const [showFrame, setShowFrame] = useState(true);
+ const [showOriginImage, setShowOriginImage] = useState(true);
+ const [showComponent, setShowComponent] = useState(false);
+ const overview: ReactNode =
+ session.input.activeStep === 'structure-recognition' ? (
+ {
+ session.input.focusNode(treeId, nodeId);
+ session.input.highlightStatusField(nodeId, 'layout_status');
+ }}
+ />
+ ) : (
+ {
+ session.input.focusNode(treeId, nodeId);
+ session.input.highlightStatusField(nodeId, 'component_status');
+ }}
+ />
+ );
const saveDisabled =
session.save.isSaving ||
session.save.isGenerating ||
@@ -82,49 +110,67 @@ export default function UiEditorPage({
const historyUndo = session.history.undo;
const historyRedo = session.history.redo;
const selectedNodeId = session.input.selectedNodeId;
- const activeImageId = session.input.activeImageId;
const deleteNode = session.input.deleteNode;
- useEffect(() => {
- if (session.save.isDirty) setGenerateSuccess(null);
- }, [session.save.isDirty]);
-
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId,
- activeImageId,
deleteNode,
historyUndo,
historyRedo,
});
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
- }, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
+ }, [deleteNode, historyRedo, historyUndo, selectedNodeId]);
async function save(afterReturn = false) {
- if (await session.save.save()) {
+ const result = await session.save.save();
+ if (result.status === 'saved') {
if (afterReturn) {
setReturnConfirmOpen(false);
onBack?.();
+ } else {
+ setSaveResultNotice({ kind: 'saved' });
}
+ return;
}
+ if (afterReturn) return;
+ setSaveResultNotice({
+ kind: 'failure',
+ message: result.message,
+ retryLabel: '重试保存',
+ onRetry: () => void save(afterReturn),
+ });
}
async function saveAndGenerate() {
- setGenerateSuccess(null);
+ setSaveResultNotice(null);
const result = await session.save.saveAndGenerateCode();
- if (result) {
+ if (result.status === 'generated') {
setGenerateAfterWarning(false);
- setGenerateSuccess(`代码已生成:${result.relativePath}`);
+ setSaveResultNotice({
+ kind: 'generated',
+ relativePath: result.result.relativePath,
+ });
+ return;
}
+ setSaveResultNotice({
+ kind: 'failure',
+ message:
+ result.phase === 'generate'
+ ? `项目已保存,但代码生成失败:${result.message}`
+ : result.message,
+ retryLabel: '重试保存并生成',
+ onRetry: () => void saveAndGenerate(),
+ });
}
function requestSave(afterReturn = false) {
if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) {
return;
}
- setGenerateSuccess(null);
+ setSaveResultNotice(null);
setSaveAfterReturn(afterReturn);
setGenerateAfterWarning(false);
if (session.save.hasWarnings()) {
@@ -144,7 +190,7 @@ export default function UiEditorPage({
return;
}
setSaveAfterReturn(false);
- setGenerateSuccess(null);
+ setSaveResultNotice(null);
setGenerateAfterWarning(true);
if (session.save.hasWarnings()) {
setSaveWarningOpen(true);
@@ -161,20 +207,41 @@ export default function UiEditorPage({
onBack?.();
}
- return (
-
+ const editorContent = (
+
{resourceId ? (
-