diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 7a5228442..c2cae9af5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -107,6 +107,7 @@ export function PreviewWorkspace({ logicalSize, spaceHeld, tree, + keepChildrenUnchanged: canvas.keepChildrenUnchanged, viewportRef, onPreviewTransform: updatePreviewTransform, }); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index d2ac9e82a..74f652acb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -9,9 +9,12 @@ import { import { findNodePageContext, type PageRect, + pageRectFromSize, type ResizeAxis, type ResizeHandle, resizePageRect, + resolveChildrenTransformsForParentRect, + resolvePageRect, resolveProportionalResizeAxis, setOffsetsForPageRect, } from '../../../../features/ui-editor/nodeTransformGeometry'; @@ -30,6 +33,7 @@ type GestureBase = { startTransform: UiNode['layout']['transform']; hasMoved: boolean; pendingTransform?: UiNode['layout']['transform']; + previewNodeIds: string[]; }; type ActiveGesture = @@ -79,12 +83,69 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function previewNodeIds( + tree: UITree | null, + nodeId: string, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, +) { + if (!keepChildrenUnchanged || !tree || !logicalSize) return [nodeId]; + const context = findNodePageContext( + tree.root, + nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + return context + ? [nodeId, ...context.node.children.map((child) => child.id)] + : [nodeId]; +} + +function emitPreviewTransforms( + gesture: ActiveGesture, + tree: UITree | null, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, + onPreviewTransform: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void, +) { + onPreviewTransform(gesture.nodeId, gesture.pendingTransform ?? null); + if ( + !gesture.pendingTransform || + !keepChildrenUnchanged || + !tree || + !logicalSize + ) { + return; + } + const context = findNodePageContext( + tree.root, + gesture.nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + if (!context) return; + const newNodeRect = resolvePageRect( + gesture.pendingTransform, + context.parentRect, + ); + if (!isFiniteRect(newNodeRect)) return; + for (const child of resolveChildrenTransformsForParentRect( + context.node.children, + context.rect, + newNodeRect, + )) { + onPreviewTransform(child.id, child.transform); + } +} + export function useNodeTransformInteraction({ activeImageId, canvas, logicalSize, spaceHeld, tree, + keepChildrenUnchanged, viewportRef, onPreviewTransform, }: { @@ -93,6 +154,7 @@ export function useNodeTransformInteraction({ logicalSize: { width: number; height: number } | null; spaceHeld: boolean; tree: UITree | null; + keepChildrenUnchanged: boolean; viewportRef: RefObject; onPreviewTransform?: ( nodeId: string, @@ -109,6 +171,11 @@ export function useNodeTransformInteraction({ if (gesture) { releasePointer(gesture.target, gesture.pointerId); onPreviewTransformRef.current?.(gesture.nodeId, null); + for (const nodeId of gesture.previewNodeIds) { + if (nodeId !== gesture.nodeId) { + onPreviewTransformRef.current?.(nodeId, null); + } + } } activeGestureRef.current = null; }, []); @@ -163,9 +230,22 @@ export function useNodeTransformInteraction({ startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, spaceHeld, tree?.root.id], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodePointerMove = useCallback( @@ -198,9 +278,23 @@ export function useNodeTransformInteraction({ return; } gesture.pendingTransform = nextTransform; - onPreviewTransform?.(gesture.nodeId, nextTransform); + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); const onNodePointerUp = useCallback( @@ -276,9 +370,22 @@ export function useNodeTransformInteraction({ parentRect: context.parentRect, ratioAxis: null, hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, logicalSize, spaceHeld, tree], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodeResizePointerMove = useCallback( @@ -336,9 +443,23 @@ export function useNodeTransformInteraction({ } gesture.hasMoved = true; gesture.pendingTransform = nextTransform; - onPreviewTransform?.(gesture.nodeId, nextTransform); + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); return { 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 abc6c8159..e00d86176 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 @@ -1214,6 +1214,7 @@ export function useUiEditorSession( tree: treeForActiveImage ?? null, selectedNode: selectedNodeContext?.node ?? null, selectedNodeId, + keepChildrenUnchanged, hiddenNodeIds, focusRequest, status, diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 6482858fe..bd5f2071a 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -44,6 +44,17 @@ const pageTree: UITree = { root: node('root', [child]), }; +function transformedNode( + id: string, + transform: UiNode['layout']['transform'], + children: UiNode[] = [], +) { + return { + ...node(id, children), + layout: { ...node(id).layout, transform }, + }; +} + function gestureTarget() { const target = document.createElement('div'); Object.assign(target, { @@ -83,10 +94,17 @@ function renderInteraction({ activeImageId = 'page', scale = 1, tree = pageTree, + keepChildrenUnchanged = false, + onPreviewTransform, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; + keepChildrenUnchanged?: boolean; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -99,6 +117,8 @@ function renderInteraction({ spaceHeld: false, tree: currentTree, viewportRef, + keepChildrenUnchanged, + onPreviewTransform, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -106,6 +126,97 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('previews stable child page rectangles when the parent moves', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), parent); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 1, + 'parent', + expect.objectContaining({ offset_min: [30, 25], offset_max: [130, 105] }), + ); + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + + it('previews stable child page rectangles when the parent resizes', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodeResizePointerDown( + pointerEvent(target, 1, 0, 0), + parent, + 'nw', + ); + result.current.onNodeResizePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + it('makes drag and resize mutually exclusive, then permits the next gesture', () => { const { result, canvasProjection } = renderInteraction(); const dragTarget = gestureTarget();