From f5f94111cac0af439a832f9c3264aac2ca3a0128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 20:02:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?Feat/=E5=9B=BE=E7=89=87=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E6=94=B9=E7=94=A8=E5=B8=A6=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E7=9A=84=E6=A8=A1=E6=80=81=E6=A1=86=20(#269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before: ![image.png](/attachments/5ae7df1f-cf57-4712-8038-f65ae83e2ffa) after: ![shotmd-1788417438.jpg](/attachments/676e653d-2561-4eca-b28f-9725d3b8af02) close #268 --------- Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/269 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../components/SpriteImagePreview.tsx | 3 + .../ui-editor/components/InputSidebar.tsx | 3 +- .../Inspector/Components/ComponentPanel.tsx | 1 + .../Components/ImageAssetSelector.tsx | 165 ++++++++++++++++++ .../Inspector/Components/ImagePanel.tsx | 27 +-- .../Components/componentEditorTypes.ts | 2 + .../components/Inspector/InspectorSidebar.tsx | 4 + .../tests/imageAssetSelector.test.tsx | 161 +++++++++++++++++ ...�前端架构】UI编辑会话模块边界-2026-08-19.md | 2 + ...端设计】UI编辑器图片素材选择器-2026-09-03.md | 41 +++++ 10 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImageAssetSelector.tsx create mode 100644 apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx create mode 100644 docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx index a724c4e42..98071bd37 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx +++ b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx @@ -1,3 +1,6 @@ +export const SPRITE_CHECKERBOARD_CLASS_NAME = + 'bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]'; + export function SpriteImagePreview({ src, alt, 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 83e8ff1ae..495db5bd9 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,6 +1,7 @@ import { Image as ImageIcon, Plus, Type } from 'lucide-react'; import { useMemo } from 'react'; +import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../features/ui-editor/components/SpriteImagePreview'; import { visitUiNodes } from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; @@ -184,7 +185,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { + + + + +
+

选择图片素材

+ +
+ +
+
+ + + {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({ = {}, + 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/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`。 From 8d13e868bd9b9efed7a6088eb59052ab23548613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Fri, 4 Sep 2026 11:24:21 +0800 Subject: [PATCH 2/3] =?UTF-8?q?AGC=20=E6=9B=B4=E6=96=B0=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20X=20=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE?= =?UTF-8?q?=20(#276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 为 AGC 更新提示增加可访问的 X 关闭按钮。 - 复用现有 dismiss 逻辑,手动关闭后立即移除提示。 - 增加关闭按钮的 hover、禁用和图标样式。 ## 验证 - npm --prefix apps/ai-game-creator-shell run typecheck - npm exec vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts - npm run check:encoding - git diff --check Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/276 Reviewed-by: 段舒康 Co-authored-by: 孔令弘 Co-committed-by: 孔令弘 --- .../src/components/AppUpdateNotice.tsx | 12 ++++++++++- apps/ai-game-creator-shell/src/styles.css | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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' ? (
Date: Fri, 4 Sep 2026 16:30:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?Fix/=E4=BF=AE=E5=A4=8Dui=20editor=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=80=BB=E8=BE=91=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close #266 当前实现: 按下就重新选择 实际上应该: 松开而且没有发生拖动才重新选择 before: ![shotmd-1788407266-compressed.webp](/attachments/251eccf6-ae02-4ae6-b9d2-4eb1fbf67df3) after: ![shotmd-1788418953-compressed.webp](/attachments/5c05b506-ae08-4cb1-83c8-8da8e1590a7f) Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/270 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../components/preview/PreviewWorkspace.tsx | 8 ++ .../components/preview/UiTreeRenderer.tsx | 41 ++++-- .../preview/useNodeTransformInteraction.ts | 108 ++++++++++++--- .../tests/uiEditorPreview.test.tsx | 40 +++++- .../useNodeTransformInteraction.test.tsx | 128 ++++++++++++++++++ ...方案】UI编辑器Godot容器布局模型-2026-08-18.md | 8 +- 6 files changed, 299 insertions(+), 34 deletions(-) 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 c2cae9af5..75a1efce3 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 @@ -110,6 +110,8 @@ export function PreviewWorkspace({ keepChildrenUnchanged: canvas.keepChildrenUnchanged, viewportRef, onPreviewTransform: updatePreviewTransform, + previewRef: viewportElementRef, + selectedNodeId: canvas.selectedNodeId, }); const { onNodePointerDown, @@ -118,6 +120,9 @@ export function PreviewWorkspace({ onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodePointerCancel, + onNodeResizePointerCancel, + consumeNodeClick, } = nodeInteractions; const setViewport = useCallback((next: CanvasViewport) => { @@ -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/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编辑器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 被浏览器解释为文本选取。