Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e992bcdf8 | |||
| 8368aa262c | |||
| e9ddbf16da | |||
| 22ac1f4c0b | |||
| 9910a0eec0 | |||
| 70981b9ca9 | |||
| 8a0d5600b3 | |||
| 184d88dbb8 | |||
| 791794c0a6 | |||
| 9cfb47d945 | |||
| 27859d3e19 | |||
| 43f3780a38 | |||
| a1f9149c27 | |||
| f0bba18841 | |||
| f679cfa179 | |||
| f68ffcfecd | |||
| ec94a4fe00 | |||
| 46f8592375 | |||
| cdf2882302 | |||
| d156113e71 | |||
| a8f2ac17be | |||
| 43ac9a5761 | |||
| e992e0b35c | |||
| e389b54a3a | |||
| 84c780a6f8 | |||
| 03f900fbe5 |
@@ -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)
|
||||||
|
|||||||
+24
-8
@@ -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 '保存结果';
|
||||||
|
}
|
||||||
+243
-179
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -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;
|
||||||
|
}
|
||||||
+42
-41
@@ -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
Reference in New Issue
Block a user