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 ec88d9571..61b19bd5f 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 @@ -87,15 +87,19 @@ export function PreviewWorkspace({ logicalSize, spaceHeld, tree, + previewRef: viewportElementRef, viewportRef, }); const { onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodeResizePointerCancel, + consumeNodeClick, } = nodeInteractions; const setViewport = useCallback((next: CanvasViewport) => { @@ -389,13 +393,16 @@ export function PreviewWorkspace({ fontFaces: canvas.fontFaces, }} onSelectNode={canvas.selectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={handleNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={canvas.selectExclusiveChild} viewportScale={viewport.scale} /> diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index ad91c0571..006971164 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -35,6 +35,7 @@ type UiTreeRendererProps = { selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; + consumeNodeClick?: () => boolean; onNodeContextMenu: ( event: ReactMouseEvent, node: UiNode, @@ -43,6 +44,7 @@ type UiTreeRendererProps = { onNodePointerDown: NodePointerDown; onNodePointerMove: (event: ReactPointerEvent) => void; onNodePointerUp: (event: ReactPointerEvent) => void; + onNodePointerCancel?: (event: ReactPointerEvent) => void; onNodeResizePointerDown: ( event: ReactPointerEvent, node: UiNode, @@ -50,6 +52,9 @@ type UiTreeRendererProps = { ) => void; onNodeResizePointerMove: (event: ReactPointerEvent) => void; onNodeResizePointerUp: (event: ReactPointerEvent) => void; + onNodeResizePointerCancel?: ( + event: ReactPointerEvent, + ) => void; onSelectExclusiveChild: (nodeId: NodeId) => void; viewportScale: number; }; @@ -80,13 +85,16 @@ function RenderNode({ selectedNodeId, resources, onSelectNode, + consumeNodeClick = () => false, onNodeContextMenu, onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel = onNodePointerUp, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodeResizePointerCancel = onNodeResizePointerUp, onSelectExclusiveChild, viewportScale, }: Omit & { @@ -109,6 +117,7 @@ function RenderNode({ const isEditorOverlay = renderMode === 'editor-overlay'; const isFrameVisible = isEditorOverlay || showFrame; + const hasDirectPointerGesture = !isRoot && !parentContainer; const exclusiveVisibleChildId = node.children_display_mode === 'Exclusive' ? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds) @@ -137,10 +146,18 @@ function RenderNode({ } : {}), }} - onClick={(event) => { - event.stopPropagation(); - onSelectNode(node.id); - }} + onClick={ + hasDirectPointerGesture + ? (event) => { + event.stopPropagation(); + consumeNodeClick(); + } + : (event) => { + event.stopPropagation(); + if (consumeNodeClick()) return; + onSelectNode(node.id); + } + } onContextMenu={(event) => { if (!isEditorOverlay) return; event.preventDefault(); @@ -153,7 +170,7 @@ function RenderNode({ } onPointerMove={parentContainer ? undefined : onNodePointerMove} onPointerUp={parentContainer ? undefined : onNodePointerUp} - onPointerCancel={parentContainer ? undefined : onNodePointerUp} + onPointerCancel={parentContainer ? undefined : onNodePointerCancel} title={isFrameVisible ? node.metadata.name || undefined : undefined} > {isFrameVisible && node.metadata.name ? ( @@ -192,13 +209,16 @@ function RenderNode({ selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={onNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={onSelectExclusiveChild} viewportScale={viewportScale} /> @@ -228,7 +248,7 @@ function RenderNode({ } onPointerMove={onNodeResizePointerMove} onPointerUp={onNodeResizePointerUp} - onPointerCancel={onNodeResizePointerUp} + onPointerCancel={onNodeResizePointerCancel} /> )) : null} 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 1d22c3531..978799ba6 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 @@ -28,6 +28,7 @@ type GestureBase = { startClientX: number; startClientY: number; startTransform: UiNode['layout']['transform']; + hitNodeId: string; hasMoved: boolean; }; @@ -78,6 +79,32 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function resolveHitNodeId( + event: ReactPointerEvent, + fallback: string, +) { + const target = event.target; + if (!(target instanceof Element)) return fallback; + return ( + target.closest('[data-node-id]')?.dataset.nodeId ?? fallback + ); +} + +function isInsidePreview( + event: ReactPointerEvent, + previewRef: RefObject | undefined, +) { + const preview = previewRef?.current; + if (!preview) return true; + const rect = preview.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); +} + export function useNodeTransformInteraction({ activeImageId, canvas, @@ -85,6 +112,7 @@ export function useNodeTransformInteraction({ spaceHeld, tree, viewportRef, + previewRef, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -92,8 +120,10 @@ export function useNodeTransformInteraction({ spaceHeld: boolean; tree: UITree | null; viewportRef: RefObject; + previewRef?: RefObject; }) { const activeGestureRef = useRef(null); + const suppressNextNodeClickRef = useRef(false); const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; @@ -103,6 +133,12 @@ export function useNodeTransformInteraction({ useEffect(() => cancelGesture, [cancelGesture]); + useEffect(() => { + const cancelOnWindowBlur = () => cancelGesture(); + window.addEventListener('blur', cancelOnWindowBlur); + return () => window.removeEventListener('blur', cancelOnWindowBlur); + }, [cancelGesture]); + useEffect(() => { const gesture = activeGestureRef.current; if ( @@ -140,7 +176,7 @@ export function useNodeTransformInteraction({ } event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; activeGestureRef.current = { kind: 'drag', treeId: activeImageId, @@ -150,10 +186,11 @@ export function useNodeTransformInteraction({ startClientX: event.clientX, startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), + hitNodeId: resolveHitNodeId(event, node.id), hasMoved: false, }; }, - [activeImageId, canvas, spaceHeld, tree?.root.id], + [activeImageId, spaceHeld, tree?.root.id], ); const onNodePointerMove = useCallback( @@ -164,6 +201,8 @@ export function useNodeTransformInteraction({ } event.stopPropagation(); if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return; + gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; const scale = viewportRef.current?.scale; if (!Number.isFinite(scale) || scale <= 0) { cancelGesture(); @@ -175,7 +214,6 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - gesture.hasMoved = true; const nextTransform = structuredClone(gesture.startTransform); nextTransform.offset_min[0] += logicalDeltaX; nextTransform.offset_min[1] += logicalDeltaY; @@ -191,6 +229,21 @@ export function useNodeTransformInteraction({ ); const onNodePointerUp = useCallback( + (event: ReactPointerEvent) => { + const gesture = activeGestureRef.current; + if (!gesture || !acceptsGestureEvent(event)) return; + event.stopPropagation(); + if (gesture.hasMoved) suppressNextNodeClickRef.current = true; + if (!gesture.hasMoved && isInsidePreview(event, previewRef)) { + canvas.selectNode(gesture.hitNodeId); + suppressNextNodeClickRef.current = true; + } + cancelGesture(); + }, + [acceptsGestureEvent, cancelGesture, canvas, previewRef], + ); + + const onNodePointerCancel = useCallback( (event: ReactPointerEvent) => { if (!acceptsGestureEvent(event)) return; event.stopPropagation(); @@ -236,7 +289,7 @@ export function useNodeTransformInteraction({ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; activeGestureRef.current = { kind: 'resize', treeId: activeImageId, @@ -247,13 +300,14 @@ export function useNodeTransformInteraction({ startClientX: event.clientX, startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), + hitNodeId: node.id, startRect: context.rect, parentRect: context.parentRect, ratioAxis: null, hasMoved: false, }; }, - [activeImageId, canvas, logicalSize, spaceHeld, tree], + [activeImageId, logicalSize, spaceHeld, tree], ); const onNodeResizePointerMove = useCallback( @@ -268,6 +322,8 @@ export function useNodeTransformInteraction({ } event.stopPropagation(); if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return; + gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; const scale = viewportRef.current?.scale; if (!Number.isFinite(scale) || scale <= 0) { cancelGesture(); @@ -309,18 +365,26 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - gesture.hasMoved = true; canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); }, [acceptsGestureEvent, cancelGesture, canvas, viewportRef], ); + const consumeNodeClick = useCallback(() => { + if (!suppressNextNodeClickRef.current) return false; + suppressNextNodeClickRef.current = false; + return true; + }, []); + return { onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp: onNodePointerUp, + onNodeResizePointerCancel: onNodePointerCancel, + consumeNodeClick, }; }