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 7607f7387..1af37815c 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 @@ -1155,7 +1155,8 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const moveNode = useCallback( ( - treeId: UIDesignImageId, + sourceTreeId: UIDesignImageId, + targetTreeId: UIDesignImageId, nodeId: NodeId, targetParentId: NodeId, targetIndex: number, @@ -1166,68 +1167,52 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { return { ok: false, reason: 'invalid' }; } const current = stateRef.current; - const tree = current.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, + const sourceTree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === sourceTreeId, ); - 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) { + const targetTree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === targetTreeId, + ); + if (!sourceTree || !targetTree) return { ok: false, reason: 'missing' }; + const source = findNodeLocation(sourceTree.root, nodeId); + const target = findNodeLocation(targetTree.root, targetParentId); + if (!source || !target || source.node.id === sourceTree.root.id) { return { ok: false, reason: 'missing' }; } if ( - source.node.id === target.node.id || - containsNode(source.node, targetParentId) + sourceTreeId !== targetTreeId && + containsNode(targetTree.root, nodeId) ) { - return { ok: false, reason: 'invalid' }; + return { ok: false, reason: 'duplicate' }; } - 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) + sourceTreeId === targetTreeId && + (source.node.id === target.node.id || + containsNode(source.node, targetParentId)) ) { 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 nextSourceTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === sourceTreeId, )!; - const nextSource = findNodeLocation(nextTree.root, nodeId)!; - const nextTarget = findNodeLocation(nextTree.root, targetParentId)!.node; + const nextTargetTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === targetTreeId, + )!; + const nextSource = findNodeLocation(nextSourceTree.root, nodeId)!; + const nextTarget = findNodeLocation( + nextTargetTree.root, + targetParentId, + )!.node; + if (targetIndex > nextTarget.children.length) { + return { ok: false, reason: 'invalid' }; + } 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' }; + // TODO: Cross-tree moves intentionally preserve the local transform. + // Coordinate conversion between design images can be added separately. 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 b4ed82b66..40fa2ceb2 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,9 +1,24 @@ import { Image as ImageIcon, Plus, ScanSearch } from 'lucide-react'; +import { useMemo } from 'react'; +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 { UiEditorPageController } from '../useUiEditorPage'; import { ImportOverview } from './ImportOverview'; 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, }: { @@ -19,11 +34,42 @@ export function InputSidebar({ images, sprites, spriteReferenceCounts, - treeForActiveImage, selectedNodeId, focusRequest, } = controller; + const treeIdByNodeId = useMemo(() => { + const result = new Map(); + for (const tree of editor.state.ui_trees) { + visitTreeNodes(tree.root, tree.src_ui_design, result); + } + return result; + }, [editor.state.ui_trees]); + + const superRoot = useMemo(() => { + if (editor.state.ui_trees.length === 0) return null; + return { + id: SUPER_ROOT_ID, + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + metadata: { + name: 'UI Trees', + description: '', + layout_status: 'Passed', + components_status: 'Passed', + allow_llm_edit_layout: false, + allow_llm_edit_component: false, + source: 'System', + }, + components: [], + children: editor.state.ui_trees.map((tree) => tree.root), + }; + }, [editor.state.ui_trees]); + return (