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 94740ffe8..f6c9973d5 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 @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react'; -import type { Component } from './types/Component'; import { validateSpriteBorder } from './spriteBorder'; +import type { Component } from './types/Component'; import type { Node } from './types/Node'; import type { NodeId } from './types/NodeId'; import type { NodeMetadata } from './types/NodeMetadata'; @@ -38,6 +38,12 @@ export type NodeMetadataPatch = Partial< Pick >; +export type ComponentIndex = number; + +export type NodeTransformOptions = { + keepChildrenUnchanged?: boolean; +}; + type Rect = { min: [number, number]; size: [number, number]; @@ -112,14 +118,10 @@ function createHumanNode(state: State): Node { }; } -function synchronizePageTrees(state: State): void { - const pageIds = new Set( - Object.entries(state.ui_design_images) - .filter(([, image]) => image.metadata.role === 'Page') - .map(([id]) => id), - ); +function synchronizeDesignImageTrees(state: State): void { + const imageIds = new Set(Object.keys(state.ui_design_images)); state.ui_trees = state.ui_trees.filter((tree) => - pageIds.has(tree.src_ui_design), + imageIds.has(tree.src_ui_design), ); for (const [id] of Object.entries(state.ui_design_images)) { if ( @@ -247,6 +249,140 @@ function visitComponents(nodes: Node[], visit: (component: Component) => void) { } } +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isFinitePositive(value: unknown): value is number { + return isFiniteNumber(value) && value > 0; +} + +function isEnumValue( + value: unknown, + values: readonly T[], +): value is T { + return typeof value === 'string' && values.includes(value as T); +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' + ? (value as Record) + : null; +} + +function isValidFillMethod(method: unknown): boolean { + const record = asRecord(method); + if (!record) return false; + if ('Horizontal' in record) { + return isEnumValue(record.Horizontal, ['Left', 'Right'] as const); + } + if ('Vertical' in record) { + return isEnumValue(record.Vertical, ['Bottom', 'Top'] as const); + } + for (const key of ['Radial90', 'Radial180', 'Radial360'] as const) { + if (key in record) { + const radial = asRecord(record[key]); + if (!radial) return false; + return ( + typeof radial.clockwise === 'boolean' && + isEnumValue( + radial.origin, + key === 'Radial90' + ? (['BottomLeft', 'TopLeft', 'TopRight', 'BottomRight'] as const) + : key === 'Radial180' + ? (['Bottom', 'Left', 'Top', 'Right'] as const) + : (['Bottom', 'Right', 'Top', 'Left'] as const), + ) + ); + } + } + return false; +} + +function isValidImageType(imageType: unknown): boolean { + const record = asRecord(imageType); + if (!record) return false; + if ('Simple' in record) { + const value = asRecord(record.Simple); + if (!value) return false; + return typeof value.preserve_aspect === 'boolean'; + } + if ('Sliced' in record || 'Tiled' in record) { + const value = asRecord('Sliced' in record ? record.Sliced : record.Tiled); + return ( + value !== null && + typeof value.fill_center === 'boolean' && + isFinitePositive(value.pixels_per_unit_multiplier) + ); + } + if ('Filled' in record) { + const value = asRecord(record.Filled); + return ( + value !== null && + typeof value.preserve_aspect === 'boolean' && + isFiniteNumber(value.amount) && + value.amount >= 0 && + value.amount <= 1 && + isValidFillMethod(value.method) + ); + } + return false; +} + +function isValidComponent(component: Component): boolean { + if ('Image' in component) { + return ( + (component.Image.target_graphic === null || + typeof component.Image.target_graphic === 'string') && + isValidImageType(component.Image.image_type) + ); + } + if ('Text' in component) { + const text = component.Text; + const colorValid = + Array.isArray(text.color) && + text.color.length === 4 && + text.color.every( + (channel) => + Number.isInteger(channel) && channel >= 0 && channel <= 255, + ); + const sizingValid = + ('Fixed' in text.font_sizing && + isFinitePositive(text.font_sizing.Fixed)) || + ('BestFit' in text.font_sizing && + isFinitePositive(text.font_sizing.BestFit.min) && + isFinitePositive(text.font_sizing.BestFit.max) && + text.font_sizing.BestFit.min <= text.font_sizing.BestFit.max); + return ( + typeof text.content === 'string' && + (text.font === null || typeof text.font === 'string') && + isEnumValue(text.font_style, [ + 'Normal', + 'Bold', + 'Italic', + 'BoldItalic', + ] as const) && + sizingValid && + colorValid && + isEnumValue(text.alignment, [ + 'UpperLeft', + 'UpperCenter', + 'UpperRight', + 'MiddleLeft', + 'MiddleCenter', + 'MiddleRight', + 'LowerLeft', + 'LowerCenter', + 'LowerRight', + ] as const) && + isEnumValue(text.horizontal_overflow, ['Wrap', 'Overflow'] as const) && + isEnumValue(text.vertical_overflow, ['Truncate', 'Overflow'] as const) && + isFinitePositive(text.line_spacing) + ); + } + return false; +} + export function designImageRemovalImpact( state: State, id: UIDesignImageId, @@ -311,7 +447,7 @@ function spriteResourceValidationError(sprite: SpriteAsset) { export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const [state, setState] = useState(() => { const next = cloneState(initialState); - synchronizePageTrees(next); + synchronizeDesignImageTrees(next); return next; }); const [isLocked, setIsLocked] = useState(false); @@ -396,7 +532,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { } const next = cloneState(current); next.ui_design_images[id]!.metadata.role = role; - synchronizePageTrees(next); + synchronizeDesignImageTrees(next); commit(next); return { ok: true, value: undefined }; }, @@ -456,7 +592,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { for (const entry of entries) { next.ui_design_images[entry.id] = structuredClone(entry.image); } - synchronizePageTrees(next); + synchronizeDesignImageTrees(next); commit(next); return { ok: true, value: undefined }; }, @@ -634,6 +770,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { treeId: UIDesignImageId, nodeId: NodeId, transform: Node['transform'], + options: NodeTransformOptions = {}, ): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; @@ -661,8 +798,223 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const nextTree = next.ui_trees.find( (candidate) => candidate.src_ui_design === treeId, )!; - findNodeLocation(nextTree.root, nodeId)!.node.transform = - structuredClone(transform); + const nextNode = findNodeLocation(nextTree.root, nodeId)!.node; + nextNode.transform = structuredClone(transform); + if (options.keepChildrenUnchanged && location.parent) { + const image = current.ui_design_images[treeId]; + if ( + !image || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + return { ok: false, reason: 'invalid' }; + } + const pageRect: Rect = { + min: [0, 0], + size: [ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ], + }; + const oldParentRect = findNodePageRect( + tree.root, + location.parent.id, + pageRect, + ); + const oldNodeRect = findNodePageRect(tree.root, nodeId, pageRect); + if ( + !oldParentRect || + !isValidRect(oldParentRect) || + !oldNodeRect || + !isValidRect(oldNodeRect) + ) { + return { ok: false, reason: 'invalid' }; + } + const newNodeRect = resolveNodeRect(transform, oldParentRect); + if (!isValidRect(newNodeRect)) { + return { ok: false, reason: 'invalid' }; + } + const childTransforms = location.node.children.map((child) => { + const childRect = resolveNodeRect(child.transform, oldNodeRect); + return { + id: child.id, + transform: setOffsetsForPageRect( + child.transform, + childRect, + newNodeRect, + ), + }; + }); + if (childTransforms.some((child) => child.transform === null)) { + return { ok: false, reason: 'invalid' }; + } + for (const child of childTransforms) { + const nextChild = nextNode.children.find( + (candidate) => candidate.id === child.id, + ); + if (!nextChild || !child.transform) { + return { ok: false, reason: 'invalid' }; + } + nextChild.transform = child.transform; + } + } + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeComponents = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + components: Component[], + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!components.every(isValidComponent)) { + 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' }; + 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.components = + structuredClone(components); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const insertComponent = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + index: ComponentIndex, + component: Component, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if ( + !isValidComponent(component) || + !Number.isInteger(index) || + index < 0 + ) { + 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' }; + if ( + location.node.id === tree.root.id || + index > location.node.components.length + ) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextNode = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + nextNode.components.splice(index, 0, structuredClone(component)); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const deleteComponent = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + index: ComponentIndex, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!Number.isInteger(index) || index < 0) + 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' }; + if ( + location.node.id === tree.root.id || + index >= location.node.components.length + ) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextNode = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + nextNode.components.splice(index, 1); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const moveComponent = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + fromIndex: ComponentIndex, + toIndex: ComponentIndex, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if ( + !Number.isInteger(fromIndex) || + !Number.isInteger(toIndex) || + fromIndex < 0 || + toIndex < 0 + ) { + 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' }; + if ( + location.node.id === tree.root.id || + fromIndex >= location.node.components.length || + toIndex >= location.node.components.length + ) { + return { ok: false, reason: 'invalid' }; + } + if (fromIndex === toIndex) return { ok: true, value: undefined }; + const next = cloneState(current); + const nextNode = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + const [component] = nextNode.components.splice(fromIndex, 1); + if (!component) return { ok: false, reason: 'invalid' }; + nextNode.components.splice(toIndex, 0, component); commit(next); return { ok: true, value: undefined }; }, @@ -844,7 +1196,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const replaceState = useCallback( (nextState: State) => { const next = cloneState(nextState); - synchronizePageTrees(next); + synchronizeDesignImageTrees(next); commit(next); }, [commit], @@ -868,6 +1220,10 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { insertNodeAfter, deleteNode, setNodeTransform, + setNodeComponents, + insertComponent, + deleteComponent, + moveComponent, setNodeMetadata, moveNode, removeDesignImage, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 54679c4d8..df615cc4c 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,7 +1,7 @@ import { EditorDialogs } from './components/EditorDialogs'; import { InputSidebar } from './components/InputSidebar'; import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; -import { PreviewWorkspace } from './components/PreviewWorkspace'; +import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; import { ToolNavigation } from './components/ToolNavigation'; import { useUiEditorPage } from './useUiEditorPage'; @@ -18,7 +18,7 @@ export default function UiEditorPage({ activeTool={controller.activeTool} onChange={controller.selectTool} /> -
+
@@ -35,6 +35,24 @@ export default function UiEditorPage({ {controller.recognitionStatus}

) : null} + {controller.mergeStatus ? ( +

+ {controller.mergeStatus} +

+ ) : null} +