diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 7cc1c8444..fa9630719 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -1,5 +1,5 @@ import { listen } from '@tauri-apps/api/event'; -import { Download, LoaderCircle } from 'lucide-react'; +import { Download, LoaderCircle, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { APP_VERSION } from '../app/appMetadata'; @@ -117,6 +117,16 @@ export function AppUpdateNotice() { > {isDownloading ? '正在下载…' : '下载更新'} + {downloadState !== 'idle' ? (
input.selectSprite(sprite.asset_id)} > diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx index 081431a48..ab030488d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx @@ -293,6 +293,7 @@ function renderComponentEditor( updateComponent(index, { Image: next })} /> diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx new file mode 100644 index 000000000..4ab6255f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx @@ -0,0 +1,165 @@ +import { Check, ImageIcon, X } from 'lucide-react'; +import { useState } from 'react'; + +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; +import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../../../features/ui-editor/components/SpriteImagePreview'; +import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset'; + +export function ImageAssetSelector({ + value, + sprites, + previewUrls, + readOnly, + onChange, +}: { + value: string | null; + sprites: Record; + previewUrls: Record; + readOnly: boolean; + onChange: (value: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const [failedPreviewUrls, setFailedPreviewUrls] = useState< + Record + >({}); + const selectedSprite = value ? sprites[value] : undefined; + let selectedLabel = '未绑定'; + if (selectedSprite) { + selectedLabel = selectedSprite.metadata.name || value || '未绑定'; + } else if (value) { + selectedLabel = `素材不存在(${value})`; + } + const buttonLabel = value ? '更换素材' : '选择素材'; + + function close() { + setOpen(false); + } + + function select(nextValue: string | null) { + onChange(nextValue); + close(); + } + + function markPreviewFailed(id: string, url: string) { + setFailedPreviewUrls((current) => { + if (current[id] === url) return current; + return { ...current, [id]: url }; + }); + } + + return ( + <> +
+ 目标素材 +
+
+ {selectedLabel} +
+ +
+
+ + +
+

选择图片素材

+ +
+ +
+
+ + + {value && !selectedSprite ? ( +
+
+ ) : null} + + {Object.entries(sprites).map(([id, sprite]) => { + const previewUrl = previewUrls[id]; + const hasPreview = + Boolean(previewUrl) && failedPreviewUrls[id] !== previewUrl; + const selected = value === id; + return ( + + ); + })} +
+ {Object.keys(sprites).length === 0 ? ( +

+ 暂无可用素材 +

+ ) : null} +
+
+ + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx index 176021783..06b3e8b53 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx @@ -4,10 +4,12 @@ import type { ImageType } from '../../../../../features/ui-editor/types/ImageTyp import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; import type { ImageEditorProps } from './componentEditorTypes'; import { ComponentNumberInput, ComponentSelect } from './ComponentField'; +import { ImageAssetSelector } from './ImageAssetSelector'; export function ImagePanel({ component, sprites, + previewUrls, readOnly, onChange, }: ImageEditorProps) { @@ -17,24 +19,13 @@ export function ImagePanel({ return (
- - update({ - ...component, - target_graphic: event.target.value || null, - }) - } - > - - {Object.entries(sprites).map(([id, sprite]) => ( - - ))} - + update({ ...component, target_graphic })} + /> ; + previewUrls: Record; fonts: Record; fontFaces: Record; projectPath: string; @@ -35,6 +36,7 @@ export type ComponentEditorProps = { export type ImageEditorProps = ComponentEditorProps & { sprites: Record; + previewUrls: Record; }; export type TextEditorProps = ComponentEditorProps & { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index ca0d35db0..7557c3727 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -111,6 +111,7 @@ export function InspectorSidebar({ onTransformChange={inspector.setNodeTransform} onLayoutChange={inspector.setNodeLayout} sprites={inspector.sprites} + previewUrls={inspector.previewUrls} fonts={inspector.fonts} fontFaces={inspector.fontFaces} projectPath={inspector.projectPath} @@ -288,6 +289,7 @@ function NodeInspector({ onTransformChange, onLayoutChange, sprites, + previewUrls, fonts, fontFaces, projectPath, @@ -317,6 +319,7 @@ function NodeInspector({ onTransformChange: UiEditorInspectorProjection['setNodeTransform']; onLayoutChange: UiEditorInspectorProjection['setNodeLayout']; sprites: UiEditorInspectorProjection['sprites']; + previewUrls: UiEditorInspectorProjection['previewUrls']; fonts: UiEditorInspectorProjection['fonts']; fontFaces: UiEditorInspectorProjection['fontFaces']; projectPath: string; @@ -497,6 +500,7 @@ function NodeInspector({ { @@ -412,13 +417,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 38d1b9454..34f624152 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 @@ -36,6 +36,7 @@ type UiTreeRendererProps = { selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; + consumeNodeClick?: () => boolean; onNodeContextMenu: ( event: ReactMouseEvent, node: UiNode, @@ -44,6 +45,7 @@ type UiTreeRendererProps = { onNodePointerDown: NodePointerDown; onNodePointerMove: (event: ReactPointerEvent) => void; onNodePointerUp: (event: ReactPointerEvent) => void; + onNodePointerCancel: (event: ReactPointerEvent) => void; onNodeResizePointerDown: ( event: ReactPointerEvent, node: UiNode, @@ -51,6 +53,7 @@ type UiTreeRendererProps = { ) => void; onNodeResizePointerMove: (event: ReactPointerEvent) => void; onNodeResizePointerUp: (event: ReactPointerEvent) => void; + onNodeResizePointerCancel: (event: ReactPointerEvent) => void; onSelectExclusiveChild: (nodeId: NodeId) => void; viewportScale: number; }; @@ -87,13 +90,16 @@ function RenderNode({ selectedNodeId, resources, onSelectNode, + consumeNodeClick = () => false, onNodeContextMenu, onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodeResizePointerCancel, onSelectExclusiveChild, viewportScale, }: Omit & { @@ -118,6 +124,8 @@ function RenderNode({ const isEditorOverlay = renderMode === 'editor-overlay'; const isFrameVisible = isEditorOverlay || showFrame; + const receivesPointerGesture = parentContainer === undefined; + const hasDirectPointerGesture = receivesPointerGesture && !isRoot; const exclusiveVisibleChildId = node.children_display_mode === 'Exclusive' ? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds) @@ -125,7 +133,7 @@ function RenderNode({ return (
{ - 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(); @@ -158,11 +174,13 @@ function RenderNode({ onNodeContextMenu(event, node, Boolean(isRoot)); }} onPointerDown={ - parentContainer ? undefined : (event) => onNodePointerDown(event, node) + receivesPointerGesture + ? (event) => onNodePointerDown(event, node) + : undefined } - onPointerMove={parentContainer ? undefined : onNodePointerMove} - onPointerUp={parentContainer ? undefined : onNodePointerUp} - onPointerCancel={parentContainer ? undefined : onNodePointerUp} + onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined} + onPointerUp={receivesPointerGesture ? onNodePointerUp : undefined} + onPointerCancel={receivesPointerGesture ? onNodePointerCancel : undefined} title={isFrameVisible ? node.metadata.name || undefined : undefined} > {isFrameVisible && node.metadata.name ? ( @@ -202,13 +220,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} /> @@ -238,7 +259,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 74f652acb..9b79de2d7 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 @@ -31,6 +31,7 @@ type GestureBase = { startClientX: number; startClientY: number; startTransform: UiNode['layout']['transform']; + hitNodeId: string; hasMoved: boolean; pendingTransform?: UiNode['layout']['transform']; previewNodeIds: string[]; @@ -83,6 +84,41 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function findNodeById(node: UiNode, nodeId: string): UiNode | null { + if (node.id === nodeId) return node; + for (const child of node.children) { + const match = findNodeById(child, nodeId); + if (match) return match; + } + return null; +} + +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 + ); +} + function previewNodeIds( tree: UITree | null, nodeId: string, @@ -148,6 +184,8 @@ export function useNodeTransformInteraction({ keepChildrenUnchanged, viewportRef, onPreviewTransform, + previewRef, + selectedNodeId, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -160,11 +198,14 @@ export function useNodeTransformInteraction({ nodeId: string, transform: UiNode['layout']['transform'] | null, ) => void; + previewRef?: RefObject; + selectedNodeId?: string | null; }) { const activeGestureRef = useRef(null); // Keep cleanup stable while still invoking the latest preview callback. const onPreviewTransformRef = useRef(onPreviewTransform); onPreviewTransformRef.current = onPreviewTransform; + const suppressNextNodeClickRef = useRef(false); const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; @@ -178,10 +219,16 @@ export function useNodeTransformInteraction({ } } activeGestureRef.current = null; + suppressNextNodeClickRef.current = false; }, []); useEffect(() => cancelGesture, [cancelGesture]); + useEffect(() => { + window.addEventListener('blur', cancelGesture); + return () => window.removeEventListener('blur', cancelGesture); + }, [cancelGesture]); + useEffect(() => { const gesture = activeGestureRef.current; if ( @@ -217,22 +264,29 @@ export function useNodeTransformInteraction({ ) { return; } + const selectedNode = + selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null; + const dragNode = + selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node; + if (!isFiniteTransform(dragNode.layout.transform)) return; event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; + event.preventDefault(); activeGestureRef.current = { kind: 'drag', treeId: activeImageId, - nodeId: node.id, + nodeId: dragNode.id, pointerId: event.pointerId, target: event.currentTarget, startClientX: event.clientX, startClientY: event.clientY, - startTransform: structuredClone(node.layout.transform), + startTransform: structuredClone(dragNode.layout.transform), + hitNodeId: resolveHitNodeId(event, node.id), hasMoved: false, previewNodeIds: previewNodeIds( tree, - node.id, + dragNode.id, logicalSize, keepChildrenUnchanged, ), @@ -240,10 +294,10 @@ export function useNodeTransformInteraction({ }, [ activeImageId, - canvas, keepChildrenUnchanged, logicalSize, spaceHeld, + selectedNodeId, tree, ], ); @@ -268,6 +322,7 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; const nextTransform = structuredClone(gesture.startTransform); nextTransform.offset_min[0] += logicalDeltaX; nextTransform.offset_min[1] += logicalDeltaY; @@ -302,11 +357,11 @@ export function useNodeTransformInteraction({ if (!acceptsGestureEvent(event)) return; event.stopPropagation(); const gesture = activeGestureRef.current; - if ( - event.type !== 'pointercancel' && - gesture?.hasMoved && - gesture.pendingTransform - ) { + if (!gesture) return; + const cleanPointerUpInside = + !gesture?.hasMoved && isInsidePreview(event, previewRef); + if (cleanPointerUpInside) canvas.selectNode(gesture.hitNodeId); + if (gesture.hasMoved && gesture.pendingTransform) { canvas.updateNodeTransform( gesture.treeId, gesture.nodeId, @@ -314,8 +369,19 @@ export function useNodeTransformInteraction({ ); } cancelGesture(); + if (gesture.hasMoved || cleanPointerUpInside) + suppressNextNodeClickRef.current = true; }, - [acceptsGestureEvent, cancelGesture, canvas], + [acceptsGestureEvent, cancelGesture, canvas, previewRef], + ); + + const onNodePointerCancel = useCallback( + (event: ReactPointerEvent) => { + if (!acceptsGestureEvent(event)) return; + event.stopPropagation(); + cancelGesture(); + }, + [acceptsGestureEvent, cancelGesture], ); const onNodeResizePointerDown = useCallback( @@ -355,7 +421,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, @@ -366,6 +432,7 @@ 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, @@ -378,14 +445,7 @@ export function useNodeTransformInteraction({ ), }; }, - [ - activeImageId, - canvas, - keepChildrenUnchanged, - logicalSize, - spaceHeld, - tree, - ], + [activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree], ); const onNodeResizePointerMove = useCallback( @@ -442,6 +502,7 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; gesture.pendingTransform = nextTransform; emitPreviewTransforms( gesture, @@ -463,11 +524,18 @@ export function useNodeTransformInteraction({ ); return { + consumeNodeClick: useCallback(() => { + if (!suppressNextNodeClickRef.current) return false; + suppressNextNodeClickRef.current = false; + return true; + }, []), onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp: onNodePointerUp, + onNodeResizePointerCancel: onNodePointerCancel, }; } diff --git a/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx new file mode 100644 index 000000000..a012f2d60 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx @@ -0,0 +1,161 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ImageAssetSelector } from '../src/view/ui-editor/components/Inspector/Components/ImageAssetSelector'; + +const sprites = { + hero: { + asset_id: 'hero', + metadata: { name: '主角', asset_type: 'sprite' }, + path: 'assets/hero.png', + pixel_size: [32, 32] as [number, number], + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }, + enemy: { + asset_id: 'enemy', + metadata: { name: '敌人', asset_type: 'sprite' }, + path: 'assets/enemy.png', + pixel_size: [32, 32] as [number, number], + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }, +}; + +function renderSelector( + value: string | null = null, + onChange = vi.fn(), + previewUrls: Record = {}, + selectorSprites: typeof sprites = sprites, +) { + return render( + , + ); +} + +describe('ImageAssetSelector', () => { + afterEach(() => cleanup()); + + it('selects a sprite and clears the selection from the modal', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSelector(null, onChange); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + expect(screen.getByRole('dialog', { name: '选择图片素材' })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /主角/ })); + expect(onChange).toHaveBeenCalledWith('hero'); + expect(screen.queryByRole('dialog')).toBeNull(); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + await user.click(screen.getByRole('button', { name: /清除选择/ })); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('shows preview images, placeholders, and invalid references', async () => { + const user = userEvent.setup(); + renderSelector('missing', vi.fn(), { hero: 'data:image/png;base64,abc' }); + + expect(screen.getByText('素材不存在(missing)')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '更换素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + expect(within(dialog).getByAltText('主角')).toBeTruthy(); + expect(within(dialog).getByText('素材不存在')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: /清除选择/ }), + ).toBeTruthy(); + }); + + it('cannot open while read only', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '更换素材' })).toHaveProperty( + 'disabled', + true, + ); + }); + + it('retries a preview when its URL is regenerated', async () => { + const user = userEvent.setup(); + const view = renderSelector(null, vi.fn(), { + hero: 'data:image/png;base64,first', + }); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + const image = within(dialog).getByAltText('主角'); + fireEvent.error(image); + expect(within(dialog).queryByAltText('主角')).toBeNull(); + + view.rerender( + , + ); + + expect( + within(screen.getByRole('dialog', { name: '选择图片素材' })).getByAltText( + '主角', + ), + ).toBeTruthy(); + }); + + it('supports keyboard activation for sprite cards', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSelector(null, onChange); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const hero = screen.getByRole('button', { name: /主角/ }); + hero.focus(); + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledWith('hero'); + }); + + it('closes when the modal backdrop is clicked', async () => { + const user = userEvent.setup(); + renderSelector(); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + const overlay = dialog.parentElement; + expect(overlay).toBeTruthy(); + await user.click(overlay!); + expect(screen.queryByRole('dialog', { name: '选择图片素材' })).toBeNull(); + }); + + it('shows an empty state when no sprites are available', async () => { + const user = userEvent.setup(); + renderSelector(null, vi.fn(), {}, {}); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + expect(within(dialog).getByText('暂无可用素材')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: /清除选择/ }), + ).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 5b47a87c3..6f79be210 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { UITree } from '../src/features/ui-editor/types/UITree'; @@ -9,6 +9,10 @@ import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer'; import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu'; +afterEach(() => { + cleanup(); +}); + function node(id: string, children: UiNode[] = []): UiNode { return { id, @@ -62,6 +66,7 @@ function renderTree( options: { showFrame?: boolean; selectedNodeId?: string | null; + onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; } = {}, ) { @@ -73,20 +78,41 @@ function renderTree( hiddenNodeIds={hiddenNodeIds} selectedNodeId={options.selectedNodeId ?? null} resources={resources} - onSelectNode={vi.fn()} + onSelectNode={options.onSelectNode ?? vi.fn()} onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()} onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); } describe('UI tree preview visibility', () => { + it('disables native text selection on preview node layers', () => { + const rendered = renderTree('editor-overlay', new Set()); + + expect( + rendered.container + .querySelector('[data-node-id="child"]') + ?.classList.contains('select-none'), + ).toBe(true); + }); + + it('does not use click alone to select directly draggable nodes', () => { + const onSelectNode = vi.fn(); + const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); + fireEvent.click( + rendered.container.querySelector('[data-node-id="child"]')!, + ); + expect(onSelectNode).not.toHaveBeenCalled(); + }); + it('shares node menu restrictions, disabled actions, and dismissal behavior', () => { const onClose = vi.fn(); const onInsertChild = vi.fn(); @@ -150,9 +176,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -227,9 +255,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -299,9 +329,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={onNodePointerDown} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} onSelectExclusiveChild={onSelectChild} viewportScale={1} />, @@ -331,9 +363,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index bd5f2071a..7434c355e 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -70,12 +70,14 @@ function pointerEvent( pointerId: number, clientX: number, clientY: number, + eventTarget: EventTarget = target, ) { return { button: 0, clientX, clientY, currentTarget: target, + target: eventTarget, pointerId, preventDefault: vi.fn(), shiftKey: false, @@ -96,6 +98,7 @@ function renderInteraction({ tree = pageTree, keepChildrenUnchanged = false, onPreviewTransform, + selectedNodeId = null, }: { activeImageId?: string | null; scale?: number; @@ -105,6 +108,7 @@ function renderInteraction({ nodeId: string, transform: UiNode['layout']['transform'] | null, ) => void; + selectedNodeId?: string | null; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -119,6 +123,7 @@ function renderInteraction({ viewportRef, keepChildrenUnchanged, onPreviewTransform, + selectedNodeId, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -126,6 +131,95 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('moves the current selection when dragging another node without selecting the hit node', () => { + const selected = transformedNode('selected', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [80, 70], + }); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [selected, child]), + }; + const { result, canvasProjection } = renderInteraction({ + tree, + selectedNodeId: 'selected', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); + }); + + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith( + 'page', + 'selected', + expect.objectContaining({ + offset_min: [24, 20], + offset_max: [84, 70], + }), + ); + }); + + it('prevents native text selection when starting a node drag', () => { + const { result } = renderInteraction(); + const target = gestureTarget(); + const event = pointerEvent(target, 1, 0, 0); + + act(() => { + result.current.onNodePointerDown(event, child); + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('cleans preview transforms for the selected parent when dragging from a child', () => { + 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, + selectedNodeId: 'parent', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown( + pointerEvent(target, 1, 0, 0), + nestedChild, + ); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + result.current.onNodePointerCancel(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenCalledWith('parent', null); + expect(onPreviewTransform).toHaveBeenCalledWith('child', null); + }); + it('previews stable child page rectangles when the parent moves', () => { const nestedChild = transformedNode('child', { anchor_min: [0, 0], @@ -250,6 +344,40 @@ describe('useNodeTransformInteraction', () => { expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); + it('defers selection until a clean pointerup and suppresses follow-up click', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + act(() => { + result.current.onNodePointerUp(pointerEvent(target, 1, 1, 1)); + }); + expect(canvasProjection.selectNode).toHaveBeenCalledWith('child'); + expect(result.current.consumeNodeClick()).toBe(true); + expect(result.current.consumeNodeClick()).toBe(false); + }); + + it('does not select after a moved gesture or pointer cancellation', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(true); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child); + result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(false); + }); + it('cancels a gesture when its tree changes and ignores its later events', () => { const { result, rerender, canvasProjection } = renderInteraction(); const target = gestureTarget(); diff --git a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md index 6e33010bd..12460d0e0 100644 --- a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md +++ b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md @@ -15,6 +15,8 @@ 预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find` 的 `undefined` 传播到视图接口。 +素材缩略图的加载失败状态按“素材 ID + 预览 URL”记录;同一 URL 失败后显示占位图,资源重新加载或重新导入产生新 URL 时必须允许再次尝试,不能把失败永久绑定到素材 ID。 + 保存与代码生成共享同一份持久化 State/revision。会话层在保存或生成进行期间互斥拦截,且代码生成必须基于已加载的持久化 revision;视图层的保存按钮和“保存并返回”按钮同步遵守该互斥状态。 `UiDesignStateStore` 的 `generateCode(assetId)` 是必需能力,返回成功结果时不得为 nullable;所有注入的 adapter 与测试替身都必须实现该方法。 diff --git a/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md b/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md new file mode 100644 index 000000000..f2b926c74 --- /dev/null +++ b/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md @@ -0,0 +1,41 @@ +# UI 编辑器图片素材选择器 + +> 更新时间:`2026-09-03` + +## 目标 + +在 UI 编辑器 Inspector 的图片组件中,用独立 modal 图片选择器替换原生素材下拉框,使用户绑定素材时可以看到现有预览图或缩略图。 + +## 数据来源与边界 + +- 素材列表继续使用 Inspector 当前的 `sprites`,顺序沿用 `Object.entries(sprites)`。 +- `SpriteAsset` 不新增缩略图字段。预览继续消费 UI 编辑会话已有的 `previewUrls: Record`;预览缺失不影响素材绑定。 +- `previewUrls` 由 Inspector projection 沿 `NodeInspector → ComponentPanel → ImagePanel` 传入选择器。 +- 本次不新增后端接口、持久化字段、搜索筛选或二级大图预览。 + +## 交互合同 + +### Inspector 触发区域 + +- 正常绑定显示素材名称。 +- 空值显示 `未绑定`。 +- 引用不存在的素材显示 `素材不存在()`,保留原 ID,不在打开面板时自动清除。 +- 未绑定时按钮文案为 `选择素材`;已有绑定或失效引用时为 `更换素材`。 +- `readOnly` 时按钮禁用,不能打开选择器。 + +### 素材选择 modal + +- 使用现有 `ThemedModal`,带遮罩、关闭按钮、Esc 关闭和点击遮罩关闭。 +- modal 内容使用纵向 flex;素材区域占据剩余空间并内部滚动,modal 设置最大高度。 +- 素材以弹性网格展示,卡片包含预览图和素材名称,图片使用 `object-contain`。 +- 有预览时显示 `previewUrls[id]`;无预览或加载失败时显示棋盘格背景与 `ImageIcon` 占位,素材仍可选。 +- 当前绑定项显示选中边框或标记。 +- 第一项为明确的“清除选择”,点击立即写入 `null` 并关闭。 +- 点击任意正常素材立即写入其 ID 并关闭;重复点击当前素材也按相同流程处理。 +- 当前失效引用保留为禁用信息卡,显示 ID;用户仍可使用“清除选择”。 +- 素材为空时显示 `暂无可用素材`,保留关闭操作。 +- 卡片支持 Tab 聚焦以及 Enter/Space 选择;关闭后焦点返回触发按钮。 + +## 验收 + +自动化测试覆盖:打开/关闭、按钮和名称状态、当前项高亮、选择 ID、清除 `null`、缺预览占位、失效引用、空列表、`readOnly` 禁止打开、键盘选择和遮罩关闭。实现后运行定向测试、前端类型检查、编码检查及 `git diff --check`。 diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index f9852c817..ec137834f 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -1,6 +1,6 @@ # UI 编辑器 Godot 容器布局模型 -更新时间:`2026-08-18` +更新时间:`2026-09-03` ## 范围 @@ -40,3 +40,9 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa ## 编辑器交互 布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。 + +Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。 + +取消、失焦、树切换或无效几何导致的手势终止会同时清理下一次 click 抑制状态;只有正常 pointerup 才会根据拖动结果抑制后续 click。 + +预览节点层禁止浏览器原生文字选择;拖动手势开始时阻止默认行为,避免 pointer drag 被浏览器解释为文本选取。