diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts new file mode 100644 index 000000000..9e044efb8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts @@ -0,0 +1,16 @@ +import type { Node as UiNode } from './types/Node'; +import type { NodeId } from './types/NodeId'; + +export function visitUiNodes( + root: UiNode, + visit: (node: UiNode) => void, +): void { + visit(root); + for (const child of root.children) visitUiNodes(child, visit); +} + +export function collectUiNodeIds(root: UiNode): Set { + const ids = new Set(); + visitUiNodes(root, (node) => ids.add(node.id)); + return ids; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UiNodeMoveRequest.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UiNodeMoveRequest.ts new file mode 100644 index 000000000..11d021c1c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UiNodeMoveRequest.ts @@ -0,0 +1,11 @@ +import type { NodeId } from './NodeId'; +import type { UIDesignImageId } from './UIDesignImageId'; + +/** UI 节点移动的完整上下文,避免在树视图与状态层之间散落五个并行参数。 */ +export type UiNodeMoveRequest = { + sourceTreeId: UIDesignImageId; + targetTreeId: UIDesignImageId; + nodeId: NodeId; + targetParentId: NodeId; + targetIndex: number; +}; 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 95f585a63..bf81a6fc0 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 @@ -14,6 +14,7 @@ import type { State } from './types/State'; import type { UIDesignImage } from './types/UIDesignImage'; import type { UIDesignImageId } from './types/UIDesignImageId'; import type { UIDesignImageRole } from './types/UIDesignImageRole'; +import type { UiNodeMoveRequest } from './types/UiNodeMoveRequest'; export const EMPTY_UI_EDITOR_STATE: State = { ui_trees: [], @@ -1151,13 +1152,13 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { ); const moveNode = useCallback( - ( - sourceTreeId: UIDesignImageId, - targetTreeId: UIDesignImageId, - nodeId: NodeId, - targetParentId: NodeId, - targetIndex: number, - ): UiEditorOperationResult => { + ({ + sourceTreeId, + targetTreeId, + nodeId, + targetParentId, + targetIndex, + }: UiNodeMoveRequest): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; if (!Number.isInteger(targetIndex) || targetIndex < 0) { @@ -1189,6 +1190,44 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { ) { return { ok: false, reason: 'invalid' }; } + let nextTransform: Node['transform'] | null = null; + if (sourceTreeId === targetTreeId) { + const image = current.ui_design_images[sourceTreeId]; + if (!image) return { ok: false, reason: 'missing' }; + 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(sourceTree.root, nodeId, logicalSize); + const targetPageRect = findNodePageRect( + targetTree.root, + targetParentId, + logicalSize, + ); + if ( + !pageRect || + pageRect.size.some((value) => value <= 0) || + !targetPageRect || + targetPageRect.size.some((value) => value <= 0) + ) { + return { ok: false, reason: 'invalid' }; + } + nextTransform = setOffsetsForPageRect( + source.node.transform, + pageRect, + targetPageRect, + ); + if (!nextTransform) return { ok: false, reason: 'invalid' }; + } const next = cloneState(current); const nextSourceTree = next.ui_trees.find( (candidate) => candidate.src_ui_design === sourceTreeId, @@ -1208,8 +1247,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { ? nextSource.parent.children.splice(nextSource.index, 1) : []; if (!moved) return { ok: false, reason: 'invalid' }; - // TODO: Cross-tree moves intentionally preserve the local transform. - // Coordinate conversion between design images can be added separately. + // 跨树时只保留节点原本的局部 transform;源、目标界面图的画布和父级坐标空间独立, + // “保留 transform”不等于承诺页面位置稳定。只有同树移动才在上面做页面矩形换算。 + if (nextTransform) moved.transform = nextTransform; nextTarget.children.splice(targetIndex, 0, moved); commit(next); return { ok: true, value: undefined }; 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 424aedbde..49d76bfb4 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 @@ -1,6 +1,7 @@ import { Image as ImageIcon, Plus, ScanSearch, Type } from 'lucide-react'; import { useMemo } from 'react'; +import { visitUiNodes } from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; @@ -10,15 +11,6 @@ import { UiTreePanel } from './UiTreePanel'; const SUPER_ROOT_ID = '__ui-editor-super-root__'; -function visitTreeNodes( - node: UiNode, - treeId: UIDesignImageId, - result: Map, -) { - result.set(node.id, treeId); - for (const child of node.children) visitTreeNodes(child, treeId, result); -} - export function InputSidebar({ controller, }: { @@ -44,21 +36,13 @@ export function InputSidebar({ const treeIdByNodeId = useMemo(() => { const result = new Map(); for (const tree of editor.state.ui_trees) { - visitTreeNodes(tree.root, tree.src_ui_design, result); + visitUiNodes(tree.root, (node) => + result.set(node.id, tree.src_ui_design), + ); } return result; }, [editor.state.ui_trees]); - const hiddenNodeIdsByTree = useMemo(() => { - const result = new Map>(); - for (const [treeId, hiddenNodeIds] of Object.entries( - controller.hiddenNodeIdsByImage, - )) { - result.set(treeId, new Set(Object.keys(hiddenNodeIds))); - } - return result; - }, [controller.hiddenNodeIdsByImage]); - const superRoot = useMemo(() => { if (editor.state.ui_trees.length === 0) return null; return { @@ -330,15 +314,13 @@ export function InputSidebar({ selectedNodeId={selectedNodeId} focusRequest={focusRequest} treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null} - hiddenNodeIdsForTree={(treeId) => - hiddenNodeIdsByTree.get(treeId) ?? new Set() - } + hiddenNodeIds={controller.hiddenNodeIds} onSelectNode={(treeId, nodeId) => { controller.selectDesignImage(treeId); controller.selectNode(nodeId); }} - onToggleNodeVisibility={(treeId, nodeId) => - controller.toggleNodePreviewVisibility(nodeId, treeId) + onToggleNodeVisibility={(nodeId) => + controller.toggleNodePreviewVisibility(nodeId) } onInsertNode={(treeId, parentId) => { controller.insertNode(parentId, treeId); @@ -349,21 +331,7 @@ export function InputSidebar({ onDeleteNode={(treeId, nodeId) => { controller.deleteNode(nodeId, treeId); }} - onMoveNode={( - sourceTreeId, - targetTreeId, - nodeId, - targetParentId, - targetIndex, - ) => { - controller.moveNode( - sourceTreeId, - targetTreeId, - nodeId, - targetParentId, - targetIndex, - ); - }} + onMoveNode={(request) => controller.moveNode(request)} /> ); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index a99396730..901983ec9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -16,28 +16,21 @@ import { import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; +import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiEditorNodeFocusRequest } from '../model'; -const EMPTY_HIDDEN_NODE_IDS = new Set(); - type UiTreePanelProps = { root: UiNode | null; selectedNodeId: NodeId | null; focusRequest: UiEditorNodeFocusRequest | null; treeIdForNode: (nodeId: NodeId) => UIDesignImageId | null; - hiddenNodeIdsForTree: (treeId: UIDesignImageId) => ReadonlySet; + hiddenNodeIds: ReadonlySet; onSelectNode: (treeId: UIDesignImageId, id: NodeId) => void; - onToggleNodeVisibility: (treeId: UIDesignImageId, id: NodeId) => void; + onToggleNodeVisibility: (id: NodeId) => void; onInsertNode: (treeId: UIDesignImageId, parentId: NodeId) => void; onInsertNodeAfter: (treeId: UIDesignImageId, nodeId: NodeId) => void; onDeleteNode: (treeId: UIDesignImageId, nodeId: NodeId) => void; - onMoveNode: ( - sourceTreeId: UIDesignImageId, - targetTreeId: UIDesignImageId, - nodeId: NodeId, - targetParentId: NodeId, - targetIndex: number, - ) => void; + onMoveNode: (request: UiNodeMoveRequest) => void; }; function TreeRow({ @@ -118,7 +111,7 @@ export function UiTreePanel({ selectedNodeId, focusRequest, treeIdForNode, - hiddenNodeIdsForTree, + hiddenNodeIds, onSelectNode, onToggleNodeVisibility, onInsertNode, @@ -171,7 +164,13 @@ export function UiTreePanel({ dragIds, siblingIds: destinationIds, }); - onMoveNode(sourceTreeId, targetTreeId, nodeId, parentId, targetIndex); + onMoveNode({ + sourceTreeId, + targetTreeId, + nodeId, + targetParentId: parentId, + targetIndex, + }); }; const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []); @@ -212,7 +211,6 @@ export function UiTreePanel({ aria-label="UI 节点树" > {(props) => { - const treeId = treeIdForNode(props.node.data.id); return ( { - const nodeTreeId = treeIdForNode(nodeId); - if (nodeTreeId) onToggleNodeVisibility(nodeTreeId, nodeId); - }} + hiddenNodeIds={hiddenNodeIds} + onToggleNodeVisibility={onToggleNodeVisibility} onOpenContextMenu={(event, nodeId) => nodeId === root.id ? undefined diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index a371e5cb5..d3d6bea75 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -19,6 +19,7 @@ import { validateVisualBindingResult, } from '../../features/ui-editor/prerequisites'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; +import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { Component } from '../../features/ui-editor/types/Component'; import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId'; @@ -31,6 +32,7 @@ import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder'; import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole'; import type { UIDesignSuggestionTreeNode } from '../../features/ui-editor/types/UIDesignSuggestionTreeNode'; +import type { UiNodeMoveRequest } from '../../features/ui-editor/types/UiNodeMoveRequest'; import { mockUiDesignStateStore, type UiDesignStateStore, @@ -58,18 +60,6 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; const ASSET_BATCH_SIZE = 5; -type HiddenNodeIdsByImage = Record>; - -function collectNodeIds(root: UiNode): Set { - const ids = new Set(); - const visit = (node: UiNode) => { - ids.add(node.id); - for (const child of node.children) visit(child); - }; - visit(root); - return ids; -} - export type PendingWorkflowStepChange = { from: UiEditorStepId; to: UiEditorStepId; @@ -102,8 +92,10 @@ export function useUiEditorPage( null, ); const [selectedNodeId, setSelectedNodeId] = useState(null); - const [hiddenNodeIdsByImage, setHiddenNodeIdsByImage] = - useState({}); + // NodeId 在 State 内全局唯一;隐藏状态直接记录节点 ID,跨树移动时随节点保留。 + const [hiddenNodeIds, setHiddenNodeIds] = useState>( + () => new Set(), + ); const { focusRequest, focusNode } = useUiEditorNodeFocus({ activateImage: setActiveImageId, clearSpriteSelection: () => setSelectedSpriteId(null), @@ -137,7 +129,7 @@ export function useUiEditorPage( setIsLoading(false); setLoadError(null); setPersistedRevision(0); - setHiddenNodeIdsByImage({}); + setHiddenNodeIds(new Set()); return; } let cancelled = false; @@ -156,7 +148,7 @@ export function useUiEditorPage( setActiveImageId(ids[0] ?? null); setSelectedNodeId(null); setSelectedSpriteId(null); - setHiddenNodeIdsByImage({}); + setHiddenNodeIds(new Set()); setPreviewUrls({}); setPersistedRevision(revision); if (projectPath) { @@ -277,83 +269,44 @@ export function useUiEditorPage( : null; useEffect(() => { - setHiddenNodeIdsByImage((current) => { - let changed = false; - const next: HiddenNodeIdsByImage = {}; - for (const [treeId, hiddenNodeIds] of Object.entries(current)) { - const tree = editor.state.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - if (!tree) { - changed = true; - continue; - } - const validNodeIds = collectNodeIds(tree.root); - const filtered = Object.fromEntries( - Object.keys(hiddenNodeIds) - .filter((nodeId) => validNodeIds.has(nodeId)) - .map((nodeId) => [nodeId, true]), - ) as Record; - if ( - Object.keys(filtered).length !== Object.keys(hiddenNodeIds).length - ) { - changed = true; - } - if (Object.keys(filtered).length > 0) { - next[treeId as UIDesignImageId] = filtered; - } + const validNodeIds = new Set(); + for (const tree of editor.state.ui_trees) { + for (const nodeId of collectUiNodeIds(tree.root)) { + validNodeIds.add(nodeId); } - return changed ? next : current; + } + setHiddenNodeIds((current) => { + const next = new Set( + [...current].filter((nodeId) => validNodeIds.has(nodeId)), + ); + return next.size === current.size ? current : next; }); }, [editor.state.ui_trees]); - const hiddenNodeIds = useMemo>( - () => - new Set( - activeImageId - ? Object.keys(hiddenNodeIdsByImage[activeImageId] ?? {}) - : [], - ), - [activeImageId, hiddenNodeIdsByImage], - ); - const isNodePreviewVisible = useCallback( - (nodeId: NodeId, treeId = activeImageId) => - !(treeId ? hiddenNodeIdsByImage[treeId]?.[nodeId] : false), - [activeImageId, hiddenNodeIdsByImage], + (nodeId: NodeId) => !hiddenNodeIds.has(nodeId), + [hiddenNodeIds], ); const setNodePreviewVisible = useCallback( - (nodeId: NodeId, visible: boolean, treeId = activeImageId) => { - if (!treeId) return; - setHiddenNodeIdsByImage((current) => { - const currentForImage = current[treeId] ?? {}; - const currentlyVisible = !currentForImage[nodeId]; + (nodeId: NodeId, visible: boolean) => { + setHiddenNodeIds((current) => { + const currentlyVisible = !current.has(nodeId); if (currentlyVisible === visible) return current; - const nextForImage = { ...currentForImage }; - if (visible) delete nextForImage[nodeId]; - else nextForImage[nodeId] = true; - const next = { ...current }; - if (Object.keys(nextForImage).length > 0) { - next[treeId] = nextForImage; - } else { - delete next[treeId]; - } + const next = new Set(current); + if (visible) next.delete(nodeId); + else next.add(nodeId); return next; }); }, - [activeImageId], + [], ); const toggleNodePreviewVisibility = useCallback( - (nodeId: NodeId, treeId = activeImageId) => { - setNodePreviewVisible( - nodeId, - !isNodePreviewVisible(nodeId, treeId), - treeId, - ); + (nodeId: NodeId) => { + setNodePreviewVisible(nodeId, !isNodePreviewVisible(nodeId)); }, - [activeImageId, isNodePreviewVisible, setNodePreviewVisible], + [isNodePreviewVisible, setNodePreviewVisible], ); const findNodeContext = useCallback(function findNodeContext( @@ -460,12 +413,6 @@ export function useUiEditorPage( if (id === selectedSpriteId) setSelectedSpriteId(null); if (id === selectedFontId) setSelectedFontId(null); if (id !== activeImageId) return; - setHiddenNodeIdsByImage((current) => { - if (!(id in current)) return current; - const next = { ...current }; - delete next[id as UIDesignImageId]; - return next; - }); setSelectedNodeId(null); const index = imageOrder.indexOf(id); const remaining = imageOrder.filter((item) => item !== id); @@ -598,7 +545,7 @@ export function useUiEditorPage( setActiveImageId(null); setSelectedSpriteId(null); setSelectedNodeId(null); - setHiddenNodeIdsByImage({}); + setHiddenNodeIds(new Set()); setPreviewUrls({}); setClearOpen(false); } @@ -687,20 +634,8 @@ export function useUiEditorPage( return result; } - function moveNode( - sourceTreeId: UIDesignImageId, - targetTreeId: UIDesignImageId, - nodeId: NodeId, - targetParentId: NodeId, - targetIndex: number, - ) { - const result = editor.moveNode( - sourceTreeId, - targetTreeId, - nodeId, - targetParentId, - targetIndex, - ); + function moveNode(request: UiNodeMoveRequest) { + const result = editor.moveNode(request); if (!result.ok) { setStatus( result.reason === 'locked' @@ -928,7 +863,6 @@ export function useUiEditorPage( activeImage, selectedSprite, selectedFont, - hiddenNodeIdsByImage, hiddenNodeIds, isNodePreviewVisible, setNodePreviewVisible, diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 7acd1e907..01f717feb 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -158,7 +158,7 @@ describe('UiEditorPage', () => { expect(result.current.selectedSpriteId).toBe('panel'); }); - it('keeps node preview visibility transient and scoped to each interface image', () => { + it('keeps node preview visibility transient and attached to global node IDs', () => { const { result } = renderHook(() => useUiEditorPage('/tmp/ui-editor')); act(() => { result.current.editor.addDesignImages([ @@ -209,7 +209,7 @@ describe('UiEditorPage', () => { expect(JSON.stringify(result.current.editor.state)).toBe(stateBeforeToggle); act(() => result.current.selectDesignImage('page-b')); - expect(result.current.hiddenNodeIds.size).toBe(0); + expect(result.current.hiddenNodeIds.has(rootId)).toBe(true); act(() => result.current.selectDesignImage('page-a')); expect(result.current.hiddenNodeIds.has(rootId)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 9435efccf..b1895c657 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -122,7 +122,13 @@ describe('useUiEditorState', () => { act(() => { expect( - result.current.moveNode('source', 'target', 'moved', 'target-root', 0), + result.current.moveNode({ + sourceTreeId: 'source', + targetTreeId: 'target', + nodeId: 'moved', + targetParentId: 'target-root', + targetIndex: 0, + }), ).toEqual({ ok: true, value: undefined }); }); @@ -133,6 +139,72 @@ describe('useUiEditorState', () => { }); }); + it('keeps page position stable when reparenting within one UI tree', () => { + const moved = nodeWithSprite('moved'); + moved.transform = { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }; + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + ui_trees: [ + { + src_ui_design: 'page', + root: pageRoot('page-root', [ + { + ...pageRoot('source-parent'), + transform: { + anchor_min: [0, 0], + anchor_max: [0.5, 0.5], + offset_min: [0, 0], + offset_max: [0, 0], + }, + children: [moved], + }, + { + ...pageRoot('target-parent'), + transform: { + anchor_min: [0.5, 0.5], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + }, + ]), + }, + ], + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + expect( + result.current.moveNode({ + sourceTreeId: 'page', + targetTreeId: 'page', + nodeId: 'moved', + targetParentId: 'target-parent', + targetIndex: 0, + }), + ).toEqual({ ok: true, value: undefined }); + }); + + expect( + result.current.state.ui_trees[0]?.root.children[0]?.children, + ).toEqual([]); + expect( + result.current.state.ui_trees[0]?.root.children[1]?.children[0] + ?.transform, + ).toMatchObject({ + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [-50, -40], + offset_max: [-50, -40], + }); + }); + it('exposes focused operations over one shared state', () => { const { result } = renderHook(() => useUiEditorState());