From 9ea3b3cfe697bf85f2730c87282d541850269287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 14 Aug 2026 12:16:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E9=80=BB=E8=BE=91=E4=BB=A5=E5=A2=9E=E5=BC=BA=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=A0=91=E6=93=8D=E4=BD=9C=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加页面树同步逻辑,确保 `Page` 节点与 UI 树一致。 - 实现节点的新增、删除、移动和元数据更新功能。 - 扩展节点 Inspector,支持查看和修改选中节点的名称、描述和变换属性。 - 重构相关测试,覆盖新的页面树和节点操作功能。 - 引入嵌套节点坐标计算逻辑,确保节点相对位置一致性。 --- .../types/UIDesignSuggestionTreeNode.ts | 5 + .../features/ui-editor/useUiEditorState.ts | 509 +++++++++++++++-- .../ui-editor/components/EditorDialogs.tsx | 29 + .../ui-editor/components/ImportOverview.tsx | 2 +- .../ui-editor/components/InputSidebar.tsx | 37 +- .../components/Inspector/InspectorSidebar.tsx | 522 +++++++++++++----- .../Inspector/Transform/TransformEditor.tsx | 74 +-- .../ui-editor/components/PreviewWorkspace.tsx | 415 ++++++++++++-- .../view/ui-editor/components/UiTreePanel.tsx | 250 +++++++-- .../src/view/ui-editor/useUiEditorPage.ts | 188 ++++++- .../tests/uiDesignSuggestions.test.ts | 93 ++++ .../tests/uiEditorState.test.ts | 24 +- 12 files changed, 1808 insertions(+), 340 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts create mode 100644 apps/ai-game-creator-shell/tests/uiDesignSuggestions.test.ts diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts new file mode 100644 index 000000000..fe0b35bc1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UIDesignImageId } from "./UIDesignImageId"; +import type { UIDesignImageRole } from "./UIDesignImageRole"; + +export type UIDesignSuggestionTreeNode = { id: UIDesignImageId, name: string, description: string, role: UIDesignImageRole, children: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 087eb90e2..94740ffe8 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -3,6 +3,8 @@ import { useCallback, useRef, useState } from 'react'; import type { Component } from './types/Component'; import { validateSpriteBorder } from './spriteBorder'; import type { Node } from './types/Node'; +import type { NodeId } from './types/NodeId'; +import type { NodeMetadata } from './types/NodeMetadata'; import type { SpriteAsset } from './types/SpriteAsset'; import type { SpriteAssetId } from './types/SpriteAssetId'; import type { SpriteBorder } from './types/SpriteBorder'; @@ -30,11 +32,196 @@ export type UiEditorOperationResult = | { ok: true; value: T } | { ok: false; reason: UiEditorOperationFailureReason }; -type UiEditorOperationFailure = Extract< - UiEditorOperationResult, - { ok: false } +type UiEditorOperationFailure = Extract; + +export type NodeMetadataPatch = Partial< + Pick >; +type Rect = { + min: [number, number]; + size: [number, number]; +}; + +type NodeLocation = { + node: Node; + parent: Node | null; + index: number; +}; + +function visitNodes(node: Node, visit: (node: Node) => void): void { + visit(node); + for (const child of node.children) visitNodes(child, visit); +} + +function existingNodeIds(state: State): Set { + const ids = new Set(); + for (const tree of state.ui_trees) { + visitNodes(tree.root, (node) => ids.add(node.id)); + } + return ids; +} + +function createUniqueNodeId(state: State): NodeId { + const ids = existingNodeIds(state); + const randomId = () => + globalThis.crypto?.randomUUID?.() ?? + `node-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + let id = randomId(); + while (ids.has(id)) id = randomId(); + return id; +} + +function createPageRoot(state: State): Node { + return { + id: createUniqueNodeId(state), + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + metadata: { + name: '页面根节点', + description: '', + status: 'Passed', + source: 'System', + }, + components: [], + children: [], + }; +} + +function createHumanNode(state: State): Node { + return { + id: createUniqueNodeId(state), + transform: { + anchor_min: [0.5, 0.5], + anchor_max: [0.5, 0.5], + offset_min: [-50, -50], + offset_max: [50, 50], + }, + metadata: { + name: '新节点', + description: '', + status: { NeedReview: '手动新增,待编辑' }, + source: 'Human', + }, + components: [], + children: [], + }; +} + +function synchronizePageTrees(state: State): void { + const pageIds = new Set( + Object.entries(state.ui_design_images) + .filter(([, image]) => image.metadata.role === 'Page') + .map(([id]) => id), + ); + state.ui_trees = state.ui_trees.filter((tree) => + pageIds.has(tree.src_ui_design), + ); + for (const [id] of Object.entries(state.ui_design_images)) { + if ( + state.ui_design_images[id]!.metadata.role === 'Page' && + !state.ui_trees.some((tree) => tree.src_ui_design === id) + ) { + state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) }); + } + } +} + +function resolveNodeRect(transform: Node['transform'], parent: Rect): Rect { + const min: [number, number] = [ + parent.min[0] + + parent.size[0] * transform.anchor_min[0] + + transform.offset_min[0], + parent.min[1] + + parent.size[1] * transform.anchor_min[1] + + transform.offset_min[1], + ]; + const max: [number, number] = [ + parent.min[0] + + parent.size[0] * transform.anchor_max[0] + + transform.offset_max[0], + parent.min[1] + + parent.size[1] * transform.anchor_max[1] + + transform.offset_max[1], + ]; + return { min, size: [max[0] - min[0], max[1] - min[1]] }; +} + +function isValidRect(rect: Rect): boolean { + return rect.min.every(Number.isFinite) && rect.size.every(Number.isFinite); +} + +function findNodeLocation( + node: Node, + id: NodeId, + parent: Node | null = null, + index = 0, +): NodeLocation | null { + if (node.id === id) return { node, parent, index }; + for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) { + const found = findNodeLocation( + node.children[childIndex]!, + id, + node, + childIndex, + ); + if (found) return found; + } + return null; +} + +function findNodePageRect( + node: Node, + id: NodeId, + parentRect: Rect, +): Rect | null { + const rect = resolveNodeRect(node.transform, parentRect); + if (node.id === id) return rect; + for (const child of node.children) { + const found = findNodePageRect(child, id, rect); + if (found) return found; + } + return null; +} + +function containsNode(root: Node, id: NodeId): boolean { + return findNodeLocation(root, id) !== null; +} + +function setOffsetsForPageRect( + transform: Node['transform'], + pageRect: Rect, + parentPageRect: Rect, +): Node['transform'] | null { + if (!isValidRect(pageRect) || !isValidRect(parentPageRect)) return null; + const next = structuredClone(transform); + next.offset_min = [ + pageRect.min[0] - + parentPageRect.min[0] - + parentPageRect.size[0] * next.anchor_min[0], + pageRect.min[1] - + parentPageRect.min[1] - + parentPageRect.size[1] * next.anchor_min[1], + ]; + const pageMax: [number, number] = [ + pageRect.min[0] + pageRect.size[0], + pageRect.min[1] + pageRect.size[1], + ]; + next.offset_max = [ + pageMax[0] - + parentPageRect.min[0] - + parentPageRect.size[0] * next.anchor_max[0], + pageMax[1] - + parentPageRect.min[1] - + parentPageRect.size[1] * next.anchor_max[1], + ]; + return next; +} + export type DesignImageInput = { id: UIDesignImageId; image: UIDesignImage; @@ -51,10 +238,7 @@ function cloneState(state: State): State { return structuredClone(state); } -function visitComponents( - nodes: Node[], - visit: (component: Component) => void, -) { +function visitComponents(nodes: Node[], visit: (component: Component) => void) { for (const node of nodes) { for (const component of node.components) { visit(component); @@ -69,9 +253,8 @@ export function designImageRemovalImpact( ): RemovalImpact { return { removedResourceCount: id in state.ui_design_images ? 1 : 0, - removedTreeCount: state.ui_trees.filter( - (tree) => tree.src_ui_design === id, - ).length, + removedTreeCount: state.ui_trees.filter((tree) => tree.src_ui_design === id) + .length, clearedSlaveToCount: Object.values(state.ui_design_images).filter( (image) => image.metadata.slave_to === id, ).length, @@ -85,11 +268,8 @@ export function spriteAssetRemovalImpact( ): RemovalImpact { let clearedTargetGraphicCount = 0; for (const tree of state.ui_trees) { - visitComponents(tree.children, (component) => { - if ( - 'Image' in component && - component.Image.target_graphic === id - ) { + visitComponents([tree.root], (component) => { + if ('Image' in component && component.Image.target_graphic === id) { clearedTargetGraphicCount += 1; } }); @@ -104,11 +284,7 @@ export function spriteAssetRemovalImpact( function imageResourceValidationError(image: UIDesignImage) { if (image.path.trim().length === 0) return '缺少图片路径'; - if ( - !image.pixel_size.every( - (value) => Number.isFinite(value) && value > 0, - ) - ) { + if (!image.pixel_size.every((value) => Number.isFinite(value) && value > 0)) { return `图片尺寸无效:${JSON.stringify(image.pixel_size)}`; } if (!Number.isFinite(image.pixels_per_unit) || image.pixels_per_unit <= 0) { @@ -121,9 +297,7 @@ function spriteResourceValidationError(sprite: SpriteAsset) { if (sprite.asset_id.trim().length === 0) return '缺少素材 ID'; if (sprite.path.trim().length === 0) return '缺少素材路径'; if ( - !sprite.pixel_size.every( - (value) => Number.isFinite(value) && value > 0, - ) + !sprite.pixel_size.every((value) => Number.isFinite(value) && value > 0) ) { return `素材尺寸无效:${JSON.stringify(sprite.pixel_size)}`; } @@ -134,10 +308,12 @@ function spriteResourceValidationError(sprite: SpriteAsset) { return border.ok ? null : border.message; } -export function useUiEditorState( - initialState: State = EMPTY_UI_EDITOR_STATE, -) { - const [state, setState] = useState(() => cloneState(initialState)); +export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { + const [state, setState] = useState(() => { + const next = cloneState(initialState); + synchronizePageTrees(next); + return next; + }); const [isLocked, setIsLocked] = useState(false); const stateRef = useRef(state); const isLockedRef = useRef(false); @@ -220,6 +396,7 @@ export function useUiEditorState( } const next = cloneState(current); next.ui_design_images[id]!.metadata.role = role; + synchronizePageTrees(next); commit(next); return { ok: true, value: undefined }; }, @@ -264,7 +441,10 @@ export function useUiEditorState( return { ok: false, reason: 'limit' }; } const invalidImage = entries - .map((entry) => ({ entry, error: imageResourceValidationError(entry.image) })) + .map((entry) => ({ + entry, + error: imageResourceValidationError(entry.image), + })) .find((item) => item.error); if (invalidImage?.error) { return { @@ -276,6 +456,7 @@ export function useUiEditorState( for (const entry of entries) { next.ui_design_images[entry.id] = structuredClone(entry.image); } + synchronizePageTrees(next); commit(next); return { ok: true, value: undefined }; }, @@ -295,7 +476,10 @@ export function useUiEditorState( return { ok: false, reason: 'duplicate' }; } const invalidSprite = assets - .map((asset) => ({ asset, error: spriteResourceValidationError(asset) })) + .map((asset) => ({ + asset, + error: spriteResourceValidationError(asset), + })) .find((item) => item.error); if (invalidSprite?.error) { return { @@ -346,10 +530,7 @@ export function useUiEditorState( ); const setSpriteBorder = useCallback( - ( - id: SpriteAssetId, - border: SpriteBorder, - ): UiEditorOperationResult => { + (id: SpriteAssetId, border: SpriteBorder): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; const current = stateRef.current; @@ -366,6 +547,240 @@ export function useUiEditorState( [commit, guard], ); + const insertNode = useCallback( + ( + treeId: UIDesignImageId, + parentId: NodeId, + targetIndex?: number, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const parent = findNodeLocation(tree.root, parentId); + if (!parent) return { ok: false, reason: 'missing' }; + const index = targetIndex ?? parent.node.children.length; + if ( + !Number.isInteger(index) || + index < 0 || + index > parent.node.children.length + ) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + const nextParent = findNodeLocation(nextTree.root, parentId)!.node; + const node = createHumanNode(next); + nextParent.children.splice(index, 0, node); + commit(next); + return { ok: true, value: node.id }; + }, + [commit, guard], + ); + + const insertNodeAfter = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location || !location.parent) { + return { ok: false, reason: 'invalid' }; + } + return insertNode(treeId, location.parent.id, location.index + 1); + }, + [guard, insertNode], + ); + + const deleteNode = useCallback( + (treeId: UIDesignImageId, nodeId: NodeId): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location || !location.parent) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + const nextLocation = findNodeLocation(nextTree.root, nodeId)!; + nextLocation.parent!.children.splice(nextLocation.index, 1); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeTransform = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + transform: Node['transform'], + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if ( + !Object.values(transform).every((values) => + values.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 location = findNodeLocation(tree.root, nodeId); + if (!location) return { ok: false, reason: 'missing' }; + // The page root is a structural ordinary Node, but its containing page + // rectangle is fixed. Moving or resizing it would invalidate every + // descendant's coordinate space. + if (location.node.id === tree.root.id) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + findNodeLocation(nextTree.root, nodeId)!.node.transform = + structuredClone(transform); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeMetadata = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + patch: NodeMetadataPatch, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + if (!findNodeLocation(tree.root, nodeId)) + return { ok: false, reason: 'missing' }; + const next = cloneState(current); + const node = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + if (patch.name !== undefined) node.metadata.name = patch.name; + if (patch.description !== undefined) + node.metadata.description = patch.description; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const moveNode = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + targetParentId: NodeId, + targetIndex: number, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!Number.isInteger(targetIndex) || targetIndex < 0) { + return { ok: false, reason: 'invalid' }; + } + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const image = current.ui_design_images[treeId]; + if (!tree || !image) return { ok: false, reason: 'missing' }; + const source = findNodeLocation(tree.root, nodeId); + const target = findNodeLocation(tree.root, targetParentId); + if (!source || !target || source.node.id === tree.root.id) { + return { ok: false, reason: 'missing' }; + } + if ( + source.node.id === target.node.id || + containsNode(source.node, targetParentId) + ) { + return { ok: false, reason: 'invalid' }; + } + const logicalSize: Rect = { + min: [0, 0], + size: [ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ], + }; + if ( + !isValidRect(logicalSize) || + logicalSize.size.some((value) => value <= 0) + ) { + return { ok: false, reason: 'invalid' }; + } + const pageRect = findNodePageRect(tree.root, nodeId, logicalSize); + const targetPageRect = findNodePageRect( + tree.root, + targetParentId, + logicalSize, + ); + if ( + !pageRect || + pageRect.size.some((value) => value <= 0) || + !targetPageRect || + targetPageRect.size.some((value) => value <= 0) + ) { + return { ok: false, reason: 'invalid' }; + } + const nextTransform = setOffsetsForPageRect( + source.node.transform, + pageRect, + targetPageRect, + ); + if (!nextTransform) return { ok: false, reason: 'invalid' }; + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + const nextSource = findNodeLocation(nextTree.root, nodeId)!; + const nextTarget = findNodeLocation(nextTree.root, targetParentId)!.node; + const [moved] = nextSource.parent + ? nextSource.parent.children.splice(nextSource.index, 1) + : []; + if (!moved) return { ok: false, reason: 'invalid' }; + moved.transform = nextTransform; + if (targetIndex > nextTarget.children.length) + return { ok: false, reason: 'invalid' }; + nextTarget.children.splice(targetIndex, 0, moved); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + const removeDesignImage = useCallback( ( id: UIDesignImageId, @@ -381,9 +796,7 @@ export function useUiEditorState( if (options.dryRun) return { ok: true, value: impact }; const next = cloneState(current); delete next.ui_design_images[id]; - next.ui_trees = next.ui_trees.filter( - (tree) => tree.src_ui_design !== id, - ); + next.ui_trees = next.ui_trees.filter((tree) => tree.src_ui_design !== id); for (const image of Object.values(next.ui_design_images)) { if (image.metadata.slave_to === id) image.metadata.slave_to = null; } @@ -409,11 +822,8 @@ export function useUiEditorState( const next = cloneState(current); delete next.sprite_assets[id]; for (const tree of next.ui_trees) { - visitComponents(tree.children, (component) => { - if ( - 'Image' in component && - component.Image.target_graphic === id - ) { + visitComponents([tree.root], (component) => { + if ('Image' in component && component.Image.target_graphic === id) { component.Image.target_graphic = null; } }); @@ -431,9 +841,14 @@ export function useUiEditorState( return { ok: true, value: undefined }; }, [commit, guard]); - const replaceState = useCallback((nextState: State) => { - commit(cloneState(nextState)); - }, [commit]); + const replaceState = useCallback( + (nextState: State) => { + const next = cloneState(nextState); + synchronizePageTrees(next); + commit(next); + }, + [commit], + ); return { state, @@ -449,6 +864,12 @@ export function useUiEditorState( setSpriteName, setSpriteAssetType, setSpriteBorder, + insertNode, + insertNodeAfter, + deleteNode, + setNodeTransform, + setNodeMetadata, + moveNode, removeDesignImage, removeSpriteAsset, clearState, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx index 60ed0142b..1bad535ed 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx @@ -84,6 +84,35 @@ export function EditorDialogs({ + + +

确认移除页面组件树?

+

+ 这张界面图当前是 Page,已有 UI + Tree。改为其它界面角色会自动删除页面根节点及其全部子节点,操作不可恢复。 +

+
+ + +
+
); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx index 1d68da7f1..562adfcb9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx @@ -20,7 +20,7 @@ export function ImportOverview({ uiTrees: UITree[]; }) { const componentCount = uiTrees.reduce( - (total, tree) => total + countUiComponents(tree.children), + (total, tree) => total + countUiComponents([tree.root]), 0, ); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index bd423401e..3ec9b8e49 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -4,7 +4,11 @@ import type { UiEditorPageController } from '../useUiEditorPage'; import { ImportOverview } from './ImportOverview'; import { UiTreePanel } from './UiTreePanel'; -export function InputSidebar({ controller }: { controller: UiEditorPageController }) { +export function InputSidebar({ + controller, +}: { + controller: UiEditorPageController; +}) { // TODO: AssetImporter 支持字体文件后,在这里增加独立的项目字体面板。 const { projectPath, @@ -58,7 +62,11 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle >
{previewUrls[id] ? ( - + ) : ( )} @@ -68,8 +76,8 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle {image.metadata.name} - {image.metadata.role ?? '自动判断'} · {image.pixel_size[0]} ×{' '} - {image.pixel_size[1]} + {image.metadata.role ?? '自动判断'} ·{' '} + {image.pixel_size[0]} × {image.pixel_size[1]} @@ -135,9 +143,28 @@ export function InputSidebar({ controller }: { controller: UiEditorPageControlle {activeTool !== 'input' ? ( { + controller.insertNode(parentId); + }} + onInsertNodeAfter={(nodeId) => { + controller.insertNodeAfter(nodeId); + }} + onDeleteNode={(nodeId) => { + controller.deleteNode(nodeId); + }} + onMoveNode={(nodeId, targetParentId, targetIndex) => { + if (!activeImageId) return; + controller.moveNode( + activeImageId, + nodeId, + targetParentId, + targetIndex, + ); + }} /> ) : null} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index 68366e4aa..a520f5397 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -1,4 +1,10 @@ import { Boxes, Trash2 } from 'lucide-react'; +import type { + InputHTMLAttributes, + ReactNode, + SelectHTMLAttributes, + TextareaHTMLAttributes, +} from 'react'; import type { UIDesignImageId } from '../../../../features/ui-editor/types/UIDesignImageId'; import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UIDesignImageRole'; @@ -6,172 +12,420 @@ import { UI_DESIGN_IMAGE_ROLES } from '../../model'; import type { UiEditorPageController } from '../../useUiEditorPage'; import { PrerequisiteIssues } from '../PrerequisiteIssues'; import { SpriteBorderEditor } from './SpriteBorder/SpriteBorderEditor'; +import TransformEditor from './Transform/TransformEditor'; + +type SelectedNode = NonNullable; +type SelectedSprite = NonNullable; +type ActiveImage = NonNullable; +type SelectedSpriteId = NonNullable; +type ActiveImageId = NonNullable; + +type InspectorView = + | { + kind: 'node'; + node: SelectedNode; + parentSize: UiEditorPageController['selectedNodeParentSize']; + } + | { + kind: 'sprite'; + sprite: SelectedSprite; + spriteId: SelectedSpriteId; + previewUrl: string | undefined; + referenceCount: number; + } + | { + kind: 'image'; + image: ActiveImage; + imageId: ActiveImageId; + pageOptions: UiEditorPageController['pageOptions']; + } + | { kind: 'empty' }; + +const INSPECTOR_VIEW_TITLES: Record = { + node: '当前节点', + sprite: '当前素材', + image: '当前界面', + empty: '当前界面', +}; + +const INSPECTOR_CONTROL_BASE_CLASS_NAME = + 'mt-1 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 text-xs text-(--platform-text-strong)'; +const INSPECTOR_INPUT_CLASS_NAME = `${INSPECTOR_CONTROL_BASE_CLASS_NAME} h-9 px-2`; +const INSPECTOR_TEXTAREA_CLASS_NAME = `${INSPECTOR_CONTROL_BASE_CLASS_NAME} min-h-20 resize-y px-2 py-2 leading-5`; export function InspectorSidebar({ controller, }: { controller: UiEditorPageController; }) { - const { - activeImage, - activeImageId, - selectedSprite, - selectedSpriteId, - previewUrls, - spriteReferenceCounts, - pageOptions, - } = controller; + const view = getInspectorView(controller); return (