Compare commits

...

26 Commits

Author SHA1 Message Date
k88936 1e992bcdf8 扩大未知 JavaScript 异常上报原则
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
明确所有未解决和非契约化 JS 异常必须继续抛出

说明用户 fallback 不得替代全局 error report 链路
2026-09-14 19:47:08 +08:00
k88936 8368aa262c 补充剪贴板异常上报约定
记录 UI fallback 后仍需抛出原始原生错误

明确未知异常必须进入 error report 链路
2026-09-14 19:43:08 +08:00
k88936 e9ddbf16da 重构预览网格步长计算
提取预览网格自适应步长到独立模块

补充缩放网格计算与背景样式测试

同步预览画布缩放交互文档
2026-09-14 19:40:13 +08:00
k88936 22ac1f4c0b 修复 UI 编辑器剪贴板写入权限
为主窗口补充 clipboard-manager 写文本权限

保留手动复制提示并继续抛出原始 Tauri 剪贴板错误
2026-09-14 19:39:34 +08:00
k88936 9910a0eec0 优化 UI 编辑器复制反馈布局
将复制失败提示与操作按钮分离排列
2026-09-14 19:06:15 +08:00
k88936 70981b9ca9 完善 UI 编辑器结果弹窗覆盖
增加普通保存成功与保存后生成失败的场景断言

验证路径弹窗和失败重试按钮的可见行为
2026-09-14 19:01:44 +08:00
k88936 8a0d5600b3 拆分 UI 编辑器路径复制按钮
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m27s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m5s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m15s
Project CI / Frontend tests (pull_request) Failing after 3m22s
Project CI / Repository checks (pull_request) Successful in 3m38s
Project CI / Native shell tests (pull_request) Successful in 6m42s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m12s
Project CI / Backend tests (pull_request) Successful in 8m24s
将剪贴板状态与写入逻辑提取为独立小组件

保持保存结果弹窗只负责结果布局与动作编排
2026-09-14 18:59:51 +08:00
k88936 184d88dbb8 修复多树预览受左侧选中图片限制
移除 activeImageId 对预览拖动和缩放手势的门控

按树 wrapper 提供几何上下文并允许任意树操作

保留 activeImageId 仅用于左侧图片与图片级检查
2026-09-14 18:59:51 +08:00
k88936 791794c0a6 记录 UI 编辑器保存结果弹窗约定
补充保存与代码生成结果弹窗及相对路径复制行为

记录复制失败、重试和保存并返回边界
2026-09-14 18:59:51 +08:00
k88936 9cfb47d945 补充 UI 编辑器保存结果测试
覆盖保存和生成成功弹窗与项目相对路径复制

覆盖保存失败、冲突和剪贴板失败反馈
2026-09-14 18:59:51 +08:00
k88936 27859d3e19 接入 UI 编辑器保存结果弹窗
让普通保存和保存并生成流程返回结构化结果

将成功与失败反馈接入独立弹窗并保留保存并返回直返行为
2026-09-14 18:59:51 +08:00
k88936 43f3780a38 新增 UI 编辑器保存结果弹窗
新增保存成功、代码生成成功和失败状态弹窗

提供项目相对路径复制按钮及复制失败提示
2026-09-14 18:59:51 +08:00
k88936 a1f9149c27 UI编辑器移除预览模式残留类型
完成三开关替代 render mode 的类型清理
2026-09-14 18:59:51 +08:00
k88936 f0bba18841 UI编辑器收口多树上下文菜单目标
预览菜单携带所属树 ID

避免非当前图片树的结构操作写错目标
2026-09-14 18:59:51 +08:00
k88936 f679cfa179 UI编辑器完善多树创建与文档契约
统一识别和合并结果的 createTree 流程

按选中节点所属树驱动 Inspector 编辑

补充多树预览与树级偏移权威文档
2026-09-14 18:59:50 +08:00
k88936 f68ffcfecd UI编辑器补充合并树偏移构造
为合并产出的节点补齐 NodeOffset
2026-09-14 18:59:50 +08:00
k88936 ec94a4fe00 UI编辑器重构多树预览与独立开关
预览画布同时渲染全部界面树并支持树级拖动

拆分显示框线、原图和组件开关

预览节点选择与左侧当前图片解耦
2026-09-14 18:59:50 +08:00
k88936 46f8592375 UI编辑器补充树级偏移模型
新增 Node offset min/max 数据结构

统一新树默认横向布局入口

增加树级偏移状态更新操作与实施计划
2026-09-14 18:59:50 +08:00
k88936 cdf2882302 补齐自动切分概览字体属性
向 SeparationOverview 传入 fonts 数据

修复 UI 编辑器 TS2741 类型错误
2026-09-14 18:59:50 +08:00
k88936 d156113e71 修复 UI 编辑器待办循环高亮动画
修复受控树选中回调清除 Inspector 高亮的问题

循环游标同时使用界面树与节点 ID

补充多节点循环和动画重启回归测试
2026-09-14 18:59:50 +08:00
k88936 a8f2ac17be 注释掉 InspectorSidebar 中的 LayoutEditor 组件以暂时禁用布局编辑功能 2026-09-14 18:59:50 +08:00
k88936 43ac9a5761 补充 UI 编辑器交互回归测试
从实际节点事件目标验证删除快捷键

验证 portal 右键菜单启用后会执行操作并关闭
2026-09-14 18:59:50 +08:00
k88936 e992e0b35c 压缩 UI 编辑器概览布局
将识别概览指标改为四列横向排列

将素材切分概览改为四列网格以减少纵向高度
2026-09-14 18:59:50 +08:00
k88936 e389b54a3a 修复 UI 预览画布交互
为无限视口添加跟随平移和缩放的点阵背景

恢复画布快捷缩放与初始适配比例控制

保留节点平移选择并屏蔽浏览器原生拖拽选择
2026-09-14 18:59:50 +08:00
k88936 84c780a6f8 重构 UI 编辑器全屏外壳
通过 portal 将资源编辑器挂载到 body 并覆盖标题栏以下区域

移除外层 FocusTrap,保留内部确认对话框的模态行为

统一左右浮动面板的顶部与底部留白
2026-09-14 18:59:50 +08:00
k88936 03f900fbe5 布局重构:
- 预览改为全区域背景层。
  - 左右面板、顶部流程条改为覆盖层,可遮挡预览。
  - 左右面板开合状态仅保存在当前 React 会话。
  - 增加左右面板“收起/展开”入口。
  - 编辑器关闭入口改为单独的 ×,继续通过 onBack 注入关闭逻辑。
  - 保留全局顶部栏与现有快捷键、业务状态、保存流程。
  - 底层页面仍由现有父级路由控制,不新增 URL 状态。
2026-09-14 18:59:50 +08:00
32 changed files with 1405 additions and 384 deletions
@@ -1,11 +1,12 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main", "identifier": "main",
"description": "AI 游戏创作主窗口允许读系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。", "description": "AI 游戏创作主窗口允许读系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"], "windows": ["client"],
"permissions": [ "permissions": [
"clipboard-manager:allow-read-image", "clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text", "clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:image:allow-rgba", "core:image:allow-rgba",
"core:image:allow-size", "core:image:allow-size",
"core:resources:allow-close", "core:resources:allow-close",
@@ -172,6 +172,7 @@ mod materialize {
use crate::ui_editor::layout::node::{ use crate::ui_editor::layout::node::{
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus, Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
}; };
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::state::{State, UITree}; use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId}; use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
@@ -302,6 +303,7 @@ mod materialize {
component: None, component: None,
children_display_mode: ChildrenDisplayMode::Exclusive, children_display_mode: ChildrenDisplayMode::Exclusive,
children: members.into_iter().map(|member| member.node).collect(), children: members.into_iter().map(|member| member.node).collect(),
offset: NodeOffset::default(),
}, },
priority, priority,
src_ui_design, src_ui_design,
@@ -9,6 +9,7 @@ use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::dimension::UIRect; use crate::ui_editor::layout::dimension::UIRect;
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus}; use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::resource::ui_design_image::UIDesignImage; use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::{State, UITree}; use crate::ui_editor::state::{State, UITree};
@@ -322,6 +323,7 @@ fn convert_node(
component: source.component.clone().into_option(), component: source.component.clone().into_option(),
children_display_mode: ChildrenDisplayMode::Stack, children_display_mode: ChildrenDisplayMode::Stack,
children, children,
offset: NodeOffset::default(),
}) })
} }
@@ -858,6 +860,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
component: recognition_root.component.into_option(), component: recognition_root.component.into_option(),
children_display_mode: ChildrenDisplayMode::Stack, children_display_mode: ChildrenDisplayMode::Stack,
children, children,
offset: NodeOffset::default(),
}; };
// Tree identity and root identity are assigned by Rust, never chosen by the model. // Tree identity and root identity are assigned by Rust, never chosen by the model.
ui_trees.push(UITree { ui_trees.push(UITree {
@@ -2,4 +2,5 @@ pub mod children_display_mode;
pub mod control_layout; pub mod control_layout;
pub mod dimension; pub mod dimension;
pub mod node; pub mod node;
pub mod offset;
pub mod transform; pub mod transform;
@@ -1,6 +1,7 @@
use crate::ui_editor::component::Component; use crate::ui_editor::component::Component;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::utils::NodeId; use crate::ui_editor::utils::NodeId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
@@ -14,6 +15,7 @@ pub struct Node {
pub component: Option<Component>, pub component: Option<Component>,
pub children_display_mode: ChildrenDisplayMode, pub children_display_mode: ChildrenDisplayMode,
pub children: Vec<Node>, pub children: Vec<Node>,
pub offset: NodeOffset,
} }
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct NodeOffset {
pub min: [f32; 2],
pub max: [f32; 2],
}
impl Default for NodeOffset {
fn default() -> Self {
Self {
min: [0.0, 0.0],
max: [0.0, 0.0],
}
}
}
@@ -1,4 +1,5 @@
import type { Node as UiNode } from './types/Node'; import type { Node as UiNode } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata'; import type { NodeMetadata } from './types/NodeMetadata';
import type { StageStatus } from './types/StageStatus'; import type { StageStatus } from './types/StageStatus';
import type { UIDesignImageId } from './types/UIDesignImageId'; import type { UIDesignImageId } from './types/UIDesignImageId';
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
node: UiNode; node: UiNode;
}; };
export type UiTreeNodeCursor = {
treeId: UIDesignImageId;
nodeId: NodeId;
};
export type StageStatusOverview = { export type StageStatusOverview = {
total: number; total: number;
needsAttention: number; needsAttention: number;
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
export function getNextUiTreeNodeTarget( export function getNextUiTreeNodeTarget(
targets: UiTreeNodeTarget[], targets: UiTreeNodeTarget[],
previousNodeId: string | null, previous: string | UiTreeNodeCursor | null,
): UiTreeNodeTarget | null { ): UiTreeNodeTarget | null {
if (targets.length === 0) return null; if (targets.length === 0) return null;
const previousIndex = targets.findIndex( const previousIndex =
({ node }) => node.id === previousNodeId, typeof previous === 'string'
); ? targets.findIndex(({ node }) => node.id === previous)
: previous
? targets.findIndex(
({ treeId, node }) =>
treeId === previous.treeId && node.id === previous.nodeId,
)
: -1;
return targets[(previousIndex + 1) % targets.length] ?? null; return targets[(previousIndex + 1) % targets.length] ?? null;
} }
export function getNextMatchingUiTreeNodeTarget( export function getNextMatchingUiTreeNodeTarget(
uiTrees: UITree[], uiTrees: UITree[],
previousNodeId: string | null, previous: string | UiTreeNodeCursor | null,
matches: (target: UiTreeNodeTarget) => boolean, matches: (target: UiTreeNodeTarget) => boolean,
): UiTreeNodeTarget | null { ): UiTreeNodeTarget | null {
return getNextUiTreeNodeTarget( return getNextUiTreeNodeTarget(
collectUiTreeNodeTargets(uiTrees).filter(matches), collectUiTreeNodeTargets(uiTrees).filter(matches),
previousNodeId, previous,
); );
} }
@@ -4,5 +4,6 @@ import type { Component } from "./Component";
import type { ControlLayout } from "./ControlLayout"; import type { ControlLayout } from "./ControlLayout";
import type { NodeId } from "./NodeId"; import type { NodeId } from "./NodeId";
import type { NodeMetadata } from "./NodeMetadata"; import type { NodeMetadata } from "./NodeMetadata";
import type { NodeOffset } from "./NodeOffset";
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, }; export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, offset: NodeOffset };
@@ -0,0 +1,4 @@
export type NodeOffset = {
min: [number, number];
max: [number, number];
};
@@ -17,6 +17,7 @@ import type { FontAssetId } from './types/FontAssetId';
import type { Node } from './types/Node'; import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId'; import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata'; import type { NodeMetadata } from './types/NodeMetadata';
import type { NodeOffset } from './types/NodeOffset';
import type { SpriteAsset } from './types/SpriteAsset'; import type { SpriteAsset } from './types/SpriteAsset';
import type { SpriteAssetId } from './types/SpriteAssetId'; import type { SpriteAssetId } from './types/SpriteAssetId';
import type { SpriteBorder } from './types/SpriteBorder'; import type { SpriteBorder } from './types/SpriteBorder';
@@ -34,6 +35,7 @@ export const EMPTY_UI_EDITOR_STATE: State = {
}; };
const MAX_HISTORY_LENGTH = 100; const MAX_HISTORY_LENGTH = 100;
export const UI_TREE_PADDING = 48;
export type UiEditorOperationFailureReason = export type UiEditorOperationFailureReason =
| 'locked' | 'locked'
@@ -156,6 +158,7 @@ function createPageRoot(state: State): Node {
component: null, component: null,
children_display_mode: 'Stack', children_display_mode: 'Stack',
children: [], children: [],
offset: { min: [0, 0], max: [0, 0] },
}; };
} }
@@ -187,9 +190,53 @@ function createHumanNode(state: State): Node {
component: null, component: null,
children_display_mode: 'Stack', children_display_mode: 'Stack',
children: [], children: [],
offset: { min: [0, 0], max: [0, 0] },
}; };
} }
function treeSize(state: State, treeId: UIDesignImageId): [number, number] {
const image = state.ui_design_images[treeId];
if (
!image ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
throw new Error(`界面图 ${treeId} 缺少合法尺寸`);
}
return [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
];
}
function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset {
const [width, height] = treeSize(state, treeId);
const existing = state.ui_trees.filter(
(tree) => tree.src_ui_design !== treeId,
);
if (existing.length === 0) return { min: [0, 0], max: [width, height] };
const maxX = Math.max(
...existing.map(
(tree) =>
tree.root.offset.min[0] + treeSize(state, tree.src_ui_design)[0],
),
);
const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1]));
return {
min: [maxX + UI_TREE_PADDING, minY],
max: [maxX + UI_TREE_PADDING + width, minY + height],
};
}
export function createTree(
state: State,
treeId: UIDesignImageId,
root = createPageRoot(state),
) {
root.offset = deriveTreeOffset(state, treeId);
return { src_ui_design: treeId, root };
}
function synchronizeDesignImageTrees(state: State): void { function synchronizeDesignImageTrees(state: State): void {
const imageIds = new Set(Object.keys(state.ui_design_images)); const imageIds = new Set(Object.keys(state.ui_design_images));
state.ui_trees = state.ui_trees.filter((tree) => state.ui_trees = state.ui_trees.filter((tree) =>
@@ -197,7 +244,7 @@ function synchronizeDesignImageTrees(state: State): void {
); );
for (const [id] of Object.entries(state.ui_design_images)) { for (const [id] of Object.entries(state.ui_design_images)) {
if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) { if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) {
state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) }); state.ui_trees.push(createTree(state, id));
} }
} }
} }
@@ -951,6 +998,34 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
[commit, guard], [commit, guard],
); );
const setTreeOffset = useCallback(
(
treeId: UIDesignImageId,
min: [number, number],
): UiEditorOperationResult => {
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);
return { ok: true, value: undefined };
},
[commit, guard],
);
const insertNodeAfter = useCallback( const insertNodeAfter = useCallback(
( (
treeId: UIDesignImageId, treeId: UIDesignImageId,
@@ -1464,6 +1539,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
setSpriteBorder, setSpriteBorder,
insertNode, insertNode,
insertNodeAfter, insertNodeAfter,
setTreeOffset,
deleteNode, deleteNode,
setNodeTransform, setNodeTransform,
setNodeLayout, setNodeLayout,
@@ -70,6 +70,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
component: null, component: null,
children_display_mode: 'Stack', children_display_mode: 'Stack',
children: uiTrees.map((tree) => tree.root), children: uiTrees.map((tree) => tree.root),
offset: { min: [0, 0], max: [0, 0] },
}; };
}, [uiTrees]); }, [uiTrees]);
@@ -83,8 +84,14 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null} treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
isNodePreviewVisible={input.isNodePreviewVisible} isNodePreviewVisible={input.isNodePreviewVisible}
onSelectNode={(treeId, nodeId) => { onSelectNode={(treeId, nodeId) => {
input.selectDesignImage(treeId); // react-arborist emits `onSelect` when its controlled `selection`
input.selectNode(nodeId); // 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.selectNode(nodeId);
}} }}
onToggleNodeVisibility={(nodeId) => onToggleNodeVisibility={(nodeId) =>
input.toggleNodePreviewVisibility(nodeId) input.toggleNodePreviewVisibility(nodeId)
@@ -284,7 +284,7 @@ function NodeInspector({
onMetadataChange, onMetadataChange,
highlightedStatusField, highlightedStatusField,
onTransformChange, onTransformChange,
onLayoutChange, // onLayoutChange,
sprites, sprites,
previewUrls, previewUrls,
fonts, fonts,
@@ -483,11 +483,11 @@ function NodeInspector({
readOnly={transformReadOnly || isReadOnly} readOnly={transformReadOnly || isReadOnly}
onChange={onTransformChange} onChange={onTransformChange}
/> />
<LayoutEditor {/*<LayoutEditor*/}
node={node} {/* node={node}*/}
readOnly={isReadOnly} {/* readOnly={isReadOnly}*/}
onChange={onLayoutChange} {/* onChange={onLayoutChange}*/}
/> {/*/>*/}
<ComponentPanel <ComponentPanel
key={node.id} key={node.id}
component={node.component} component={node.component}
@@ -511,6 +511,7 @@ function NodeInspector({
); );
} }
// eslint-disable-next-line unused-imports/no-unused-vars
function LayoutEditor({ function LayoutEditor({
node, node,
readOnly, readOnly,
@@ -826,11 +827,26 @@ function NodeStageSelect({
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review'; const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
useEffect(() => { useEffect(() => {
if (!highlight) return; const statusRow = statusRowRef.current;
statusRowRef.current?.scrollIntoView({ if (!highlight || !statusRow) return;
statusRow.scrollIntoView({
block: 'nearest', block: 'nearest',
behavior: 'smooth', 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()) {
animation.cancel();
animation.play();
}
}
}, [highlight]); }, [highlight]);
return ( return (
@@ -44,7 +44,7 @@ export function RecognitionOverview({
Overview Overview
</span> </span>
<h2 className="m-0 text-sm font-semibold"></h2> <h2 className="m-0 text-sm font-semibold"></h2>
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-4 gap-2">
<OverviewValue label="已识别 Node" value={overview.total} /> <OverviewValue label="已识别 Node" value={overview.total} />
<OverviewAction <OverviewAction
label="待用户检查" label="待用户检查"
@@ -47,7 +47,7 @@ export function SeparationOverview({
Overview Overview
</span> </span>
<h2 className="m-0 text-sm font-semibold"></h2> <h2 className="m-0 text-sm font-semibold"></h2>
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-4 gap-2">
<OverviewValue <OverviewValue
label="需要切分素材的组件" label="需要切分素材的组件"
value={overview.componentsNeedingAssets} value={overview.componentsNeedingAssets}
@@ -0,0 +1,44 @@
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 (error) {
setCopyState('failed');
throw error;
}
}
return (
<>
{copyState === 'failed' ? (
<p className="mt-2 text-xs text-red-600" role="alert">
</p>
) : null}
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={() => void copyPath()}
>
<Copy size={14} aria-hidden="true" />
{copyState === 'copied' ? '已复制' : '复制路径'}
</button>
</>
);
}
@@ -0,0 +1,140 @@
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 (
<ThemedModal
open={notice !== null}
onClose={onClose}
ariaLabel={ariaLabelForNotice(notice)}
panelClassName="w-[min(460px,calc(100vw-2rem))] rounded-2xl p-5"
>
{notice?.kind === 'generated' ? (
<GeneratedNotice relativePath={notice.relativePath} onClose={onClose} />
) : notice?.kind === 'saved' ? (
<SimpleNotice title="保存成功" onClose={onClose} />
) : notice?.kind === 'failure' ? (
<FailureNotice notice={notice} onClose={onClose} />
) : null}
</ThemedModal>
);
}
function GeneratedNotice({
relativePath,
onClose,
}: {
relativePath: string;
onClose: () => void;
}) {
return (
<>
<h2 className="m-0 text-base font-semibold"></h2>
<p className="mt-3 text-sm text-(--platform-text-soft)"></p>
<code className="mt-2 block select-text break-all rounded-lg bg-black/5 px-3 py-2 text-xs leading-5">
{relativePath}
</code>
<div className="mt-5">
<UiEditorCopyPathButton relativePath={relativePath} />
<div className="mt-3 flex justify-end">
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={onClose}
>
</button>
</div>
</div>
</>
);
}
function SimpleNotice({
title,
onClose,
}: {
title: string;
onClose: () => void;
}) {
return (
<>
<h2 className="m-0 text-base font-semibold">{title}</h2>
<div className="mt-5 flex justify-end">
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={onClose}
>
</button>
</div>
</>
);
}
function FailureNotice({
notice,
onClose,
}: {
notice: Extract<UiEditorSaveResultNotice, { kind: 'failure' }>;
onClose: () => void;
}) {
return (
<>
<h2 className="m-0 text-base font-semibold"></h2>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-red-700">
{notice.message}
</p>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={onClose}
>
</button>
{notice.onRetry ? (
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={() => {
onClose();
notice.onRetry?.();
}}
>
{notice.retryLabel ?? '重试'}
</button>
) : null}
</div>
</>
);
}
function ariaLabelForNotice(notice: UiEditorSaveResultNotice | null) {
if (notice?.kind === 'generated') return '代码已生成';
if (notice?.kind === 'saved') return '保存成功';
if (notice?.kind === 'failure') return '操作失败';
return '保存结果';
}
File diff suppressed because it is too large Load Diff
@@ -20,17 +20,17 @@ import { resolveExclusiveVisibleChildId } from './exclusiveVisibility';
export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry'; export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry';
export type UiEditorRenderMode = 'editor-overlay' | 'final-preview';
type NodePointerDown = ( type NodePointerDown = (
event: ReactPointerEvent<HTMLDivElement>, event: ReactPointerEvent<HTMLDivElement>,
node: UiNode, node: UiNode,
treeId?: string,
) => void; ) => void;
type UiTreeRendererProps = { type UiTreeRendererProps = {
tree: UITree | null; tree: UITree | null;
renderMode: UiEditorRenderMode; treeId?: string;
showFrame: boolean; showFrame: boolean;
showComponent: boolean;
hiddenNodeIds: ReadonlySet<NodeId>; hiddenNodeIds: ReadonlySet<NodeId>;
previewTransforms?: ReadonlyMap<NodeId, UiNode['layout']['transform']>; previewTransforms?: ReadonlyMap<NodeId, UiNode['layout']['transform']>;
selectedNodeId: NodeId | null; selectedNodeId: NodeId | null;
@@ -41,6 +41,7 @@ type UiTreeRendererProps = {
event: ReactMouseEvent<HTMLDivElement>, event: ReactMouseEvent<HTMLDivElement>,
node: UiNode, node: UiNode,
isPageRoot: boolean, isPageRoot: boolean,
treeId?: string,
) => void; ) => void;
onNodePointerDown: NodePointerDown; onNodePointerDown: NodePointerDown;
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void; onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
@@ -50,6 +51,7 @@ type UiTreeRendererProps = {
event: ReactPointerEvent<HTMLDivElement>, event: ReactPointerEvent<HTMLDivElement>,
node: UiNode, node: UiNode,
handle: ResizeHandle, handle: ResizeHandle,
treeId?: string,
) => void; ) => void;
onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void; onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void; onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
@@ -81,10 +83,11 @@ const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap<
function RenderNode({ function RenderNode({
node, node,
treeId,
isRoot, isRoot,
parentContainer, parentContainer,
renderMode,
showFrame, showFrame,
showComponent,
hiddenNodeIds, hiddenNodeIds,
previewTransforms, previewTransforms,
selectedNodeId, selectedNodeId,
@@ -124,8 +127,8 @@ function RenderNode({
return null; return null;
} }
const isEditorOverlay = renderMode === 'editor-overlay'; const isSelected = selectedNodeId === node.id;
const isFrameVisible = isEditorOverlay || showFrame; const isFrameVisible = showFrame || isSelected;
const receivesPointerGesture = parentContainer === undefined; const receivesPointerGesture = parentContainer === undefined;
const hasDirectPointerGesture = receivesPointerGesture && !isRoot; const hasDirectPointerGesture = receivesPointerGesture && !isRoot;
const exclusiveVisibleChildId = const exclusiveVisibleChildId =
@@ -170,15 +173,14 @@ function RenderNode({
} }
} }
onContextMenu={(event) => { onContextMenu={(event) => {
if (!isEditorOverlay) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onSelectNode(node.id); onSelectNode(node.id);
onNodeContextMenu(event, node, Boolean(isRoot)); onNodeContextMenu(event, node, Boolean(isRoot), treeId);
}} }}
onPointerDown={ onPointerDown={
receivesPointerGesture receivesPointerGesture
? (event) => onNodePointerDown(event, node) ? (event) => onNodePointerDown(event, node, treeId)
: undefined : undefined
} }
onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined} onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined}
@@ -191,10 +193,10 @@ function RenderNode({
{node.metadata.name} {node.metadata.name}
</span> </span>
) : null} ) : null}
{node.component ? ( {showComponent && node.component ? (
<ComponentView component={node.component} resources={resources} /> <ComponentView component={node.component} resources={resources} />
) : null} ) : null}
{renderMode === 'final-preview' && selectedNodeId === node.id ? ( {isSelected ? (
<ExclusiveChildrenTabs <ExclusiveChildrenTabs
parent={node} parent={node}
isChildVisible={(nodeId) => nodeId === exclusiveVisibleChildId} isChildVisible={(nodeId) => nodeId === exclusiveVisibleChildId}
@@ -207,13 +209,14 @@ function RenderNode({
<RenderNode <RenderNode
key={child.id} key={child.id}
node={child} node={child}
treeId={treeId}
parentContainer={ parentContainer={
isContainer(node.layout.container) isContainer(node.layout.container)
? node.layout.container ? node.layout.container
: undefined : undefined
} }
renderMode={renderMode}
showFrame={showFrame} showFrame={showFrame}
showComponent={showComponent}
hiddenNodeIds={hiddenNodeIds} hiddenNodeIds={hiddenNodeIds}
previewTransforms={activePreviewTransforms} previewTransforms={activePreviewTransforms}
selectedNodeId={selectedNodeId} selectedNodeId={selectedNodeId}
@@ -254,7 +257,7 @@ function RenderNode({
touchAction: 'none', touchAction: 'none',
}} }}
onPointerDown={(event) => onPointerDown={(event) =>
onNodeResizePointerDown(event, node, handle.id) onNodeResizePointerDown(event, node, handle.id, treeId)
} }
onPointerMove={onNodeResizePointerMove} onPointerMove={onNodeResizePointerMove}
onPointerUp={onNodeResizePointerUp} onPointerUp={onNodeResizePointerUp}
@@ -0,0 +1,26 @@
/** 背景网格的基础世界步长;缩放时按 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;
while (screenSpacing < PREVIEW_GRID_MIN_SCREEN_SPACING) {
step *= 2;
screenSpacing *= 2;
}
while (screenSpacing >= PREVIEW_GRID_MAX_SCREEN_SPACING) {
step /= 2;
screenSpacing /= 2;
}
return step;
}
@@ -176,22 +176,20 @@ function emitPreviewTransforms(
} }
export function useNodeTransformInteraction({ export function useNodeTransformInteraction({
activeImageId,
canvas, canvas,
logicalSize, trees,
logicalSizes,
spaceHeld, spaceHeld,
tree,
keepChildrenUnchanged, keepChildrenUnchanged,
viewportRef, viewportRef,
onPreviewTransform, onPreviewTransform,
previewRef, previewRef,
selectedNodeId, selectedNodeId,
}: { }: {
activeImageId: UiEditorCanvasProjection['activeImageId'];
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>; canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
logicalSize: { width: number; height: number } | null; trees: readonly UITree[];
logicalSizes: ReadonlyMap<string, { width: number; height: number }>;
spaceHeld: boolean; spaceHeld: boolean;
tree: UITree | null;
keepChildrenUnchanged: boolean; keepChildrenUnchanged: boolean;
viewportRef: RefObject<ViewportScale>; viewportRef: RefObject<ViewportScale>;
onPreviewTransform?: ( onPreviewTransform?: (
@@ -229,36 +227,30 @@ export function useNodeTransformInteraction({
return () => window.removeEventListener('blur', cancelGesture); return () => window.removeEventListener('blur', cancelGesture);
}, [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( const acceptsGestureEvent = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => { (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = activeGestureRef.current; const gesture = activeGestureRef.current;
return ( return gesture !== null && gesture.pointerId === event.pointerId;
gesture !== null &&
gesture.pointerId === event.pointerId &&
gesture.treeId === activeImageId
);
}, },
[activeImageId], [],
); );
const onNodePointerDown = useCallback( const onNodePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, node: UiNode) => { (
event: ReactPointerEvent<HTMLDivElement>,
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 ( if (
activeGestureRef.current !== null || activeGestureRef.current !== null ||
event.button !== 0 || event.button !== 0 ||
spaceHeld || spaceHeld ||
!activeImageId || !treeId ||
node.id === tree?.root.id || node.id === tree?.root.id ||
!isFiniteTransform(node.layout.transform) !isFiniteTransform(node.layout.transform)
) { ) {
@@ -275,7 +267,7 @@ export function useNodeTransformInteraction({
event.preventDefault(); event.preventDefault();
activeGestureRef.current = { activeGestureRef.current = {
kind: 'drag', kind: 'drag',
treeId: activeImageId, treeId,
nodeId: dragNode.id, nodeId: dragNode.id,
pointerId: event.pointerId, pointerId: event.pointerId,
target: event.currentTarget, target: event.currentTarget,
@@ -292,14 +284,7 @@ export function useNodeTransformInteraction({
), ),
}; };
}, },
[ [keepChildrenUnchanged, logicalSizes, spaceHeld, selectedNodeId, trees],
activeImageId,
keepChildrenUnchanged,
logicalSize,
spaceHeld,
selectedNodeId,
tree,
],
); );
const onNodePointerMove = useCallback( const onNodePointerMove = useCallback(
@@ -309,6 +294,11 @@ export function useNodeTransformInteraction({
return; return;
} }
event.stopPropagation(); 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; if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
const scale = viewportRef.current?.scale; const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) { if (!Number.isFinite(scale) || scale <= 0) {
@@ -346,8 +336,8 @@ export function useNodeTransformInteraction({
acceptsGestureEvent, acceptsGestureEvent,
cancelGesture, cancelGesture,
keepChildrenUnchanged, keepChildrenUnchanged,
logicalSize, logicalSizes,
tree, trees,
viewportRef, viewportRef,
], ],
); );
@@ -389,12 +379,18 @@ export function useNodeTransformInteraction({
event: ReactPointerEvent<HTMLDivElement>, event: ReactPointerEvent<HTMLDivElement>,
node: UiNode, node: UiNode,
handle: ResizeHandle, 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 ( if (
activeGestureRef.current !== null || activeGestureRef.current !== null ||
event.button !== 0 || event.button !== 0 ||
spaceHeld || spaceHeld ||
!activeImageId || !treeId ||
node.id === tree?.root.id || node.id === tree?.root.id ||
!tree || !tree ||
!logicalSize || !logicalSize ||
@@ -424,7 +420,7 @@ export function useNodeTransformInteraction({
suppressNextNodeClickRef.current = false; suppressNextNodeClickRef.current = false;
activeGestureRef.current = { activeGestureRef.current = {
kind: 'resize', kind: 'resize',
treeId: activeImageId, treeId,
nodeId: node.id, nodeId: node.id,
pointerId: event.pointerId, pointerId: event.pointerId,
target: event.currentTarget, target: event.currentTarget,
@@ -445,7 +441,7 @@ export function useNodeTransformInteraction({
), ),
}; };
}, },
[activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree], [keepChildrenUnchanged, logicalSizes, spaceHeld, trees],
); );
const onNodeResizePointerMove = useCallback( const onNodeResizePointerMove = useCallback(
@@ -459,6 +455,11 @@ export function useNodeTransformInteraction({
return; return;
} }
event.stopPropagation(); 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; if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
const scale = viewportRef.current?.scale; const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) { if (!Number.isFinite(scale) || scale <= 0) {
@@ -517,8 +518,8 @@ export function useNodeTransformInteraction({
acceptsGestureEvent, acceptsGestureEvent,
cancelGesture, cancelGesture,
keepChildrenUnchanged, keepChildrenUnchanged,
logicalSize, logicalSizes,
tree, trees,
viewportRef, viewportRef,
], ],
); );
@@ -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 { type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { ThemedModal } from '../../components/modal/ThemedModal'; import { ThemedModal } from '../../components/modal/ThemedModal';
import { import {
@@ -15,6 +16,10 @@ import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
import { RecognitionOverview } from './components/RecognitionOverview'; import { RecognitionOverview } from './components/RecognitionOverview';
import { SeparationOverview } from './components/SeparationOverview'; import { SeparationOverview } from './components/SeparationOverview';
import { ToolNavigation } from './components/ToolNavigation'; import { ToolNavigation } from './components/ToolNavigation';
import {
UiEditorSaveResultModal,
type UiEditorSaveResultNotice,
} from './components/UiEditorSaveResultModal';
import { WorkflowActionCard } from './components/WorkflowActionCard'; import { WorkflowActionCard } from './components/WorkflowActionCard';
import { WorkflowCompletionModal } from './components/WorkflowCompletionModal'; import { WorkflowCompletionModal } from './components/WorkflowCompletionModal';
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model'; import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
@@ -69,8 +74,38 @@ export default function UiEditorPage({
const [saveWarningOpen, setSaveWarningOpen] = useState(false); const [saveWarningOpen, setSaveWarningOpen] = useState(false);
const [saveAfterReturn, setSaveAfterReturn] = useState(false); const [saveAfterReturn, setSaveAfterReturn] = useState(false);
const [generateAfterWarning, setGenerateAfterWarning] = useState(false); const [generateAfterWarning, setGenerateAfterWarning] = useState(false);
const [generateSuccess, setGenerateSuccess] = useState<string | null>(null); const [saveResultNotice, setSaveResultNotice] =
useState<UiEditorSaveResultNotice | null>(null);
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
const [showFrame, setShowFrame] = useState(true);
const [showOriginImage, setShowOriginImage] = useState(true);
const [showComponent, setShowComponent] = useState(false);
const overview =
session.input.activeStep === 'structure-recognition' ? (
<RecognitionOverview
uiTrees={session.input.uiTrees}
onFocusStatusNode={(treeId, nodeId) => {
session.input.focusNode(treeId, nodeId);
session.input.highlightStatusField(nodeId, 'layout_status');
}}
/>
) : session.input.activeStep === 'asset-separation' ? (
<SeparationOverview
uiTrees={session.input.uiTrees}
sprites={session.input.sprites}
fonts={session.input.fonts}
onFocusStatusNode={(treeId, nodeId) => {
session.input.focusNode(treeId, nodeId);
session.input.highlightStatusField(nodeId, 'component_status');
}}
/>
) : (
<ImportOverview
images={session.input.images}
sprites={session.input.sprites}
uiTrees={session.input.uiTrees}
/>
);
const saveDisabled = const saveDisabled =
session.save.isSaving || session.save.isSaving ||
session.save.isGenerating || session.save.isGenerating ||
@@ -85,10 +120,6 @@ export default function UiEditorPage({
const activeImageId = session.input.activeImageId; const activeImageId = session.input.activeImageId;
const deleteNode = session.input.deleteNode; const deleteNode = session.input.deleteNode;
useEffect(() => {
if (session.save.isDirty) setGenerateSuccess(null);
}, [session.save.isDirty]);
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => const onKeyDown = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, { handleUiEditorKeyDown(event, {
@@ -103,28 +134,51 @@ export default function UiEditorPage({
}, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]); }, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
async function save(afterReturn = false) { async function save(afterReturn = false) {
if (await session.save.save()) { const result = await session.save.save();
if (result.status === 'saved') {
if (afterReturn) { if (afterReturn) {
setReturnConfirmOpen(false); setReturnConfirmOpen(false);
onBack?.(); onBack?.();
} else {
setSaveResultNotice({ kind: 'saved' });
} }
return;
} }
setSaveResultNotice({
kind: 'failure',
message: result.message,
retryLabel: '重试保存',
onRetry: () => void save(afterReturn),
});
} }
async function saveAndGenerate() { async function saveAndGenerate() {
setGenerateSuccess(null); setSaveResultNotice(null);
const result = await session.save.saveAndGenerateCode(); const result = await session.save.saveAndGenerateCode();
if (result) { if (result.status === 'generated') {
setGenerateAfterWarning(false); 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) { function requestSave(afterReturn = false) {
if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) { if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) {
return; return;
} }
setGenerateSuccess(null); setSaveResultNotice(null);
setSaveAfterReturn(afterReturn); setSaveAfterReturn(afterReturn);
setGenerateAfterWarning(false); setGenerateAfterWarning(false);
if (session.save.hasWarnings()) { if (session.save.hasWarnings()) {
@@ -144,7 +198,7 @@ export default function UiEditorPage({
return; return;
} }
setSaveAfterReturn(false); setSaveAfterReturn(false);
setGenerateSuccess(null); setSaveResultNotice(null);
setGenerateAfterWarning(true); setGenerateAfterWarning(true);
if (session.save.hasWarnings()) { if (session.save.hasWarnings()) {
setSaveWarningOpen(true); setSaveWarningOpen(true);
@@ -161,20 +215,41 @@ export default function UiEditorPage({
onBack?.(); onBack?.();
} }
return ( const editorContent = (
<main className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--platform-body-fill) text-(--platform-text-strong)"> <main className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--platform-body-fill) text-(--platform-text-strong)">
{resourceId ? ( {resourceId ? (
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-4 py-2"> <header className="absolute left-1/2 top-4 z-30 flex w-[min(1000px,calc(100%_-_2rem))] -translate-x-1/2 flex-wrap items-center justify-between gap-2 rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] px-4 py-2 shadow-xl backdrop-blur">
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm hover:bg-black/4"
onClick={requestBack}
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
<strong className="text-sm">{resourceLabel ?? 'UI 设计'}</strong> <strong className="text-sm">{resourceLabel ?? 'UI 设计'}</strong>
<div className="game-workbench-editor-actions"> <div className="game-workbench-editor-actions">
<div className="flex items-center gap-2 text-xs text-(--platform-text-soft)">
<label className="inline-flex cursor-pointer select-none items-center gap-1.5 rounded-lg px-2 py-1">
<input
type="checkbox"
checked={showFrame}
onChange={(event) => setShowFrame(event.target.checked)}
className="size-3 accent-orange-500"
/>
<span>线</span>
</label>
<label className="inline-flex cursor-pointer select-none items-center gap-1.5 rounded-lg px-2 py-1">
<input
type="checkbox"
checked={showOriginImage}
onChange={(event) => setShowOriginImage(event.target.checked)}
className="size-3 accent-orange-500"
/>
<span></span>
</label>
<label className="inline-flex cursor-pointer select-none items-center gap-1.5 rounded-lg px-2 py-1">
<input
type="checkbox"
checked={showComponent}
onChange={(event) => setShowComponent(event.target.checked)}
className="size-3 accent-orange-500"
/>
<span></span>
</label>
</div>
<button <button
type="button" type="button"
className="inline-flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) px-2.5 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-40" className="inline-flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) px-2.5 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
@@ -198,6 +273,14 @@ export default function UiEditorPage({
{walletEntry ? ( {walletEntry ? (
<div className="game-workbench-editor-wallet">{walletEntry}</div> <div className="game-workbench-editor-wallet">{walletEntry}</div>
) : null} ) : null}
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-2.5 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
disabled={session.canvas.isLocked}
onClick={session.canvas.openClearDialog}
>
</button>
<button <button
type="button" type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60" className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60"
@@ -220,69 +303,53 @@ export default function UiEditorPage({
</div> </div>
</header> </header>
) : null} ) : null}
{session.save.saveError ? ( {resourceId ? (
<div <button
className="mx-4 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800" type="button"
role="alert" aria-label="关闭 UI 编辑器"
className="absolute left-4 top-4 z-40 grid size-9 place-items-center rounded-lg border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] shadow-[0_12px_28px_rgb(67_48_37_/_14%)]"
onClick={requestBack}
> >
<span>{session.save.saveError}</span> <X size={18} aria-hidden="true" />
</div> </button>
) : null} ) : null}
{session.save.generateError ? ( <div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
<div <PreviewWorkspace
className="mx-4 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800" canvas={session.canvas}
role="alert" showFrame={showFrame}
> showOriginImage={showOriginImage}
<span>{session.save.generateError}</span> showComponent={showComponent}
</div> />
) : null} <div className="pointer-events-none absolute inset-0 z-10">
{generateSuccess ? ( <div
<div className="pointer-events-auto absolute bottom-20 left-4 top-16 w-[24%] min-w-[280px] max-w-[360px] select-none overflow-hidden rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] shadow-[0_18px_44px_rgb(67_48_37_/_16%)]"
className="mx-4 mt-3 shrink-0 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800" onDragStart={(event) => event.preventDefault()}
role="status" >
> <InputSidebar input={session.input} />
{generateSuccess} </div>
</div> <div className="absolute bottom-4 left-1/2 z-20 w-[min(760px,calc(100%_-_2rem))] -translate-x-1/2">
) : null} <div
<ToolNavigation className="pointer-events-auto flex w-full select-none flex-col gap-2 overflow-hidden rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] shadow-[0_18px_44px_rgb(67_48_37_/_16%)]"
activeStep={session.workflow.activeStep} onDragStart={(event) => event.preventDefault()}
furthestStepIndex={session.workflow.furthestStepIndex} >
disabled={session.workflow.isBusy} <ToolNavigation
onChange={session.workflow.requestStepChange} activeStep={session.workflow.activeStep}
/> furthestStepIndex={session.workflow.furthestStepIndex}
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-[minmax(0,24fr)_minmax(0,44fr)_minmax(0,32fr)] overflow-hidden"> disabled={session.workflow.isBusy}
<InputSidebar input={session.input} /> onChange={session.workflow.requestStepChange}
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden"> />
<WorkflowActionCard workflow={session.workflow} /> <WorkflowActionCard workflow={session.workflow} />
<PreviewWorkspace canvas={session.canvas} /> </div>
</div> <div
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden"> className="pointer-events-auto absolute bottom-0 right-full mr-3 w-64 select-none overflow-hidden rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] shadow-[0_18px_44px_rgb(67_48_37_/_16%)]"
{session.input.activeStep === 'structure-recognition' ? ( onDragStart={(event) => event.preventDefault()}
<RecognitionOverview >
uiTrees={session.input.uiTrees} {overview}
onFocusStatusNode={(treeId, nodeId) => { </div>
session.input.focusNode(treeId, nodeId); </div>
session.input.highlightStatusField(nodeId, 'layout_status'); <div className="pointer-events-auto absolute bottom-20 right-4 top-16 flex w-[32%] min-w-[320px] max-w-[480px] flex-col overflow-hidden rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] shadow-[0_18px_44px_rgb(67_48_37_/_16%)]">
}} <InspectorSidebar inspector={session.inspector} />
/> </div>
) : session.input.activeStep === 'asset-separation' ? (
<SeparationOverview
uiTrees={session.input.uiTrees}
sprites={session.input.sprites}
fonts={session.input.fonts}
onFocusStatusNode={(treeId, nodeId) => {
session.input.focusNode(treeId, nodeId);
session.input.highlightStatusField(nodeId, 'component_status');
}}
/>
) : (
<ImportOverview
images={session.input.images}
sprites={session.input.sprites}
uiTrees={session.input.uiTrees}
/>
)}
<InspectorSidebar inspector={session.inspector} />
</div> </div>
</div> </div>
<EditorDialogs dialogs={session.dialogs} /> <EditorDialogs dialogs={session.dialogs} />
@@ -290,6 +357,10 @@ export default function UiEditorPage({
notice={session.workflow.completionNotice} notice={session.workflow.completionNotice}
onClose={session.workflow.dismissCompletionNotice} onClose={session.workflow.dismissCompletionNotice}
/> />
<UiEditorSaveResultModal
notice={saveResultNotice}
onClose={() => setSaveResultNotice(null)}
/>
<ThemedModal <ThemedModal
open={returnConfirmOpen} open={returnConfirmOpen}
onClose={() => setReturnConfirmOpen(false)} onClose={() => setReturnConfirmOpen(false)}
@@ -397,6 +468,19 @@ export default function UiEditorPage({
</ThemedModal> </ThemedModal>
</main> </main>
); );
if (!resourceId) return editorContent;
if (typeof document === 'undefined') return editorContent;
return createPortal(
<div className="platform-theme platform-theme--light fixed inset-0 z-50 !bg-transparent !p-0">
<section className="h-full w-full rounded-none p-0 outline-none focus:outline-none">
{editorContent}
</section>
</div>,
document.body,
);
} }
function stepLabel(step: UiEditorWorkflowProjection['activeStep'] | undefined) { function stepLabel(step: UiEditorWorkflowProjection['activeStep'] | undefined) {
@@ -37,12 +37,14 @@ import type { UIDesignSuggestionTreeNode } from '../../features/ui-editor/types/
import type { UiNodeMoveRequest } from '../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiNodeMoveRequest } from '../../features/ui-editor/types/UiNodeMoveRequest';
import { import {
type IUiDesignStateStore, type IUiDesignStateStore,
type UiDesignCodeGenerationResult,
uiDesignStateStore, uiDesignStateStore,
} from '../../features/ui-editor/uiDesignStateStore'; } from '../../features/ui-editor/uiDesignStateStore';
import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions'; import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions';
import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces'; import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces';
import { addSpriteAssetsToState } from '../../features/ui-editor/useUiEditorState'; import { addSpriteAssetsToState } from '../../features/ui-editor/useUiEditorState';
import { import {
createTree,
EMPTY_UI_EDITOR_STATE, EMPTY_UI_EDITOR_STATE,
type NodeLayoutPatch, type NodeLayoutPatch,
type NodeMetadataPatch, type NodeMetadataPatch,
@@ -77,6 +79,14 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const SEPARATION_IMPORT_BATCH_SIZE = 100; const SEPARATION_IMPORT_BATCH_SIZE = 100;
export type UiEditorSaveResult =
| { status: 'saved' }
| { status: 'failed'; message: string };
export type UiEditorSaveAndGenerateResult =
| { status: 'generated'; result: UiDesignCodeGenerationResult }
| { status: 'failed'; phase: 'save' | 'generate'; message: string };
type LocalImageImportResponse = { type LocalImageImportResponse = {
assets: Array<{ id: string; localPath: string; assetKind?: string | null }>; assets: Array<{ id: string; localPath: string; assetKind?: string | null }>;
}; };
@@ -196,6 +206,7 @@ export function useUiEditorSession(
) { ) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
const editorDeleteNode = editor.deleteNode; const editorDeleteNode = editor.deleteNode;
const setTreeOffset = editor.setTreeOffset;
const editorUiTrees = editor.state.ui_trees; const editorUiTrees = editor.state.ui_trees;
const replaceEditorState = editor.replaceState; const replaceEditorState = editor.replaceState;
const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [isLoading, setIsLoading] = useState(Boolean(resourceId));
@@ -463,6 +474,16 @@ export function useUiEditorSession(
? editor.state.ui_trees.find((tree) => tree.src_ui_design === activeImageId) ? editor.state.ui_trees.find((tree) => tree.src_ui_design === activeImageId)
: null; : null;
const treeForSelectedNode = useMemo(() => {
if (!selectedNodeId) return null;
return (
editor.state.ui_trees.find(
(candidate) =>
findUiNodeLocation(candidate.root, selectedNodeId) !== null,
) ?? null
);
}, [editor.state.ui_trees, selectedNodeId]);
useEffect(() => { useEffect(() => {
const validNodeIds = new Set<NodeId>(); const validNodeIds = new Set<NodeId>();
for (const tree of editor.state.ui_trees) { for (const tree of editor.state.ui_trees) {
@@ -564,16 +585,18 @@ export function useUiEditorSession(
[setNodePreviewVisible], [setNodePreviewVisible],
); );
const selectedNodeContext = useMemo(() => { const selectedNodeContext = useMemo(() => {
if (!activeImage || !treeForActiveImage || !selectedNodeId) return null; if (!selectedNodeId || !treeForSelectedNode) return null;
const ppu = activeImage.pixels_per_unit; const image = images[treeForSelectedNode.src_ui_design];
const width = activeImage.pixel_size[0] / ppu; if (!image) return null;
const height = activeImage.pixel_size[1] / ppu; 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; if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return findNodeContext(treeForActiveImage.root, selectedNodeId, { return findNodeContext(treeForSelectedNode.root, selectedNodeId, {
width, width,
height, height,
}); });
}, [activeImage, selectedNodeId, treeForActiveImage]); }, [images, selectedNodeId, treeForSelectedNode]);
async function importAssets(imported: ImportedAsset[]) { async function importAssets(imported: ImportedAsset[]) {
if (!importKind || !projectPath) return; if (!importKind || !projectPath) return;
@@ -779,8 +802,12 @@ export function useUiEditorSession(
} }
function setNodeTransform(transform: UiNode['layout']['transform']) { function setNodeTransform(transform: UiNode['layout']['transform']) {
if (!activeImageId || !selectedNodeId) return; if (!treeForSelectedNode?.src_ui_design || !selectedNodeId) return;
return updateNodeTransform(activeImageId, selectedNodeId, transform); return updateNodeTransform(
treeForSelectedNode.src_ui_design,
selectedNodeId,
transform,
);
} }
function updateNodeTransform( function updateNodeTransform(
@@ -799,8 +826,12 @@ export function useUiEditorSession(
} }
function setNodeMetadata(patch: NodeMetadataPatch) { function setNodeMetadata(patch: NodeMetadataPatch) {
if (!activeImageId || !selectedNodeId) return; if (!treeForSelectedNode?.src_ui_design || !selectedNodeId) return;
const result = editor.setNodeMetadata(activeImageId, selectedNodeId, patch); const result = editor.setNodeMetadata(
treeForSelectedNode.src_ui_design,
selectedNodeId,
patch,
);
if ( if (
result.ok && result.ok &&
(patch.layout_status !== undefined || (patch.layout_status !== undefined ||
@@ -813,19 +844,23 @@ export function useUiEditorSession(
} }
function setNodeLayout(patch: NodeLayoutPatch) { function setNodeLayout(patch: NodeLayoutPatch) {
if (!activeImageId || !selectedNodeId) return; if (!treeForSelectedNode?.src_ui_design || !selectedNodeId) return;
const result = editor.setNodeLayout(activeImageId, selectedNodeId, patch); const result = editor.setNodeLayout(
treeForSelectedNode.src_ui_design,
selectedNodeId,
patch,
);
if (!result.ok) setStatus('节点布局更新失败。'); if (!result.ok) setStatus('节点布局更新失败。');
return result; return result;
} }
function setNodeChildrenDisplayMode(mode: ChildrenDisplayMode) { function setNodeChildrenDisplayMode(mode: ChildrenDisplayMode) {
if (!activeImageId || !selectedNodeId) return; if (!treeForSelectedNode?.src_ui_design || !selectedNodeId) return;
const selectedNode = treeForActiveImage const selectedNode = treeForSelectedNode
? findUiNodeLocation(treeForActiveImage.root, selectedNodeId)?.node ? findUiNodeLocation(treeForSelectedNode.root, selectedNodeId)?.node
: null; : null;
const result = editor.setNodeChildrenDisplayMode( const result = editor.setNodeChildrenDisplayMode(
activeImageId, treeForSelectedNode.src_ui_design,
selectedNodeId, selectedNodeId,
mode, mode,
); );
@@ -844,9 +879,9 @@ export function useUiEditorSession(
} }
function setNodeComponent(component: Component | null) { function setNodeComponent(component: Component | null) {
if (!activeImageId || !selectedNodeId) return; if (!treeForSelectedNode?.src_ui_design || !selectedNodeId) return;
const result = editor.setNodeComponent( const result = editor.setNodeComponent(
activeImageId, treeForSelectedNode.src_ui_design,
selectedNodeId, selectedNodeId,
component, component,
); );
@@ -1002,6 +1037,13 @@ export function useUiEditorSession(
state: snapshot, state: snapshot,
}); });
const nextState = applyRecognitionResult(snapshot, result); const nextState = applyRecognitionResult(snapshot, result);
const recognizedTrees = nextState.ui_trees;
nextState.ui_trees = [];
for (const tree of recognizedTrees) {
nextState.ui_trees.push(
createTree(nextState, tree.src_ui_design, tree.root),
);
}
editor.replaceState(nextState); editor.replaceState(nextState);
setHasRecognized(true); setHasRecognized(true);
setSelectedNodeId(null); setSelectedNodeId(null);
@@ -1036,7 +1078,15 @@ export function useUiEditorSession(
try { try {
await editor.runWithStateLocked(async (snapshot) => { await editor.runWithStateLocked(async (snapshot) => {
const result = await invoke<MergeDTO>('merge_ui', { state: snapshot }); const result = await invoke<MergeDTO>('merge_ui', { state: snapshot });
editor.replaceState(applyMergeResult(snapshot, result)); const nextState = applyMergeResult(snapshot, result);
const mergedTrees = nextState.ui_trees;
nextState.ui_trees = [];
for (const tree of mergedTrees) {
nextState.ui_trees.push(
createTree(nextState, tree.src_ui_design, tree.root),
);
}
editor.replaceState(nextState);
setSelectedNodeId(null); setSelectedNodeId(null);
setMergeStatus('已生成合并后的单棵界面树。'); setMergeStatus('已生成合并后的单棵界面树。');
}); });
@@ -1207,7 +1257,7 @@ export function useUiEditorSession(
if (separationResult === null) if (separationResult === null)
throw new Error('自动切分素材没有返回结果'); throw new Error('自动切分素材没有返回结果');
const completedResult = separationResult as SeparationDTO; const completedResult = separationResult as SeparationDTO;
if (!(await save({ allowDuringSeparation: true }))) { if ((await save({ allowDuringSeparation: true })).status !== 'saved') {
throw new Error( throw new Error(
'自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。', '自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。',
); );
@@ -1370,7 +1420,10 @@ export function useUiEditorSession(
persistedRevision === null || persistedRevision === null ||
editor.isLocked editor.isLocked
) { ) {
return false; return {
status: 'failed' as const,
message: '当前状态不允许保存,请稍后重试。',
};
} }
setSaveError(null); setSaveError(null);
setGenerateError(null); setGenerateError(null);
@@ -1385,15 +1438,18 @@ export function useUiEditorSession(
); );
if (result.status === 'conflict') { if (result.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。'); setSaveError('资源已在别处更新;请重新加载后再保存。');
return false; return {
status: 'failed' as const,
message: '资源已在别处更新;请重新加载后再保存。',
};
} }
setPersistedRevision(result.revision); setPersistedRevision(result.revision);
setSavedStateSignature(snapshotSignature); setSavedStateSignature(snapshotSignature);
return true; return { status: 'saved' as const };
}); });
} catch { } catch {
setSaveError('保存失败,请稍后重试。'); setSaveError('保存失败,请稍后重试。');
return false; return { status: 'failed' as const, message: '保存失败,请稍后重试。' };
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -1438,7 +1494,11 @@ export function useUiEditorSession(
persistedRevision === null || persistedRevision === null ||
editor.isLocked editor.isLocked
) { ) {
return null; return {
status: 'failed' as const,
phase: 'save' as const,
message: '当前状态不允许保存并生成代码,请稍后重试。',
};
} }
setSaveError(null); setSaveError(null);
setGenerateError(null); setGenerateError(null);
@@ -1454,15 +1514,34 @@ export function useUiEditorSession(
); );
if (saved.status === 'conflict') { if (saved.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。'); setSaveError('资源已在别处更新;请重新加载后再保存。');
return null; return {
status: 'failed' as const,
phase: 'save' as const,
message: '资源已在别处更新;请重新加载后再保存。',
};
} }
setPersistedRevision(saved.revision); setPersistedRevision(saved.revision);
setSavedStateSignature(snapshotSignature); setSavedStateSignature(snapshotSignature);
return await stateStore.generateCode(resourceId); try {
return {
status: 'generated' as const,
result: await stateStore.generateCode(resourceId),
};
} catch (cause) {
const message =
cause instanceof Error ? cause.message : String(cause);
setGenerateError(message);
return {
status: 'failed' as const,
phase: 'generate' as const,
message,
};
}
}); });
} catch (cause) { } catch (cause) {
setGenerateError(cause instanceof Error ? cause.message : String(cause)); const message = '保存失败,请稍后重试。';
return null; setSaveError('保存失败,请稍后重试。');
return { status: 'failed' as const, phase: 'save' as const, message };
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
setIsSaving(false); setIsSaving(false);
@@ -1518,11 +1597,14 @@ export function useUiEditorSession(
isLocked: editor.isLocked, isLocked: editor.isLocked,
activeImage, activeImage,
activeImageId, activeImageId,
uiTrees: editor.state.ui_trees,
previewUrls, previewUrls,
images, images,
sprites, sprites,
fontFaces, fontFaces,
tree: treeForActiveImage ?? null, tree: treeForActiveImage ?? null,
selectedTreeId: treeForSelectedNode?.src_ui_design ?? null,
setTreeOffset,
selectedNode: selectedNodeContext?.node ?? null, selectedNode: selectedNodeContext?.node ?? null,
selectedNodeId, selectedNodeId,
keepChildrenUnchanged, keepChildrenUnchanged,
@@ -1557,11 +1639,11 @@ export function useUiEditorSession(
selectedNode: selectedNodeContext?.node ?? null, selectedNode: selectedNodeContext?.node ?? null,
selectedNodeParentSize: selectedNodeContext?.parentSize, selectedNodeParentSize: selectedNodeContext?.parentSize,
selectedNodeParent: selectedNodeParent:
selectedNodeId && treeForActiveImage selectedNodeId && treeForSelectedNode
? (findUiNodeLocation(treeForActiveImage.root, selectedNodeId) ? (findUiNodeLocation(treeForSelectedNode.root, selectedNodeId)
?.parent ?? null) ?.parent ?? null)
: null, : null,
tree: treeForActiveImage ?? null, tree: treeForSelectedNode ?? treeForActiveImage ?? null,
previewUrls, previewUrls,
sprites, sprites,
fonts, fonts,
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { import {
getNextMatchingUiTreeNodeTarget, getNextMatchingUiTreeNodeTarget,
type UiTreeNodeCursor,
type UiTreeNodeTarget, type UiTreeNodeTarget,
} from '../../features/ui-editor/stageStatusOverview'; } from '../../features/ui-editor/stageStatusOverview';
import type { NodeId } from '../../features/ui-editor/types/NodeId'; import type { NodeId } from '../../features/ui-editor/types/NodeId';
@@ -17,20 +18,20 @@ export function useUiTreeNodeCycle({
matches: (target: UiTreeNodeTarget) => boolean; matches: (target: UiTreeNodeTarget) => boolean;
onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void; onFocusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
}) { }) {
const [lastNodeId, setLastNodeId] = useState<NodeId | null>(null); const [lastCursor, setLastCursor] = useState<UiTreeNodeCursor | null>(null);
useEffect(() => setLastNodeId(null), [uiTrees]); useEffect(() => setLastCursor(null), [uiTrees]);
const focusNext = useCallback(() => { const focusNext = useCallback(() => {
const target = getNextMatchingUiTreeNodeTarget( const target = getNextMatchingUiTreeNodeTarget(
uiTrees, uiTrees,
lastNodeId, lastCursor,
matches, matches,
); );
if (!target) return; if (!target) return;
setLastNodeId(target.node.id); setLastCursor({ treeId: target.treeId, nodeId: target.node.id });
onFocusNode(target.treeId, target.node.id); onFocusNode(target.treeId, target.node.id);
}, [lastNodeId, matches, onFocusNode, uiTrees]); }, [lastCursor, matches, onFocusNode, uiTrees]);
return { focusNext }; return { focusNext };
} }
@@ -5,6 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage';
import {
PREVIEW_GRID_BASE_STEP,
resolvePreviewGridStep,
} from '../src/view/ui-editor/components/preview/previewGrid';
import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace';
import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage';
@@ -41,6 +45,10 @@ const root: UiNode = {
component: null, component: null,
children_display_mode: 'Stack', children_display_mode: 'Stack',
children: [], children: [],
offset: {
min: [0, 0],
max: [1200, 800],
},
}; };
const activeImage: UIDesignImage = { const activeImage: UIDesignImage = {
@@ -62,6 +70,7 @@ function createCanvas(
isLocked: false, isLocked: false,
activeImage, activeImage,
activeImageId: 'page-1', activeImageId: 'page-1',
uiTrees: [{ src_ui_design: 'page-1', root }],
previewUrls: { 'page-1': 'data:image/png;base64,' }, previewUrls: { 'page-1': 'data:image/png;base64,' },
images: { 'page-1': activeImage }, images: { 'page-1': activeImage },
sprites: {}, sprites: {},
@@ -111,6 +120,31 @@ afterEach(() => {
}); });
describe('PreviewWorkspace quick zoom', () => { describe('PreviewWorkspace quick zoom', () => {
it('chooses doubled or halved world steps to keep the grid readable', () => {
expect(resolvePreviewGridStep(1)).toBe(PREVIEW_GRID_BASE_STEP);
expect(resolvePreviewGridStep(0.5)).toBe(56);
expect(resolvePreviewGridStep(0.125)).toBe(224);
expect(resolvePreviewGridStep(2)).toBe(14);
expect(resolvePreviewGridStep(4)).toBe(7);
for (const scale of [0.125, 0.25, 0.5, 1, 2, 4, 8]) {
const spacing = resolvePreviewGridStep(scale) * scale;
expect(spacing).toBeGreaterThanOrEqual(20);
expect(spacing).toBeLessThan(40);
}
});
it('applies the adaptive screen-space spacing to the preview background', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const scale = logicalViewportScale(rendered.container);
const [backgroundWidth] = preview.style.backgroundSize.split('px');
expect(Number(backgroundWidth)).toBeCloseTo(
resolvePreviewGridStep(scale) * scale,
);
});
it('uses the toolbar zoom step while the preview is hovered', () => { it('uses the toolbar zoom step while the preview is hovered', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />); const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const preview = screen.getByRole('region', { name: 'UI 预览画布' });
@@ -95,4 +95,34 @@ describe('stageStatusOverview', () => {
'review-a', 'review-a',
); );
}); });
it('uses the tree and node ids together when node ids repeat across trees', () => {
const duplicateTargets = [
{ treeId: 'page-a', node: node('same', { NeedReview: 'A' }) },
{ treeId: 'page-b', node: node('same', { NeedReview: 'B' }) },
{ treeId: 'page-c', node: node('same', { NeedReview: 'C' }) },
];
expect(getNextUiTreeNodeTarget(duplicateTargets, null)?.treeId).toBe(
'page-a',
);
expect(
getNextUiTreeNodeTarget(duplicateTargets, {
treeId: 'page-a',
nodeId: 'same',
})?.treeId,
).toBe('page-b');
expect(
getNextUiTreeNodeTarget(duplicateTargets, {
treeId: 'page-b',
nodeId: 'same',
})?.treeId,
).toBe('page-c');
expect(
getNextUiTreeNodeTarget(duplicateTargets, {
treeId: 'page-c',
nodeId: 'same',
})?.treeId,
).toBe('page-a');
});
}); });

Some files were not shown because too many files have changed in this diff Show More