From ed092b3688ba5f1696f04890c78ff7a679e7f84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 19 Aug 2026 13:25:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E7=94=A8=20UI=20=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽取页面级节点右键菜单并接入 UI Tree 在编辑叠加预览中支持节点右键操作 补充菜单交互测试与技术方案约定 --- .../ui-editor/components/InputSidebar.tsx | 1 + .../components/UiNodeContextMenu.tsx | 137 ++++++++++++++++++ .../view/ui-editor/components/UiTreePanel.tsx | 64 ++------ .../components/preview/PreviewWorkspace.tsx | 39 +++++ .../components/preview/UiTreeRenderer.tsx | 19 ++- .../tests/uiEditorPreview.test.tsx | 92 +++++++++++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + 7 files changed, 305 insertions(+), 53 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeContextMenu.tsx 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 3c8c952e5..660c75faa 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 @@ -362,6 +362,7 @@ export function InputSidebar({ controller.deleteNode(nodeId, treeId); }} onMoveNode={(request) => controller.moveNode(request)} + isLocked={controller.editor.isLocked} /> ); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeContextMenu.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeContextMenu.tsx new file mode 100644 index 000000000..78257a191 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeContextMenu.tsx @@ -0,0 +1,137 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import type { NodeId } from '../../../features/ui-editor/types/NodeId'; + +const VIEWPORT_GUTTER = 8; +const MENU_WIDTH = 176; +const PAGE_ROOT_MENU_HEIGHT = 48; +const NODE_MENU_HEIGHT = 120; + +type ContextMenuPosition = { + x: number; + y: number; +}; + +type UiNodeContextMenuProps = { + nodeId: NodeId; + position: ContextMenuPosition; + isPageRoot: boolean; + disabled?: boolean; + onClose: () => void; + onInsertChild: (nodeId: NodeId) => void; + onInsertSibling: (nodeId: NodeId) => void; + onDelete: (nodeId: NodeId) => void; +}; + +function clampMenuPosition(position: ContextMenuPosition, isPageRoot: boolean) { + if (typeof window === 'undefined') return position; + const height = isPageRoot ? PAGE_ROOT_MENU_HEIGHT : NODE_MENU_HEIGHT; + return { + x: Math.max( + VIEWPORT_GUTTER, + Math.min(position.x, window.innerWidth - MENU_WIDTH - VIEWPORT_GUTTER), + ), + y: Math.max( + VIEWPORT_GUTTER, + Math.min(position.y, window.innerHeight - height - VIEWPORT_GUTTER), + ), + }; +} + +export function UiNodeContextMenu({ + nodeId, + position, + isPageRoot, + disabled = false, + onClose, + onInsertChild, + onInsertSibling, + onDelete, +}: UiNodeContextMenuProps) { + const menuRef = useRef(null); + const [menuPosition, setMenuPosition] = useState(() => + clampMenuPosition(position, isPageRoot), + ); + + useLayoutEffect(() => { + setMenuPosition(clampMenuPosition(position, isPageRoot)); + }, [isPageRoot, position]); + + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as globalThis.Node)) { + onClose(); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + onClose(); + }; + const close = () => onClose(); + window.addEventListener('pointerdown', onPointerDown); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('blur', close); + window.addEventListener('resize', close); + window.addEventListener('scroll', close, true); + return () => { + window.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('blur', close); + window.removeEventListener('resize', close); + window.removeEventListener('scroll', close, true); + }; + }, [onClose]); + + if (typeof document === 'undefined') return null; + + const run = (action: (id: NodeId) => void) => { + if (disabled) return; + onClose(); + action(nodeId); + }; + + return createPortal( +
+ + {!isPageRoot ? ( + <> + + + + ) : null} +
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index 574150a77..80e26f4e9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -18,6 +18,7 @@ import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiEditorNodeFocusRequest } from '../model'; +import { UiNodeContextMenu } from './UiNodeContextMenu'; type UiTreePanelProps = { root: UiNode | null; @@ -31,6 +32,7 @@ type UiTreePanelProps = { onInsertNodeAfter: (treeId: UIDesignImageId, nodeId: NodeId) => void; onDeleteNode: (treeId: UIDesignImageId, nodeId: NodeId) => void; onMoveNode: (request: UiNodeMoveRequest) => void; + isLocked: boolean; }; function TreeRow({ @@ -118,6 +120,7 @@ export function UiTreePanel({ onInsertNodeAfter, onDeleteNode, onMoveNode, + isLocked, }: UiTreePanelProps) { const treeApiRef = useRef | undefined>(undefined); const [contextMenu, setContextMenu] = useState<{ @@ -126,17 +129,6 @@ export function UiTreePanel({ y: number; } | null>(null); - useEffect(() => { - if (!contextMenu) return; - const close = () => setContextMenu(null); - window.addEventListener('click', close); - window.addEventListener('blur', close); - return () => { - window.removeEventListener('click', close); - window.removeEventListener('blur', close); - }; - }, [contextMenu]); - useEffect(() => { if (!focusRequest) return; void treeApiRef.current?.scrollTo(focusRequest.nodeId, 'center'); @@ -240,46 +232,16 @@ export function UiTreePanel({ )} {contextMenu && root && contextTreeId ? ( -
event.stopPropagation()} - > - - {!pageRootIds.has(contextMenu.nodeId) ? ( - <> - - - - ) : null} -
+ setContextMenu(null)} + onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)} + onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)} + onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)} + /> ) : null} ); 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 6cfee1416..3aa5058ca 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 @@ -14,6 +14,7 @@ import { } from '@genarrative/image-canvas-react'; import { Image as ImageIcon, Minus, Plus } from 'lucide-react'; import { + type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, @@ -22,7 +23,9 @@ import { useState, } from 'react'; +import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorPageController } from '../../useUiEditorPage'; +import { UiNodeContextMenu } from '../UiNodeContextMenu'; import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs'; import { findNodePageContext } from './nodeTransformGeometry'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; @@ -46,6 +49,12 @@ export function PreviewWorkspace({ const [renderMode, setRenderMode] = useState('editor-overlay'); const [showFrame, setShowFrame] = useState(false); + const [contextMenu, setContextMenu] = useState<{ + nodeId: NodeId; + x: number; + y: number; + isPageRoot: boolean; + } | null>(null); const tree = controller.treeForActiveImage ?? null; const activeImagePixelWidth = activeImage?.pixel_size[0]; const activeImagePixelHeight = activeImage?.pixel_size[1]; @@ -280,6 +289,23 @@ export function PreviewWorkspace({ ); }; + const handleNodeContextMenu = ( + event: ReactMouseEvent, + node: NonNullable['root'], + isPageRoot: boolean, + ) => { + setContextMenu({ + nodeId: node.id, + x: event.clientX, + y: event.clientY, + isPageRoot, + }); + }; + + useEffect(() => { + if (renderMode !== 'editor-overlay') setContextMenu(null); + }, [renderMode]); + return (
@@ -383,6 +409,7 @@ export function PreviewWorkspace({ fontFaces: controller.fontFaces, }} onSelectNode={controller.selectNode} + onNodeContextMenu={handleNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} @@ -394,6 +421,18 @@ export function PreviewWorkspace({ + {contextMenu ? ( + setContextMenu(null)} + onInsertChild={(nodeId) => controller.insertNode(nodeId)} + onInsertSibling={(nodeId) => controller.insertNodeAfter(nodeId)} + onDelete={(nodeId) => controller.deleteNode(nodeId)} + /> + ) : null} void; + onNodeContextMenu: ( + event: ReactMouseEvent, + node: UiNode, + isPageRoot: boolean, + ) => void; onNodePointerDown: NodePointerDown; onNodePointerMove: (event: ReactPointerEvent) => void; onNodePointerUp: (event: ReactPointerEvent) => void; @@ -69,6 +77,7 @@ function RenderNode({ selectedNodeId, resources, onSelectNode, + onNodeContextMenu, onNodePointerDown, onNodePointerMove, onNodePointerUp, @@ -129,6 +138,13 @@ function RenderNode({ event.stopPropagation(); onSelectNode(node.id); }} + onContextMenu={(event) => { + if (!isEditorOverlay) return; + event.preventDefault(); + event.stopPropagation(); + onSelectNode(node.id); + onNodeContextMenu(event, node, Boolean(isRoot)); + }} onPointerDown={ parentContainer ? undefined : (event) => onNodePointerDown(event, node) } @@ -166,6 +182,7 @@ function RenderNode({ selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} + onNodeContextMenu={onNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 83e3b82ea..a687bdb30 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -7,6 +7,7 @@ import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { UITree } from '../src/features/ui-editor/types/UITree'; import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ExclusiveChildrenTabs'; import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer'; +import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu'; function node(id: string, children: UiNode[] = []): UiNode { return { @@ -58,7 +59,11 @@ const resources = { function renderTree( renderMode: 'editor-overlay' | 'final-preview', hiddenNodeIds: ReadonlySet, - options: { showFrame?: boolean; selectedNodeId?: string | null } = {}, + options: { + showFrame?: boolean; + selectedNodeId?: string | null; + onNodeContextMenu?: ReturnType; + } = {}, ) { return render( { + it('shares node menu restrictions, disabled actions, and dismissal behavior', () => { + const onClose = vi.fn(); + const onInsertChild = vi.fn(); + const onInsertSibling = vi.fn(); + const onDelete = vi.fn(); + render( + , + ); + + const menu = screen.getByRole('menu') as HTMLDivElement; + expect(menu.style.left).toBe(`${window.innerWidth - 184}px`); + expect(menu.style.top).toBe(`${window.innerHeight - 56}px`); + expect( + ( + screen.getByRole('menuitem', { + name: '新增子节点', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(screen.queryByRole('menuitem', { name: '新增同级节点' })).toBeNull(); + expect( + screen.queryByRole('menuitem', { name: '删除节点及子节点' }), + ).toBeNull(); + fireEvent.click(screen.getByRole('menuitem', { name: '新增子节点' })); + expect(onInsertChild).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it('maps a Godot HBoxContainer to preview CSS and disables child free-transform handles', () => { const hboxTree: UITree = { src_ui_design: 'page', @@ -101,6 +146,7 @@ describe('UI tree preview visibility', () => { selectedNodeId="first" resources={resources} onSelectNode={vi.fn()} + onNodeContextMenu={vi.fn()} onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} @@ -177,6 +223,7 @@ describe('UI tree preview visibility', () => { selectedNodeId={null} resources={resources} onSelectNode={vi.fn()} + onNodeContextMenu={vi.fn()} onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} @@ -234,4 +281,47 @@ describe('UI tree preview visibility', () => { fireEvent.click(screen.getByRole('tab', { name: 'child-b' })); expect(onToggleChild).toHaveBeenCalledWith('child-b'); }); + + it('opens a context-menu target only from editor overlay nodes', () => { + const onNodeContextMenu = vi.fn(); + const onSelectNode = vi.fn(); + const editorOverlay = render( + , + ); + const child = editorOverlay.container.querySelector( + '[data-node-id="child"]', + ) as HTMLDivElement; + fireEvent.contextMenu(child, { clientX: 32, clientY: 48 }); + expect(onSelectNode).toHaveBeenCalledWith('child'); + expect(onNodeContextMenu).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'child' }), + false, + ); + editorOverlay.unmount(); + + const finalPreview = renderTree('final-preview', new Set(), { + onNodeContextMenu, + }); + fireEvent.contextMenu( + finalPreview.container.querySelector('[data-node-id="child"]')!, + ); + expect(onNodeContextMenu).toHaveBeenCalledTimes(1); + }); }); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 12d0bdb8a..a51988c73 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,11 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-08-19 UI Editor 节点右键菜单 + +左侧 `UI Tree` 与预览画布复用同一个节点右键菜单组件和条目模型,顺序固定为“新增子节点 → 新增同级节点 → 删除节点及子节点”。页面根仅显示“新增子节点”;虚拟超级根不提供菜单。预览仅在“编辑叠加”模式中响应已渲染节点的鼠标右键,右键会选中该节点并阻止嵌套节点事件冒泡;“最终预览”以及画布空白处保留原有行为,空白处不接管浏览器原生菜单。 + +菜单由页面级 portal 呈现,使用屏幕坐标且在视口边缘内收,避免受预览画布缩放和容器裁切影响。点击菜单外、按 Escape、窗口失焦、滚动或调整窗口大小均关闭菜单。全局 `controller.editor.isLocked` 时,节点仍可选中和查看菜单,但所有结构修改项禁用;状态层的 mutation 校验继续作为最终防线。点击可用项先关闭菜单再执行现有 controller 动作,不改变既有新增或删除后的选择策略。 + ## 2026-08-19 UI Editor 预览等比角点缩放 UI Editor 预览中按住 Shift 拖动四个角点时,以本次进入等比缩放时相对原尺寸变化更大的轴作为主轴(完全相等时取水平轴),并在本次指针拖动内锁定。主轴按手柄方向和位移符号决定放大或缩小;另一轴只由初始宽高比推导,对角保持固定。不得再从两个轴各自推导的候选尺寸中取较大值,否则一轴放大、另一轴缩小时会覆盖较小轴的输入,导致控制点看似朝鼠标反方向跳动。锁定后,受比例驱动的从轴可以不跟随鼠标在该轴的位移,这是等比约束的明确结果。